├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── bench.yml │ ├── branch.yml │ ├── docker-build.yml │ ├── main.yml │ ├── package.yml │ ├── publish.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── benchmark ├── bench.mojo └── bench_server.mojo ├── client.mojo ├── docker ├── docker-compose.yml └── lightbug.dockerfile ├── lightbug.🔥 ├── lightbug_http ├── __init__.mojo ├── _libc.mojo ├── _logger.mojo ├── _owning_list.mojo ├── address.mojo ├── client.mojo ├── connection.mojo ├── cookie │ ├── __init__.mojo │ ├── cookie.mojo │ ├── duration.mojo │ ├── expiration.mojo │ ├── request_cookie_jar.mojo │ ├── response_cookie_jar.mojo │ └── same_site.mojo ├── error.mojo ├── external │ ├── __init__.mojo │ └── small_time │ │ ├── __init__.mojo │ │ ├── c.mojo │ │ ├── formatter.mojo │ │ ├── small_time.mojo │ │ ├── time_delta.mojo │ │ └── time_zone.mojo ├── header.mojo ├── http │ ├── __init__.mojo │ ├── common_response.mojo │ ├── http_version.mojo │ ├── request.mojo │ └── response.mojo ├── io │ ├── __init__.mojo │ ├── bytes.mojo │ └── sync.mojo ├── pool_manager.mojo ├── server.mojo ├── service.mojo ├── socket.mojo ├── strings.mojo └── uri.mojo ├── magic.lock ├── mojoproject.toml ├── recipes └── recipe.yaml ├── scripts ├── bench_server.sh ├── integration_test.sh ├── publish.sh └── udp_test.sh ├── static ├── lightbug_welcome.html ├── logo.png ├── roadmap.png └── test.txt ├── tests ├── integration │ ├── integration_client.py │ ├── integration_server.py │ ├── integration_test_client.mojo │ ├── integration_test_server.mojo │ ├── test_client.mojo │ ├── test_pool_manager.mojo │ ├── test_server.mojo │ ├── test_socket.mojo │ └── udp │ │ ├── udp_client.mojo │ │ └── udp_server.mojo └── lightbug_http │ ├── cookie │ ├── test_cookie.mojo │ ├── test_cookie_jar.mojo │ ├── test_duration.mojo │ └── test_expiration.mojo │ ├── http │ ├── test_http.mojo │ ├── test_request.mojo │ └── test_response.mojo │ ├── io │ ├── test_byte_reader.mojo │ ├── test_byte_writer.mojo │ └── test_bytes.mojo │ ├── test_header.mojo │ ├── test_host_port.mojo │ ├── test_owning_list.mojo │ ├── test_server.mojo │ ├── test_service.mojo │ └── test_uri.mojo └── testutils ├── __init__.mojo └── utils.mojo /.env.example: -------------------------------------------------------------------------------- 1 | DEFAULT_SERVER_PORT=8080 2 | DEFAULT_SERVER_HOST=localhost 3 | APP_ENTRYPOINT=lightbug.🔥 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: Run the benchmarking suite 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | test: 8 | name: Run benchmarks 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | - name: Run the test suite 14 | run: | 15 | curl -ssL https://magic.modular.com | bash 16 | source $HOME/.bash_profile 17 | magic run bench 18 | # magic run bench_server # Commented out until we get `wrk` installed 19 | -------------------------------------------------------------------------------- /.github/workflows/branch.yml: -------------------------------------------------------------------------------- 1 | name: Branch workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | test: 16 | uses: ./.github/workflows/test.yml 17 | 18 | bench: 19 | uses: ./.github/workflows/bench.yml 20 | 21 | package: 22 | uses: ./.github/workflows/package.yml 23 | 24 | docker: 25 | needs: package 26 | uses: ./.github/workflows/docker-build.yml 27 | with: 28 | tags: | 29 | type=ref,event=branch 30 | type=sha,format=long 31 | secrets: 32 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 33 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | tags: 7 | required: true 8 | type: string 9 | secrets: 10 | DOCKERHUB_USERNAME: 11 | required: true 12 | DOCKERHUB_TOKEN: 13 | required: true 14 | 15 | jobs: 16 | docker: 17 | name: Build and push Docker image 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Docker meta 24 | id: meta 25 | uses: docker/metadata-action@v5 26 | with: 27 | images: ${{ secrets.DOCKERHUB_USERNAME }}/lightbug_http 28 | tags: ${{ inputs.tags }} 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Login to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - name: Build and push 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | file: ./docker/lightbug.dockerfile 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | cache-from: type=gha 48 | cache-to: type=gha,mode=max 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/test.yml 13 | 14 | package: 15 | uses: ./.github/workflows/package.yml 16 | 17 | publish: 18 | uses: ./.github/workflows/publish.yml 19 | secrets: 20 | PREFIX_API_KEY: ${{ secrets.PREFIX_API_KEY }} 21 | 22 | docker: 23 | needs: [package, publish] 24 | uses: ./.github/workflows/docker-build.yml 25 | with: 26 | tags: | 27 | type=raw,value=edge 28 | type=raw,value=stable 29 | type=sha,prefix=stable- 30 | secrets: 31 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 32 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Create package 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | package: 8 | name: Package 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | - name: Run the package command 14 | run: | 15 | curl -ssL https://magic.modular.com | bash 16 | source $HOME/.bash_profile 17 | magic run mojo package lightbug_http -o lightbug_http.mojopkg 18 | 19 | - name: Upload package as artifact 20 | uses: actions/upload-artifact@v4 21 | with: 22 | name: lightbug_http-package 23 | path: lightbug_http.mojopkg 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | PREFIX_API_KEY: 7 | required: true 8 | 9 | jobs: 10 | publish: 11 | name: Publish package 12 | strategy: 13 | matrix: 14 | include: 15 | - { target: linux-64, os: ubuntu-latest } 16 | - { target: osx-arm64, os: macos-14 } 17 | fail-fast: false 18 | runs-on: ${{ matrix.os }} 19 | timeout-minutes: 5 20 | defaults: 21 | run: 22 | shell: bash 23 | steps: 24 | - name: Checkout repo 25 | uses: actions/checkout@v4 26 | 27 | - name: Build package for target platform 28 | env: 29 | TARGET_PLATFORM: ${{ matrix.target }} 30 | PREFIX_API_KEY: ${{ secrets.PREFIX_API_KEY }} 31 | CONDA_BLD_PATH: ${{ runner.workspace }}/.rattler 32 | run: | 33 | curl -ssL https://magic.modular.com | bash 34 | source $HOME/.bash_profile 35 | 36 | # Temporary method to fetch the rattler binary. 37 | RATTLER_BINARY="rattler-build-aarch64-apple-darwin" 38 | if [[ $TARGET_PLATFORM == "linux-64" ]]; then RATTLER_BINARY="rattler-build-x86_64-unknown-linux-musl"; fi 39 | curl -SL --progress-bar https://github.com/prefix-dev/rattler-build/releases/download/v0.33.2/${RATTLER_BINARY} -o rattler-build 40 | chmod +x rattler-build 41 | 42 | # Build and push 43 | magic run build --target-platform=$TARGET_PLATFORM 44 | magic run publish 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release tag pipeline 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | test: 14 | uses: ./.github/workflows/test.yml 15 | 16 | package: 17 | uses: ./.github/workflows/package.yml 18 | 19 | publish: 20 | uses: ./.github/workflows/publish.yml 21 | secrets: 22 | PREFIX_API_KEY: ${{ secrets.PREFIX_API_KEY }} 23 | 24 | upload-to-release: 25 | name: Upload to release 26 | needs: package 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Get the version 30 | id: get_version 31 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 32 | 33 | - uses: actions/download-artifact@v4 34 | with: 35 | name: lightbug_http-package 36 | 37 | - name: Upload package to release 38 | uses: svenstaro/upload-release-action@v2 39 | with: 40 | file: lightbug_http.mojopkg 41 | tag: ${{ steps.get_version.outputs.VERSION }} 42 | overwrite: true 43 | 44 | docker: 45 | needs: [package, upload-to-release] 46 | uses: ./.github/workflows/docker-build.yml 47 | with: 48 | tags: | 49 | type=semver,pattern={{version}} 50 | type=semver,pattern={{major}}.{{minor}} 51 | type=raw,value=latest 52 | secrets: 53 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 54 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run the testing suite 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | test: 8 | name: Run tests 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | - name: Run the test suite 14 | run: | 15 | curl -ssL https://magic.modular.com | bash 16 | source $HOME/.bash_profile 17 | magic run test 18 | magic run integration_tests_py 19 | magic run integration_tests_external 20 | magic run integration_tests_udp 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.📦 2 | *.mojopkg 3 | .DS_Store 4 | .mojoenv 5 | install_id 6 | 7 | # pixi environments 8 | .pixi 9 | *.egg-info 10 | 11 | # magic environments 12 | .magic 13 | 14 | # Rattler 15 | output 16 | 17 | # integration tests 18 | udp_client.DSYM 19 | udp_server.DSYM 20 | __pycache__ 21 | 22 | # misc 23 | .vscode 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: local 9 | hooks: 10 | - id: mojo-format 11 | name: mojo-format 12 | entry: magic run mojo format -l 120 13 | language: system 14 | files: '\.(mojo|🔥)$' 15 | stages: [pre-commit] 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | @saviorand. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to lightbug_http 3 | 4 | First off, thanks for taking the time to contribute! ❤️ 5 | 6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 7 | 8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 9 | > - Star the project 10 | > - Tweet about it 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | 14 | 15 | ## Table of Contents 16 | 17 | - [I Have a Question](#i-have-a-question) 18 | - [I Want To Contribute](#i-want-to-contribute) 19 | - [Reporting Bugs](#reporting-bugs) 20 | - [Suggesting Enhancements](#suggesting-enhancements) 21 | - [Your First Code Contribution](#your-first-code-contribution) 22 | - [Improving The Documentation](#improving-the-documentation) 23 | - [Styleguides](#styleguides) 24 | - [Commit Messages](#commit-messages) 25 | - [Join The Project Team](#join-the-project-team) 26 | 27 | 28 | 29 | ## I Have a Question 30 | 31 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/saviorand/). 32 | 33 | Before you ask a question, it is best to search for existing [Issues](https://github.com/saviorand/lightbug_http/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 34 | 35 | If you then still feel the need to ask a question and need clarification, we recommend the following: 36 | 37 | - Open an [Issue](https://github.com/saviorand/lightbug_http/issues/new). 38 | - Provide as much context as you can about what you're running into. 39 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 40 | 41 | We will then take care of the issue as soon as possible. 42 | 43 | 57 | 58 | ## I Want To Contribute 59 | 60 | > ### Legal Notice 61 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 62 | 63 | ### Reporting Bugs 64 | 65 | 66 | #### Before Submitting a Bug Report 67 | 68 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 69 | 70 | - Make sure that you are using the latest version. 71 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/saviorand/). If you are looking for support, you might want to check [this section](#i-have-a-question)). 72 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/saviorand/lightbug_httpissues?q=label%3Abug). 73 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 74 | - Collect information about the bug: 75 | - Stack trace (Traceback) 76 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 77 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 78 | - Possibly your input and the output 79 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 80 | 81 | 82 | #### How Do I Submit a Good Bug Report? 83 | 84 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent directly to [the author](https://www.linkedin.com/in/valentin-erokhin-24969a14a/). 85 | 86 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 87 | 88 | - Open an [Issue](https://github.com/saviorand/lightbug_http/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 89 | - Explain the behavior you would expect and the actual behavior. 90 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 91 | - Provide the information you collected in the previous section. 92 | 93 | Once it's filed: 94 | 95 | - The project team will label the issue accordingly. 96 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 97 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 98 | 99 | 100 | ### Suggesting Enhancements 101 | 102 | This section guides you through submitting an enhancement suggestion for lightbug_http, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 103 | 104 | 105 | #### Before Submitting an Enhancement 106 | 107 | - Make sure that you are using the latest version. 108 | - Read the [documentation](https://github.com/saviorand/) carefully and find out if the functionality is already covered, maybe by an individual configuration. 109 | - Perform a [search](https://github.com/saviorand/lightbug_http/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 110 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 111 | 112 | 113 | #### How Do I Submit a Good Enhancement Suggestion? 114 | 115 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/saviorand/lightbug_http/issues). 116 | 117 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 118 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 119 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 120 | - **Explain why this enhancement would be useful** to most lightbug_http users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 121 | 122 | 123 | 124 | ## Attribution 125 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Valentin Erokhin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lightbug: 2 | cd docker && \ 3 | docker compose up --build 4 | -------------------------------------------------------------------------------- /benchmark/bench.mojo: -------------------------------------------------------------------------------- 1 | from memory import Span 2 | from benchmark import * 3 | from lightbug_http.io.bytes import bytes, Bytes 4 | from lightbug_http.header import Headers, Header 5 | from lightbug_http.io.bytes import ByteReader, ByteWriter 6 | from lightbug_http.http import HTTPRequest, HTTPResponse, encode 7 | from lightbug_http.uri import URI 8 | 9 | alias headers = "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Length: 1234\r\nConnection: close\r\nTrailer: end-of-message\r\n\r\n" 10 | 11 | alias body = "I am the body of an HTTP request" * 5 12 | alias body_bytes = bytes(body) 13 | alias Request = "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Length: 1234\r\nConnection: close\r\nTrailer: end-of-message\r\n\r\n" + body 14 | alias Response = "HTTP/1.1 200 OK\r\nserver: lightbug_http\r\ncontent-type: application/octet-stream\r\nconnection: keep-alive\r\ncontent-length: 13\r\ndate: 2024-06-02T13:41:50.766880+00:00\r\n\r\n" + body 15 | 16 | 17 | fn main(): 18 | run_benchmark() 19 | 20 | 21 | fn run_benchmark(): 22 | try: 23 | var config = BenchConfig() 24 | config.verbose_timing = True 25 | var m = Bench(config) 26 | m.bench_function[lightbug_benchmark_header_encode](BenchId("HeaderEncode")) 27 | m.bench_function[lightbug_benchmark_header_parse](BenchId("HeaderParse")) 28 | m.bench_function[lightbug_benchmark_request_encode](BenchId("RequestEncode")) 29 | m.bench_function[lightbug_benchmark_request_parse](BenchId("RequestParse")) 30 | m.bench_function[lightbug_benchmark_response_encode](BenchId("ResponseEncode")) 31 | m.bench_function[lightbug_benchmark_response_parse](BenchId("ResponseParse")) 32 | m.dump_report() 33 | except: 34 | print("failed to start benchmark") 35 | 36 | 37 | var headers_struct = Headers( 38 | Header("Content-Type", "application/json"), 39 | Header("Content-Length", "1234"), 40 | Header("Connection", "close"), 41 | Header("Date", "some-datetime"), 42 | Header("SomeHeader", "SomeValue"), 43 | ) 44 | 45 | 46 | @parameter 47 | fn lightbug_benchmark_response_encode(mut b: Bencher): 48 | @always_inline 49 | @parameter 50 | fn response_encode(): 51 | var res = HTTPResponse(body.as_bytes(), headers=headers_struct) 52 | _ = encode(res^) 53 | 54 | b.iter[response_encode]() 55 | 56 | 57 | @parameter 58 | fn lightbug_benchmark_response_parse(mut b: Bencher): 59 | @always_inline 60 | @parameter 61 | fn response_parse(): 62 | try: 63 | _ = HTTPResponse.from_bytes(Response.as_bytes()) 64 | except: 65 | pass 66 | 67 | b.iter[response_parse]() 68 | 69 | 70 | @parameter 71 | fn lightbug_benchmark_request_parse(mut b: Bencher): 72 | @always_inline 73 | @parameter 74 | fn request_parse(): 75 | try: 76 | _ = HTTPRequest.from_bytes("127.0.0.1/path", 4096, Request.as_bytes()) 77 | except: 78 | pass 79 | 80 | b.iter[request_parse]() 81 | 82 | 83 | @parameter 84 | fn lightbug_benchmark_request_encode(mut b: Bencher): 85 | @always_inline 86 | @parameter 87 | fn request_encode() raises: 88 | var uri = URI.parse("http://127.0.0.1:8080/some-path") 89 | var req = HTTPRequest( 90 | uri=uri, 91 | headers=headers_struct, 92 | body=body_bytes, 93 | ) 94 | _ = encode(req^) 95 | 96 | try: 97 | b.iter[request_encode]() 98 | except e: 99 | print("failed to encode request, error: ", e) 100 | 101 | 102 | @parameter 103 | fn lightbug_benchmark_header_encode(mut b: Bencher): 104 | @always_inline 105 | @parameter 106 | fn header_encode(): 107 | var b = ByteWriter() 108 | b.write(headers_struct) 109 | 110 | b.iter[header_encode]() 111 | 112 | 113 | @parameter 114 | fn lightbug_benchmark_header_parse(mut b: Bencher): 115 | @always_inline 116 | @parameter 117 | fn header_parse(): 118 | try: 119 | var header = Headers() 120 | var reader = ByteReader(headers.as_bytes()) 121 | _ = header.parse_raw(reader) 122 | except e: 123 | print("failed", e) 124 | 125 | b.iter[header_parse]() 126 | -------------------------------------------------------------------------------- /benchmark/bench_server.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.server import Server 2 | from lightbug_http.service import TechEmpowerRouter 3 | 4 | 5 | def main(): 6 | try: 7 | var server = Server(tcp_keep_alive=True) 8 | var handler = TechEmpowerRouter() 9 | server.listen_and_serve("localhost:8080", handler) 10 | except e: 11 | print("Error starting server: " + e.__str__()) 12 | return 13 | -------------------------------------------------------------------------------- /client.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http import * 2 | from lightbug_http.client import Client 3 | 4 | 5 | fn test_request(mut client: Client) raises -> None: 6 | var uri = URI.parse("google.com") 7 | var headers = Headers(Header("Host", "google.com")) 8 | var request = HTTPRequest(uri, headers) 9 | var response = client.do(request^) 10 | 11 | # print status code 12 | print("Response:", response.status_code) 13 | 14 | print(response.headers) 15 | 16 | print( 17 | "Is connection set to connection-close? ", response.connection_close() 18 | ) 19 | 20 | # print body 21 | print(to_string(response.body_raw)) 22 | 23 | 24 | fn main() -> None: 25 | try: 26 | var client = Client() 27 | test_request(client) 28 | except e: 29 | print(e) 30 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | lightbug: 4 | build: 5 | context: . 6 | args: 7 | - DEFAULT_SERVER_HOST=${DEFAULT_SERVER_HOST} 8 | - DEFAULT_SERVER_PORT=${DEFAULT_SERVER_PORT} 9 | ports: 10 | - "${DEFAULT_SERVER_PORT}:${DEFAULT_SERVER_PORT}" 11 | environment: 12 | - DEFAULT_SERVER_HOST=${DEFAULT_SERVER_HOST} 13 | - DEFAULT_SERVER_PORT=${DEFAULT_SERVER_PORT} 14 | - APP_ENTRYPOINT=${APP_ENTRYPOINT} 15 | -------------------------------------------------------------------------------- /docker/lightbug.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/modular/magic:latest 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | RUN git clone https://github.com/Lightbug-HQ/lightbug_http 6 | 7 | WORKDIR /lightbug_http 8 | 9 | ARG DEFAULT_SERVER_PORT=8080 10 | ARG DEFAULT_SERVER_HOST=localhost 11 | 12 | EXPOSE ${DEFAULT_SERVER_PORT} 13 | 14 | ENV DEFAULT_SERVER_PORT=${DEFAULT_SERVER_PORT} 15 | ENV DEFAULT_SERVER_HOST=${DEFAULT_SERVER_HOST} 16 | ENV APP_ENTRYPOINT=lightbug.🔥 17 | 18 | CMD magic run mojo ${APP_ENTRYPOINT} 19 | -------------------------------------------------------------------------------- /lightbug.🔥: -------------------------------------------------------------------------------- 1 | from lightbug_http import Welcome, Server 2 | from os.env import getenv 3 | 4 | fn main() raises: 5 | var server = Server() 6 | var handler = Welcome() 7 | 8 | var host = getenv("DEFAULT_SERVER_HOST", "localhost") 9 | var port = getenv("DEFAULT_SERVER_PORT", "8080") 10 | 11 | server.listen_and_serve(host + ":" + port, handler) 12 | -------------------------------------------------------------------------------- /lightbug_http/__init__.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.http import HTTPRequest, HTTPResponse, OK, NotFound, StatusCode 2 | from lightbug_http.uri import URI 3 | from lightbug_http.header import Header, Headers, HeaderKey 4 | from lightbug_http.cookie import Cookie, RequestCookieJar, ResponseCookieJar 5 | from lightbug_http.service import HTTPService, Welcome, Counter 6 | from lightbug_http.server import Server 7 | from lightbug_http.strings import to_string 8 | -------------------------------------------------------------------------------- /lightbug_http/_logger.mojo: -------------------------------------------------------------------------------- 1 | from sys.param_env import env_get_string 2 | from sys import stdout, stderr 3 | 4 | 5 | struct LogLevel: 6 | alias FATAL = 0 7 | alias ERROR = 1 8 | alias WARN = 2 9 | alias INFO = 3 10 | alias DEBUG = 4 11 | 12 | 13 | fn get_log_level() -> Int: 14 | """Returns the log level based on the parameter environment variable `LOG_LEVEL`. 15 | 16 | Returns: 17 | The log level. 18 | """ 19 | alias level = env_get_string["LB_LOG_LEVEL", "INFO"]() 20 | 21 | @parameter 22 | if level == "INFO": 23 | return LogLevel.INFO 24 | elif level == "WARN": 25 | return LogLevel.WARN 26 | elif level == "ERROR": 27 | return LogLevel.ERROR 28 | elif level == "DEBUG": 29 | return LogLevel.DEBUG 30 | elif level == "FATAL": 31 | return LogLevel.FATAL 32 | else: 33 | return LogLevel.INFO 34 | 35 | 36 | alias LOG_LEVEL = get_log_level() 37 | """Logger level determined by the `LB_LOG_LEVEL` param environment variable. 38 | 39 | When building or running the application, you can set `LB_LOG_LEVEL` by providing the the following option: 40 | 41 | ```bash 42 | mojo build ... -D LB_LOG_LEVEL=DEBUG 43 | # or 44 | mojo ... -D LB_LOG_LEVEL=DEBUG 45 | ``` 46 | """ 47 | 48 | 49 | @value 50 | struct Logger[level: Int]: 51 | fn _log_message[event_level: Int](self, message: String): 52 | @parameter 53 | if level >= event_level: 54 | 55 | @parameter 56 | if event_level < LogLevel.WARN: 57 | # Write to stderr if FATAL or ERROR 58 | print(message, file=stderr) 59 | else: 60 | print(message) 61 | 62 | fn info[*Ts: Writable](self, *messages: *Ts): 63 | var msg = String.write("\033[36mINFO\033[0m - ") 64 | 65 | @parameter 66 | fn write_message[T: Writable](message: T): 67 | msg.write(message, " ") 68 | 69 | messages.each[write_message]() 70 | self._log_message[LogLevel.INFO](msg) 71 | 72 | fn warn[*Ts: Writable](self, *messages: *Ts): 73 | var msg = String.write("\033[33mWARN\033[0m - ") 74 | 75 | @parameter 76 | fn write_message[T: Writable](message: T): 77 | msg.write(message, " ") 78 | 79 | messages.each[write_message]() 80 | self._log_message[LogLevel.WARN](msg) 81 | 82 | fn error[*Ts: Writable](self, *messages: *Ts): 83 | var msg = String.write("\033[31mERROR\033[0m - ") 84 | 85 | @parameter 86 | fn write_message[T: Writable](message: T): 87 | msg.write(message, " ") 88 | 89 | messages.each[write_message]() 90 | self._log_message[LogLevel.ERROR](msg) 91 | 92 | fn debug[*Ts: Writable](self, *messages: *Ts): 93 | var msg = String.write("\033[34mDEBUG\033[0m - ") 94 | 95 | @parameter 96 | fn write_message[T: Writable](message: T): 97 | msg.write(message, " ") 98 | 99 | messages.each[write_message]() 100 | self._log_message[LogLevel.DEBUG](msg) 101 | 102 | fn fatal[*Ts: Writable](self, *messages: *Ts): 103 | var msg = String.write("\033[35mFATAL\033[0m - ") 104 | 105 | @parameter 106 | fn write_message[T: Writable](message: T): 107 | msg.write(message, " ") 108 | 109 | messages.each[write_message]() 110 | self._log_message[LogLevel.FATAL](msg) 111 | 112 | 113 | alias logger = Logger[LOG_LEVEL]() 114 | -------------------------------------------------------------------------------- /lightbug_http/client.mojo: -------------------------------------------------------------------------------- 1 | from collections import Dict 2 | from lightbug_http.connection import TCPConnection, default_buffer_size, create_connection 3 | from lightbug_http.http import HTTPRequest, HTTPResponse, encode 4 | from lightbug_http.header import Headers, HeaderKey 5 | from lightbug_http.io.bytes import Bytes, ByteReader 6 | from lightbug_http._logger import logger 7 | from lightbug_http.pool_manager import PoolManager, PoolKey 8 | from lightbug_http.uri import URI, Scheme 9 | 10 | 11 | struct Client: 12 | var host: String 13 | var port: Int 14 | var name: String 15 | var allow_redirects: Bool 16 | 17 | var _connections: PoolManager[TCPConnection] 18 | 19 | fn __init__( 20 | out self, 21 | host: String = "127.0.0.1", 22 | port: Int = 8888, 23 | cached_connections: Int = 10, 24 | allow_redirects: Bool = False, 25 | ): 26 | self.host = host 27 | self.port = port 28 | self.name = "lightbug_http_client" 29 | self.allow_redirects = allow_redirects 30 | self._connections = PoolManager[TCPConnection](cached_connections) 31 | 32 | fn do(mut self, owned request: HTTPRequest) raises -> HTTPResponse: 33 | """The `do` method is responsible for sending an HTTP request to a server and receiving the corresponding response. 34 | 35 | It performs the following steps: 36 | 1. Creates a connection to the server specified in the request. 37 | 2. Sends the request body using the connection. 38 | 3. Receives the response from the server. 39 | 4. Closes the connection. 40 | 5. Returns the received response as an `HTTPResponse` object. 41 | 42 | Note: The code assumes that the `HTTPRequest` object passed as an argument has a valid URI with a host and port specified. 43 | 44 | Args: 45 | request: An `HTTPRequest` object representing the request to be sent. 46 | 47 | Returns: 48 | The received response. 49 | 50 | Raises: 51 | Error: If there is a failure in sending or receiving the message. 52 | """ 53 | if request.uri.host == "": 54 | raise Error("Client.do: Host must not be empty.") 55 | 56 | # TODO (@thatstoasty): Implement TLS support. 57 | # var is_tls = False 58 | var scheme = Scheme.HTTP 59 | if request.uri.is_https(): 60 | # is_tls = True 61 | scheme = Scheme.HTTPS 62 | 63 | var port: UInt16 64 | if request.uri.port: 65 | port = request.uri.port.value() 66 | else: 67 | if request.uri.scheme == Scheme.HTTP.value: 68 | port = 80 69 | elif request.uri.scheme == Scheme.HTTPS.value: 70 | port = 443 71 | else: 72 | raise Error("Client.do: Invalid scheme received in the URI.") 73 | 74 | var pool_key = PoolKey(request.uri.host, port, scheme) 75 | var cached_connection = False 76 | var conn: TCPConnection 77 | try: 78 | conn = self._connections.take(pool_key) 79 | cached_connection = True 80 | except e: 81 | if String(e) == "PoolManager.take: Key not found.": 82 | conn = create_connection(request.uri.host, port) 83 | else: 84 | logger.error(e) 85 | raise Error("Client.do: Failed to create a connection to host.") 86 | 87 | var bytes_sent: Int 88 | try: 89 | bytes_sent = conn.write(encode(request)) 90 | except e: 91 | # Maybe peer reset ungracefully, so try a fresh connection 92 | if String(e) == "SendError: Connection reset by peer.": 93 | logger.debug("Client.do: Connection reset by peer. Trying a fresh connection.") 94 | conn.teardown() 95 | if cached_connection: 96 | return self.do(request^) 97 | logger.error("Client.do: Failed to send message.") 98 | raise e 99 | 100 | # TODO: What if the response is too large for the buffer? We should read until the end of the response. (@thatstoasty) 101 | var new_buf = Bytes(capacity=default_buffer_size) 102 | try: 103 | _ = conn.read(new_buf) 104 | except e: 105 | if String(e) == "EOF": 106 | conn.teardown() 107 | if cached_connection: 108 | return self.do(request^) 109 | raise Error("Client.do: No response received from the server.") 110 | else: 111 | logger.error(e) 112 | raise Error("Client.do: Failed to read response from peer.") 113 | 114 | var response: HTTPResponse 115 | try: 116 | response = HTTPResponse.from_bytes(new_buf, conn) 117 | except e: 118 | logger.error("Failed to parse a response...") 119 | try: 120 | conn.teardown() 121 | except: 122 | logger.error("Failed to teardown connection...") 123 | raise e 124 | 125 | # Redirects should not keep the connection alive, as redirects can send the client to a different server. 126 | if self.allow_redirects and response.is_redirect(): 127 | conn.teardown() 128 | return self._handle_redirect(request^, response^) 129 | # Server told the client to close the connection, we can assume the server closed their side after sending the response. 130 | elif response.connection_close(): 131 | conn.teardown() 132 | # Otherwise, persist the connection by giving it back to the pool manager. 133 | else: 134 | self._connections.give(pool_key, conn^) 135 | return response 136 | 137 | fn _handle_redirect( 138 | mut self, owned original_request: HTTPRequest, owned original_response: HTTPResponse 139 | ) raises -> HTTPResponse: 140 | var new_uri: URI 141 | var new_location: String 142 | try: 143 | new_location = original_response.headers[HeaderKey.LOCATION] 144 | except e: 145 | raise Error("Client._handle_redirect: `Location` header was not received in the response.") 146 | 147 | if new_location and new_location.startswith("http"): 148 | try: 149 | new_uri = URI.parse(new_location) 150 | except e: 151 | raise Error("Client._handle_redirect: Failed to parse the new URI: " + String(e)) 152 | original_request.headers[HeaderKey.HOST] = new_uri.host 153 | else: 154 | new_uri = original_request.uri 155 | new_uri.path = new_location 156 | original_request.uri = new_uri 157 | return self.do(original_request^) 158 | -------------------------------------------------------------------------------- /lightbug_http/connection.mojo: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from memory import Span 3 | from sys.info import os_is_macos 4 | from lightbug_http.address import NetworkType 5 | from lightbug_http.io.bytes import Bytes, ByteView, bytes 6 | from lightbug_http.io.sync import Duration 7 | from lightbug_http.address import parse_address, TCPAddr, UDPAddr 8 | from lightbug_http._libc import ( 9 | sockaddr, 10 | SOCK_DGRAM, 11 | SO_REUSEADDR, 12 | socket, 13 | connect, 14 | listen, 15 | accept, 16 | send, 17 | bind, 18 | shutdown, 19 | close, 20 | ) 21 | from lightbug_http._logger import logger 22 | from lightbug_http.socket import Socket 23 | 24 | 25 | alias default_buffer_size = 4096 26 | """The default buffer size for reading and writing data.""" 27 | alias default_tcp_keep_alive = Duration(15 * 1000 * 1000 * 1000) # 15 seconds 28 | """The default TCP keep-alive duration.""" 29 | 30 | 31 | trait Connection(Movable): 32 | fn read(self, mut buf: Bytes) raises -> Int: 33 | ... 34 | 35 | fn write(self, buf: Span[Byte]) raises -> Int: 36 | ... 37 | 38 | fn close(mut self) raises: 39 | ... 40 | 41 | fn shutdown(mut self) raises -> None: 42 | ... 43 | 44 | fn teardown(mut self) raises: 45 | ... 46 | 47 | fn local_addr(self) -> TCPAddr: 48 | ... 49 | 50 | fn remote_addr(self) -> TCPAddr: 51 | ... 52 | 53 | 54 | struct NoTLSListener: 55 | """A TCP listener that listens for incoming connections and can accept them.""" 56 | 57 | var socket: Socket[TCPAddr] 58 | 59 | fn __init__(out self, owned socket: Socket[TCPAddr]): 60 | self.socket = socket^ 61 | 62 | fn __init__(out self) raises: 63 | self.socket = Socket[TCPAddr]() 64 | 65 | fn __moveinit__(out self, owned existing: Self): 66 | self.socket = existing.socket^ 67 | 68 | fn accept(self) raises -> TCPConnection: 69 | return TCPConnection(self.socket.accept()) 70 | 71 | fn close(mut self) raises -> None: 72 | return self.socket.close() 73 | 74 | fn shutdown(mut self) raises -> None: 75 | return self.socket.shutdown() 76 | 77 | fn teardown(mut self) raises: 78 | self.socket.teardown() 79 | 80 | fn addr(self) -> TCPAddr: 81 | return self.socket.local_address() 82 | 83 | 84 | struct ListenConfig: 85 | var _keep_alive: Duration 86 | 87 | fn __init__(out self, keep_alive: Duration = default_tcp_keep_alive): 88 | self._keep_alive = keep_alive 89 | 90 | fn listen[network: NetworkType = NetworkType.tcp4](mut self, address: String) raises -> NoTLSListener: 91 | var local = parse_address[__origin_of(address)](network, address) 92 | var addr = TCPAddr(String(local[0]), local[1]) 93 | var socket: Socket[TCPAddr] 94 | try: 95 | socket = Socket[TCPAddr]() 96 | except e: 97 | logger.error(e) 98 | raise Error("ListenConfig.listen: Failed to create listener due to socket creation failure.") 99 | 100 | @parameter 101 | # TODO: do we want to add SO_REUSEPORT on linux? Doesn't work on some systems 102 | if os_is_macos(): 103 | try: 104 | socket.set_socket_option(SO_REUSEADDR, 1) 105 | except e: 106 | logger.warn("ListenConfig.listen: Failed to set socket as reusable", e) 107 | 108 | var bind_success = False 109 | var bind_fail_logged = False 110 | while not bind_success: 111 | try: 112 | socket.bind(addr.ip, addr.port) 113 | bind_success = True 114 | except e: 115 | if not bind_fail_logged: 116 | print("Bind attempt failed: ", e) 117 | print("Retrying. Might take 10-15 seconds.") 118 | bind_fail_logged = True 119 | print(".", end="", flush=True) 120 | 121 | try: 122 | socket.shutdown() 123 | except e: 124 | logger.error("ListenConfig.listen: Failed to shutdown socket:", e) 125 | # TODO: Should shutdown failure be a hard failure? We can still ungracefully close the socket. 126 | sleep(UInt(1)) 127 | 128 | try: 129 | socket.listen(128) 130 | except e: 131 | logger.error(e) 132 | raise Error("ListenConfig.listen: Listen failed on sockfd: " + String(socket.fd)) 133 | 134 | var listener = NoTLSListener(socket^) 135 | var msg = String.write("\n🔥🐝 Lightbug is listening on ", "http://", addr.ip, ":", String(addr.port)) 136 | print(msg) 137 | print("Ready to accept connections...") 138 | 139 | return listener^ 140 | 141 | 142 | struct TCPConnection: 143 | var socket: Socket[TCPAddr] 144 | 145 | fn __init__(out self, owned socket: Socket[TCPAddr]): 146 | self.socket = socket^ 147 | 148 | fn __moveinit__(out self, owned existing: Self): 149 | self.socket = existing.socket^ 150 | 151 | fn read(self, mut buf: Bytes) raises -> Int: 152 | try: 153 | return self.socket.receive(buf) 154 | except e: 155 | if String(e) == "EOF": 156 | raise e 157 | else: 158 | logger.error(e) 159 | raise Error("TCPConnection.read: Failed to read data from connection.") 160 | 161 | fn write(self, buf: Span[Byte]) raises -> Int: 162 | if buf[-1] == 0: 163 | raise Error("TCPConnection.write: Buffer must not be null-terminated.") 164 | 165 | try: 166 | return self.socket.send(buf) 167 | except e: 168 | logger.error("TCPConnection.write: Failed to write data to connection.") 169 | raise e 170 | 171 | fn close(mut self) raises: 172 | self.socket.close() 173 | 174 | fn shutdown(mut self) raises: 175 | self.socket.shutdown() 176 | 177 | fn teardown(mut self) raises: 178 | self.socket.teardown() 179 | 180 | fn is_closed(self) -> Bool: 181 | return self.socket._closed 182 | 183 | # TODO: Switch to property or return ref when trait supports attributes. 184 | fn local_addr(self) -> TCPAddr: 185 | return self.socket.local_address() 186 | 187 | fn remote_addr(self) -> TCPAddr: 188 | return self.socket.remote_address() 189 | 190 | 191 | struct UDPConnection[network: NetworkType]: 192 | var socket: Socket[UDPAddr[network]] 193 | 194 | fn __init__(out self, owned socket: Socket[UDPAddr[network]]): 195 | self.socket = socket^ 196 | 197 | fn __moveinit__(out self, owned existing: Self): 198 | self.socket = existing.socket^ 199 | 200 | fn read_from(mut self, size: Int = default_buffer_size) raises -> (Bytes, String, UInt16): 201 | """Reads data from the underlying file descriptor. 202 | 203 | Args: 204 | size: The size of the buffer to read data into. 205 | 206 | Returns: 207 | The number of bytes read, or an error if one occurred. 208 | 209 | Raises: 210 | Error: If an error occurred while reading data. 211 | """ 212 | return self.socket.receive_from(size) 213 | 214 | fn read_from(mut self, mut dest: Bytes) raises -> (UInt, String, UInt16): 215 | """Reads data from the underlying file descriptor. 216 | 217 | Args: 218 | dest: The buffer to read data into. 219 | 220 | Returns: 221 | The number of bytes read, or an error if one occurred. 222 | 223 | Raises: 224 | Error: If an error occurred while reading data. 225 | """ 226 | return self.socket.receive_from(dest) 227 | 228 | fn write_to(mut self, src: Span[Byte], address: UDPAddr) raises -> Int: 229 | """Writes data to the underlying file descriptor. 230 | 231 | Args: 232 | src: The buffer to read data into. 233 | address: The remote peer address. 234 | 235 | Returns: 236 | The number of bytes written, or an error if one occurred. 237 | 238 | Raises: 239 | Error: If an error occurred while writing data. 240 | """ 241 | return self.socket.send_to(src, address.ip, address.port) 242 | 243 | fn write_to(mut self, src: Span[Byte], host: String, port: UInt16) raises -> Int: 244 | """Writes data to the underlying file descriptor. 245 | 246 | Args: 247 | src: The buffer to read data into. 248 | host: The remote peer address in IPv4 format. 249 | port: The remote peer port. 250 | 251 | Returns: 252 | The number of bytes written, or an error if one occurred. 253 | 254 | Raises: 255 | Error: If an error occurred while writing data. 256 | """ 257 | return self.socket.send_to(src, host, port) 258 | 259 | fn close(mut self) raises: 260 | self.socket.close() 261 | 262 | fn shutdown(mut self) raises: 263 | self.socket.shutdown() 264 | 265 | fn teardown(mut self) raises: 266 | self.socket.teardown() 267 | 268 | fn is_closed(self) -> Bool: 269 | return self.socket._closed 270 | 271 | fn local_addr(self) -> ref [self.socket._local_address] UDPAddr[network]: 272 | return self.socket.local_address() 273 | 274 | fn remote_addr(self) -> ref [self.socket._remote_address] UDPAddr[network]: 275 | return self.socket.remote_address() 276 | 277 | 278 | fn create_connection(host: String, port: UInt16) raises -> TCPConnection: 279 | """Connect to a server using a socket. 280 | 281 | Args: 282 | host: The host to connect to. 283 | port: The port to connect on. 284 | 285 | Returns: 286 | The socket file descriptor. 287 | """ 288 | var socket = Socket[TCPAddr]() 289 | try: 290 | socket.connect(host, port) 291 | except e: 292 | logger.error(e) 293 | try: 294 | socket.shutdown() 295 | except e: 296 | logger.error("Failed to shutdown socket: " + String(e)) 297 | raise Error("Failed to establish a connection to the server.") 298 | 299 | return TCPConnection(socket^) 300 | 301 | 302 | fn listen_udp[network: NetworkType = NetworkType.udp4](local_address: UDPAddr) raises -> UDPConnection[network]: 303 | """Creates a new UDP listener. 304 | 305 | Args: 306 | local_address: The local address to listen on. 307 | 308 | Returns: 309 | A UDP connection. 310 | 311 | Raises: 312 | Error: If the address is invalid or failed to bind the socket. 313 | """ 314 | var socket = Socket[UDPAddr[network]](socket_type=SOCK_DGRAM) 315 | socket.bind(local_address.ip, local_address.port) 316 | return UDPConnection[network](socket^) 317 | 318 | 319 | fn listen_udp[network: NetworkType = NetworkType.udp4](local_address: String) raises -> UDPConnection[network]: 320 | """Creates a new UDP listener. 321 | 322 | Args: 323 | local_address: The address to listen on. The format is "host:port". 324 | 325 | Returns: 326 | A UDP connection. 327 | 328 | Raises: 329 | Error: If the address is invalid or failed to bind the socket. 330 | """ 331 | var address = parse_address(NetworkType.udp4, local_address) 332 | return listen_udp[network](UDPAddr[network](String(address[0]), address[1])) 333 | 334 | 335 | fn listen_udp[network: NetworkType = NetworkType.udp4](host: String, port: UInt16) raises -> UDPConnection[network]: 336 | """Creates a new UDP listener. 337 | 338 | Args: 339 | host: The address to listen on in ipv4 format. 340 | port: The port number. 341 | 342 | Returns: 343 | A UDP connection. 344 | 345 | Raises: 346 | Error: If the address is invalid or failed to bind the socket. 347 | """ 348 | return listen_udp[network](UDPAddr[network](host, port)) 349 | 350 | 351 | fn dial_udp[network: NetworkType = NetworkType.udp4](local_address: UDPAddr[network]) raises -> UDPConnection[network]: 352 | """Connects to the address on the named network. The network must be "udp", "udp4", or "udp6". 353 | 354 | Args: 355 | local_address: The local address. 356 | 357 | Returns: 358 | The UDP connection. 359 | 360 | Raises: 361 | Error: If the network type is not supported or failed to connect to the address. 362 | """ 363 | return UDPConnection(Socket[UDPAddr[network]](local_address=local_address, socket_type=SOCK_DGRAM)) 364 | 365 | 366 | fn dial_udp[network: NetworkType = NetworkType.udp4](local_address: String) raises -> UDPConnection[network]: 367 | """Connects to the address on the named network. The network must be "udp", "udp4", or "udp6". 368 | 369 | Args: 370 | local_address: The local address. 371 | 372 | Returns: 373 | The UDP connection. 374 | 375 | Raises: 376 | Error: If the network type is not supported or failed to connect to the address. 377 | """ 378 | var address = parse_address(network, local_address) 379 | return dial_udp[network](UDPAddr[network](String(address[0]), address[1])) 380 | 381 | 382 | fn dial_udp[network: NetworkType = NetworkType.udp4](host: String, port: UInt16) raises -> UDPConnection[network]: 383 | """Connects to the address on the udp network. 384 | 385 | Args: 386 | host: The host to connect to. 387 | port: The port to connect on. 388 | 389 | Returns: 390 | The UDP connection. 391 | 392 | Raises: 393 | Error: If failed to connect to the address. 394 | """ 395 | return dial_udp[network](UDPAddr[network](host, port)) 396 | -------------------------------------------------------------------------------- /lightbug_http/cookie/__init__.mojo: -------------------------------------------------------------------------------- 1 | from .cookie import * 2 | from .duration import * 3 | from .same_site import * 4 | from .expiration import * 5 | from .request_cookie_jar import * 6 | from .response_cookie_jar import * 7 | -------------------------------------------------------------------------------- /lightbug_http/cookie/cookie.mojo: -------------------------------------------------------------------------------- 1 | from collections import Optional 2 | from lightbug_http.header import HeaderKey 3 | 4 | 5 | struct Cookie(CollectionElement): 6 | alias EXPIRES = "Expires" 7 | alias MAX_AGE = "Max-Age" 8 | alias DOMAIN = "Domain" 9 | alias PATH = "Path" 10 | alias SECURE = "Secure" 11 | alias HTTP_ONLY = "HttpOnly" 12 | alias SAME_SITE = "SameSite" 13 | alias PARTITIONED = "Partitioned" 14 | 15 | alias SEPERATOR = "; " 16 | alias EQUAL = "=" 17 | 18 | var name: String 19 | var value: String 20 | var expires: Expiration 21 | var secure: Bool 22 | var http_only: Bool 23 | var partitioned: Bool 24 | var same_site: Optional[SameSite] 25 | var domain: Optional[String] 26 | var path: Optional[String] 27 | var max_age: Optional[Duration] 28 | 29 | @staticmethod 30 | fn from_set_header(header_str: String) raises -> Self: 31 | var parts = header_str.split(Cookie.SEPERATOR) 32 | if len(parts) < 1: 33 | raise Error("invalid Cookie") 34 | 35 | var cookie = Cookie("", parts[0], path=String("/")) 36 | if Cookie.EQUAL in parts[0]: 37 | var name_value = parts[0].split(Cookie.EQUAL) 38 | cookie.name = name_value[0] 39 | cookie.value = name_value[1] 40 | 41 | for i in range(1, len(parts)): 42 | var part = parts[i] 43 | if part == Cookie.PARTITIONED: 44 | cookie.partitioned = True 45 | elif part == Cookie.SECURE: 46 | cookie.secure = True 47 | elif part == Cookie.HTTP_ONLY: 48 | cookie.http_only = True 49 | elif part.startswith(Cookie.SAME_SITE): 50 | cookie.same_site = SameSite.from_string(part.removeprefix(Cookie.SAME_SITE + Cookie.EQUAL)) 51 | elif part.startswith(Cookie.DOMAIN): 52 | cookie.domain = part.removeprefix(Cookie.DOMAIN + Cookie.EQUAL) 53 | elif part.startswith(Cookie.PATH): 54 | cookie.path = part.removeprefix(Cookie.PATH + Cookie.EQUAL) 55 | elif part.startswith(Cookie.MAX_AGE): 56 | cookie.max_age = Duration.from_string(part.removeprefix(Cookie.MAX_AGE + Cookie.EQUAL)) 57 | elif part.startswith(Cookie.EXPIRES): 58 | var expires = Expiration.from_string(part.removeprefix(Cookie.EXPIRES + Cookie.EQUAL)) 59 | if expires: 60 | cookie.expires = expires.value() 61 | 62 | return cookie 63 | 64 | fn __init__( 65 | out self, 66 | name: String, 67 | value: String, 68 | expires: Expiration = Expiration.session(), 69 | max_age: Optional[Duration] = Optional[Duration](None), 70 | domain: Optional[String] = Optional[String](None), 71 | path: Optional[String] = Optional[String](None), 72 | same_site: Optional[SameSite] = Optional[SameSite](None), 73 | secure: Bool = False, 74 | http_only: Bool = False, 75 | partitioned: Bool = False, 76 | ): 77 | self.name = name 78 | self.value = value 79 | self.expires = expires 80 | self.max_age = max_age 81 | self.domain = domain 82 | self.path = path 83 | self.secure = secure 84 | self.http_only = http_only 85 | self.same_site = same_site 86 | self.partitioned = partitioned 87 | 88 | fn __str__(self) -> String: 89 | return String.write("Name: ", self.name, " Value: ", self.value) 90 | 91 | fn __copyinit__(out self: Cookie, existing: Cookie): 92 | self.name = existing.name 93 | self.value = existing.value 94 | self.max_age = existing.max_age 95 | self.expires = existing.expires 96 | self.domain = existing.domain 97 | self.path = existing.path 98 | self.secure = existing.secure 99 | self.http_only = existing.http_only 100 | self.same_site = existing.same_site 101 | self.partitioned = existing.partitioned 102 | 103 | fn __moveinit__(out self: Cookie, owned existing: Cookie): 104 | self.name = existing.name^ 105 | self.value = existing.value^ 106 | self.max_age = existing.max_age^ 107 | self.expires = existing.expires^ 108 | self.domain = existing.domain^ 109 | self.path = existing.path^ 110 | self.secure = existing.secure 111 | self.http_only = existing.http_only 112 | self.same_site = existing.same_site^ 113 | self.partitioned = existing.partitioned 114 | 115 | fn clear_cookie(mut self): 116 | self.max_age = Optional[Duration](None) 117 | self.expires = Expiration.invalidate() 118 | 119 | fn to_header(self) raises -> Header: 120 | return Header(HeaderKey.SET_COOKIE, self.build_header_value()) 121 | 122 | fn build_header_value(self) -> String: 123 | var header_value = String.write(self.name, Cookie.EQUAL, self.value) 124 | if self.expires.is_datetime(): 125 | var v: Optional[String] 126 | try: 127 | v = self.expires.http_date_timestamp() 128 | except: 129 | v = None 130 | # TODO: This should be a hardfail however Writeable trait write_to method does not raise 131 | # the call flow needs to be refactored 132 | pass 133 | 134 | if v: 135 | header_value.write(Cookie.SEPERATOR, Cookie.EXPIRES, Cookie.EQUAL, v.value()) 136 | if self.max_age: 137 | header_value.write( 138 | Cookie.SEPERATOR, Cookie.MAX_AGE, Cookie.EQUAL, String(self.max_age.value().total_seconds) 139 | ) 140 | if self.domain: 141 | header_value.write(Cookie.SEPERATOR, Cookie.DOMAIN, Cookie.EQUAL, self.domain.value()) 142 | if self.path: 143 | header_value.write(Cookie.SEPERATOR, Cookie.PATH, Cookie.EQUAL, self.path.value()) 144 | if self.secure: 145 | header_value.write(Cookie.SEPERATOR, Cookie.SECURE) 146 | if self.http_only: 147 | header_value.write(Cookie.SEPERATOR, Cookie.HTTP_ONLY) 148 | if self.same_site: 149 | header_value.write(Cookie.SEPERATOR, Cookie.SAME_SITE, Cookie.EQUAL, String(self.same_site.value())) 150 | if self.partitioned: 151 | header_value.write(Cookie.SEPERATOR, Cookie.PARTITIONED) 152 | return header_value 153 | -------------------------------------------------------------------------------- /lightbug_http/cookie/duration.mojo: -------------------------------------------------------------------------------- 1 | @value 2 | struct Duration: 3 | var total_seconds: Int 4 | 5 | fn __init__(out self, seconds: Int = 0, minutes: Int = 0, hours: Int = 0, days: Int = 0): 6 | self.total_seconds = seconds 7 | self.total_seconds += minutes * 60 8 | self.total_seconds += hours * 60 * 60 9 | self.total_seconds += days * 24 * 60 * 60 10 | 11 | @staticmethod 12 | fn from_string(str: String) -> Optional[Self]: 13 | try: 14 | return Duration(seconds=Int(str)) 15 | except: 16 | return Optional[Self](None) 17 | -------------------------------------------------------------------------------- /lightbug_http/cookie/expiration.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.external.small_time import SmallTime 2 | 3 | alias HTTP_DATE_FORMAT = "ddd, DD MMM YYYY HH:mm:ss ZZZ" 4 | alias TZ_GMT = TimeZone(0, "GMT") 5 | 6 | 7 | @value 8 | struct Expiration(CollectionElement): 9 | var variant: UInt8 10 | var datetime: Optional[SmallTime] 11 | 12 | @staticmethod 13 | fn session() -> Self: 14 | return Self(variant=0, datetime=None) 15 | 16 | @staticmethod 17 | fn from_datetime(time: SmallTime) -> Self: 18 | return Self(variant=1, datetime=time) 19 | 20 | @staticmethod 21 | fn from_string(str: String) -> Optional[Expiration]: 22 | try: 23 | return Self.from_datetime(strptime(str, HTTP_DATE_FORMAT, TZ_GMT)) 24 | except: 25 | return None 26 | 27 | @staticmethod 28 | fn invalidate() -> Self: 29 | return Self(variant=1, datetime=SmallTime(1970, 1, 1, 0, 0, 0, 0)) 30 | 31 | fn is_session(self) -> Bool: 32 | return self.variant == 0 33 | 34 | fn is_datetime(self) -> Bool: 35 | return self.variant == 1 36 | 37 | fn http_date_timestamp(self) raises -> Optional[String]: 38 | if not self.datetime: 39 | return Optional[String](None) 40 | 41 | # TODO fix this it breaks time and space (replacing timezone might add or remove something sometimes) 42 | var dt = self.datetime.value() 43 | dt.tz = TZ_GMT 44 | return Optional[String](dt.format(HTTP_DATE_FORMAT)) 45 | 46 | fn __eq__(self, other: Self) -> Bool: 47 | if self.variant != other.variant: 48 | return False 49 | if self.variant == 1: 50 | if Bool(self.datetime) != Bool(other.datetime): 51 | return False 52 | elif not Bool(self.datetime) and not Bool(other.datetime): 53 | return True 54 | return self.datetime.value().isoformat() == other.datetime.value().isoformat() 55 | 56 | return True 57 | -------------------------------------------------------------------------------- /lightbug_http/cookie/request_cookie_jar.mojo: -------------------------------------------------------------------------------- 1 | from collections import Optional, List, Dict 2 | from lightbug_http.external.small_time import SmallTime, TimeZone 3 | from lightbug_http.external.small_time.small_time import strptime 4 | from lightbug_http.strings import to_string, lineBreak 5 | from lightbug_http.header import HeaderKey, write_header 6 | from lightbug_http.io.bytes import ByteReader, ByteWriter, is_newline, is_space 7 | 8 | 9 | @value 10 | struct RequestCookieJar(Writable, Stringable): 11 | var _inner: Dict[String, String] 12 | 13 | fn __init__(out self): 14 | self._inner = Dict[String, String]() 15 | 16 | fn __init__(out self, *cookies: Cookie): 17 | self._inner = Dict[String, String]() 18 | for cookie in cookies: 19 | self._inner[cookie[].name] = cookie[].value 20 | 21 | fn parse_cookies(mut self, headers: Headers) raises: 22 | var cookie_header = headers.get(HeaderKey.COOKIE) 23 | if not cookie_header: 24 | return None 25 | 26 | var cookie_strings = cookie_header.value().split("; ") 27 | 28 | for chunk in cookie_strings: 29 | var key = String("") 30 | var value = chunk[] 31 | if "=" in chunk[]: 32 | var key_value = chunk[].split("=") 33 | key = key_value[0] 34 | value = key_value[1] 35 | 36 | # TODO value must be "unquoted" 37 | self._inner[key] = value 38 | 39 | @always_inline 40 | fn empty(self) -> Bool: 41 | return len(self._inner) == 0 42 | 43 | @always_inline 44 | fn __contains__(self, key: String) -> Bool: 45 | return key in self._inner 46 | 47 | fn __contains__(self, key: Cookie) -> Bool: 48 | return key.name in self 49 | 50 | @always_inline 51 | fn __getitem__(self, key: String) raises -> String: 52 | return self._inner[key.lower()] 53 | 54 | fn get(self, key: String) -> Optional[String]: 55 | try: 56 | return self[key] 57 | except: 58 | return Optional[String](None) 59 | 60 | fn to_header(self) -> Optional[Header]: 61 | alias equal = "=" 62 | if len(self._inner) == 0: 63 | return None 64 | 65 | var header_value = List[String]() 66 | for cookie in self._inner.items(): 67 | header_value.append(cookie[].key + equal + cookie[].value) 68 | return Header(HeaderKey.COOKIE, StaticString("; ").join(header_value)) 69 | 70 | fn encode_to(mut self, mut writer: ByteWriter): 71 | var header = self.to_header() 72 | if header: 73 | write_header(writer, header.value().key, header.value().value) 74 | 75 | fn write_to[T: Writer](self, mut writer: T): 76 | var header = self.to_header() 77 | if header: 78 | write_header(writer, header.value().key, header.value().value) 79 | 80 | fn __str__(self) -> String: 81 | return to_string(self) 82 | -------------------------------------------------------------------------------- /lightbug_http/cookie/response_cookie_jar.mojo: -------------------------------------------------------------------------------- 1 | from collections import Optional, List, Dict, KeyElement 2 | from lightbug_http.strings import to_string 3 | from lightbug_http.header import HeaderKey, write_header 4 | from lightbug_http.io.bytes import ByteWriter 5 | 6 | 7 | @value 8 | struct ResponseCookieKey(KeyElement): 9 | var name: String 10 | var domain: String 11 | var path: String 12 | 13 | fn __init__( 14 | out self, 15 | name: String, 16 | domain: Optional[String] = Optional[String](None), 17 | path: Optional[String] = Optional[String](None), 18 | ): 19 | self.name = name 20 | self.domain = domain.or_else("") 21 | self.path = path.or_else("/") 22 | 23 | fn __ne__(self: Self, other: Self) -> Bool: 24 | return not (self == other) 25 | 26 | fn __eq__(self: Self, other: Self) -> Bool: 27 | return self.name == other.name and self.domain == other.domain and self.path == other.path 28 | 29 | fn __moveinit__(out self: Self, owned existing: Self): 30 | self.name = existing.name 31 | self.domain = existing.domain 32 | self.path = existing.path 33 | 34 | fn __copyinit__(out self: Self, existing: Self): 35 | self.name = existing.name 36 | self.domain = existing.domain 37 | self.path = existing.path 38 | 39 | fn __hash__(self: Self) -> UInt: 40 | return hash(self.name + "~" + self.domain + "~" + self.path) 41 | 42 | 43 | @value 44 | struct ResponseCookieJar(Writable, Stringable): 45 | var _inner: Dict[ResponseCookieKey, Cookie] 46 | 47 | fn __init__(out self): 48 | self._inner = Dict[ResponseCookieKey, Cookie]() 49 | 50 | fn __init__(out self, *cookies: Cookie): 51 | self._inner = Dict[ResponseCookieKey, Cookie]() 52 | for cookie in cookies: 53 | self.set_cookie(cookie[]) 54 | 55 | @always_inline 56 | fn __setitem__(mut self, key: ResponseCookieKey, value: Cookie): 57 | self._inner[key] = value 58 | 59 | fn __getitem__(self, key: ResponseCookieKey) raises -> Cookie: 60 | return self._inner[key] 61 | 62 | fn get(self, key: ResponseCookieKey) -> Optional[Cookie]: 63 | try: 64 | return self[key] 65 | except: 66 | return None 67 | 68 | @always_inline 69 | fn __contains__(self, key: ResponseCookieKey) -> Bool: 70 | return key in self._inner 71 | 72 | @always_inline 73 | fn __contains__(self, key: Cookie) -> Bool: 74 | return ResponseCookieKey(key.name, key.domain, key.path) in self 75 | 76 | fn __str__(self) -> String: 77 | return to_string(self) 78 | 79 | fn __len__(self) -> Int: 80 | return len(self._inner) 81 | 82 | @always_inline 83 | fn set_cookie(mut self, cookie: Cookie): 84 | self[ResponseCookieKey(cookie.name, cookie.domain, cookie.path)] = cookie 85 | 86 | @always_inline 87 | fn empty(self) -> Bool: 88 | return len(self) == 0 89 | 90 | fn from_headers(mut self, headers: List[String]) raises: 91 | for header in headers: 92 | try: 93 | self.set_cookie(Cookie.from_set_header(header[])) 94 | except: 95 | raise Error("Failed to parse cookie header string " + header[]) 96 | 97 | # fn encode_to(mut self, mut writer: ByteWriter): 98 | # for cookie in self._inner.values(): 99 | # var v = cookie[].build_header_value() 100 | # write_header(writer, HeaderKey.SET_COOKIE, v) 101 | 102 | fn write_to[T: Writer](self, mut writer: T): 103 | for cookie in self._inner.values(): 104 | var v = cookie[].build_header_value() 105 | write_header(writer, HeaderKey.SET_COOKIE, v) 106 | -------------------------------------------------------------------------------- /lightbug_http/cookie/same_site.mojo: -------------------------------------------------------------------------------- 1 | @value 2 | struct SameSite: 3 | var value: UInt8 4 | 5 | alias none = SameSite(0) 6 | alias lax = SameSite(1) 7 | alias strict = SameSite(2) 8 | 9 | alias NONE = "none" 10 | alias LAX = "lax" 11 | alias STRICT = "strict" 12 | 13 | @staticmethod 14 | fn from_string(str: String) -> Optional[Self]: 15 | if str == SameSite.NONE: 16 | return SameSite.none 17 | elif str == SameSite.LAX: 18 | return SameSite.lax 19 | elif str == SameSite.STRICT: 20 | return SameSite.strict 21 | return None 22 | 23 | fn __eq__(self, other: Self) -> Bool: 24 | return self.value == other.value 25 | 26 | fn __str__(self) -> String: 27 | if self.value == 0: 28 | return SameSite.NONE 29 | elif self.value == 1: 30 | return SameSite.LAX 31 | else: 32 | return SameSite.STRICT 33 | -------------------------------------------------------------------------------- /lightbug_http/error.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.http import HTTPResponse 2 | 3 | alias TODO_MESSAGE = "TODO".as_bytes() 4 | 5 | 6 | # TODO: Custom error handlers provided by the user 7 | @value 8 | struct ErrorHandler: 9 | fn Error(self) -> HTTPResponse: 10 | return HTTPResponse(TODO_MESSAGE) 11 | -------------------------------------------------------------------------------- /lightbug_http/external/__init__.mojo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/lightbug_http/external/__init__.mojo -------------------------------------------------------------------------------- /lightbug_http/external/small_time/__init__.mojo: -------------------------------------------------------------------------------- 1 | # small_time library, courtesy @thatstoasty , 2025 2 | # https://github.com/thatstoasty/small-time/ 3 | from .small_time import SmallTime, now 4 | from .time_zone import TimeZone 5 | from .time_delta import TimeDelta 6 | -------------------------------------------------------------------------------- /lightbug_http/external/small_time/c.mojo: -------------------------------------------------------------------------------- 1 | # small_time library, courtesy @thatstoasty , 2025 2 | # https://github.com/thatstoasty/small-time/ 3 | from sys import external_call 4 | from sys.ffi import c_uchar 5 | from memory import UnsafePointer, Pointer, stack_allocation 6 | 7 | 8 | @register_passable("trivial") 9 | struct TimeVal: 10 | """Time value.""" 11 | 12 | var tv_sec: Int 13 | """Seconds.""" 14 | var tv_usec: Int 15 | """Microseconds.""" 16 | 17 | fn __init__(out self, tv_sec: Int = 0, tv_usec: Int = 0): 18 | """Initializes a new time value. 19 | 20 | Args: 21 | tv_sec: Seconds. 22 | tv_usec: Microseconds. 23 | """ 24 | self.tv_sec = tv_sec 25 | self.tv_usec = tv_usec 26 | 27 | 28 | @register_passable("trivial") 29 | struct Tm: 30 | """C Tm struct.""" 31 | 32 | var tm_sec: Int32 33 | """Seconds.""" 34 | var tm_min: Int32 35 | """Minutes.""" 36 | var tm_hour: Int32 37 | """Hour.""" 38 | var tm_mday: Int32 39 | """Day of the month.""" 40 | var tm_mon: Int32 41 | """Month.""" 42 | var tm_year: Int32 43 | """Year minus 1900.""" 44 | var tm_wday: Int32 45 | """Day of the week.""" 46 | var tm_yday: Int32 47 | """Day of the year.""" 48 | var tm_isdst: Int32 49 | """Daylight savings flag.""" 50 | var tm_gmtoff: Int64 51 | """Localtime zone offset seconds.""" 52 | 53 | fn __init__(out self): 54 | """Initializes a new time struct.""" 55 | self.tm_sec = 0 56 | self.tm_min = 0 57 | self.tm_hour = 0 58 | self.tm_mday = 0 59 | self.tm_mon = 0 60 | self.tm_year = 0 61 | self.tm_wday = 0 62 | self.tm_yday = 0 63 | self.tm_isdst = 0 64 | self.tm_gmtoff = 0 65 | 66 | 67 | fn gettimeofday() -> TimeVal: 68 | """Gets the current time. It's a wrapper around libc `gettimeofday`. 69 | 70 | Returns: 71 | Current time. 72 | """ 73 | var tv = stack_allocation[1, TimeVal]() 74 | _ = external_call["gettimeofday", Int32](tv, 0) 75 | return tv.take_pointee() 76 | 77 | 78 | fn time() -> Int: 79 | """Returns the current time in seconds since the Epoch. 80 | 81 | Returns: 82 | Current time in seconds. 83 | """ 84 | var time = 0 85 | return external_call["time", Int](Pointer(to=time)) 86 | 87 | 88 | fn localtime(owned tv_sec: Int) -> Tm: 89 | """Converts a time value to a broken-down local time. 90 | 91 | Args: 92 | tv_sec: Time value in seconds since the Epoch. 93 | 94 | Returns: 95 | Broken down local time. 96 | """ 97 | return external_call["localtime", UnsafePointer[Tm]](UnsafePointer(to=tv_sec)).take_pointee() 98 | 99 | 100 | fn strptime(time_str: String, time_format: String) -> Tm: 101 | """Parses a time string according to a format string. 102 | 103 | Args: 104 | time_str: Time string. 105 | time_format: Time format string. 106 | 107 | Returns: 108 | Broken down time. 109 | """ 110 | var tm = stack_allocation[1, Tm]() 111 | _ = external_call["strptime", NoneType, UnsafePointer[c_uchar], UnsafePointer[c_uchar], UnsafePointer[Tm]]( 112 | time_str.unsafe_ptr(), time_format.unsafe_ptr(), tm 113 | ) 114 | return tm.take_pointee() 115 | 116 | 117 | fn gmtime(owned tv_sec: Int) -> Tm: 118 | """Converts a time value to a broken-down UTC time. 119 | 120 | Args: 121 | tv_sec: Time value in seconds since the Epoch. 122 | 123 | Returns: 124 | Broken down UTC time. 125 | """ 126 | return external_call["gmtime", UnsafePointer[Tm]](Pointer(to=tv_sec)).take_pointee() 127 | -------------------------------------------------------------------------------- /lightbug_http/external/small_time/formatter.mojo: -------------------------------------------------------------------------------- 1 | # small_time library, courtesy @thatstoasty , 2025 2 | # https://github.com/thatstoasty/small-time/ 3 | from collections import InlineArray 4 | from collections.string import StringSlice 5 | from utils import StaticTuple 6 | from lightbug_http.external.small_time.time_zone import UTC_TZ 7 | 8 | alias MONTH_NAMES = InlineArray[String, 13]( 9 | "", 10 | "January", 11 | "February", 12 | "March", 13 | "April", 14 | "May", 15 | "June", 16 | "July", 17 | "August", 18 | "September", 19 | "October", 20 | "November", 21 | "December", 22 | ) 23 | """The full month names.""" 24 | 25 | alias MONTH_ABBREVIATIONS = InlineArray[String, 13]( 26 | "", 27 | "Jan", 28 | "Feb", 29 | "Mar", 30 | "Apr", 31 | "May", 32 | "Jun", 33 | "Jul", 34 | "Aug", 35 | "Sep", 36 | "Oct", 37 | "Nov", 38 | "Dec", 39 | ) 40 | """The month name abbreviations.""" 41 | 42 | alias DAY_NAMES = InlineArray[String, 8]( 43 | "", 44 | "Monday", 45 | "Tuesday", 46 | "Wednesday", 47 | "Thursday", 48 | "Friday", 49 | "Saturday", 50 | "Sunday", 51 | ) 52 | """The full day names.""" 53 | alias DAY_ABBREVIATIONS = InlineArray[String, 8]("", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") 54 | """The day name abbreviations.""" 55 | alias formatter = _Formatter() 56 | """Default formatter instance.""" 57 | 58 | 59 | struct _Formatter: 60 | """SmallTime formatter.""" 61 | 62 | var _sub_chrs: StaticTuple[Int, 128] 63 | """Substitution characters.""" 64 | 65 | fn __init__(out self): 66 | """Initializes a new formatter.""" 67 | self._sub_chrs = StaticTuple[Int, 128]() 68 | for i in range(128): 69 | self._sub_chrs[i] = 0 70 | self._sub_chrs[_Y] = 4 71 | self._sub_chrs[_M] = 4 72 | self._sub_chrs[_D] = 2 73 | self._sub_chrs[_d] = 4 74 | self._sub_chrs[_H] = 2 75 | self._sub_chrs[_h] = 2 76 | self._sub_chrs[_m] = 2 77 | self._sub_chrs[_s] = 2 78 | self._sub_chrs[_S] = 6 79 | self._sub_chrs[_Z] = 3 80 | self._sub_chrs[_A] = 1 81 | self._sub_chrs[_a] = 1 82 | 83 | fn format(self, m: SmallTime, fmt: String) -> String: 84 | """Formats the given time value using the specified format string. 85 | "YYYY[abc]MM" -> replace("YYYY") + "abc" + replace("MM") 86 | 87 | Args: 88 | m: Time value. 89 | fmt: Format string. 90 | 91 | Returns: 92 | Formatted time string. 93 | """ 94 | if len(fmt) == 0: 95 | return "" 96 | 97 | var format = fmt.as_string_slice() 98 | var result: String = "" 99 | var in_bracket = False 100 | var start = 0 101 | 102 | for i in range(len(format)): 103 | if format[i] == "[": 104 | if in_bracket: 105 | result.write("[") 106 | else: 107 | in_bracket = True 108 | 109 | result.write(self.replace(m, format[start:i])) 110 | 111 | start = i + 1 112 | elif format[i] == "]": 113 | if in_bracket: 114 | result.write(format[start:i]) 115 | in_bracket = False 116 | else: 117 | result.write(format[start:i]) 118 | result.write("]") 119 | start = i + 1 120 | 121 | if in_bracket: 122 | result.write("[") 123 | 124 | if start < len(format): 125 | result.write(self.replace(m, format[start:])) 126 | return result 127 | 128 | fn replace(self, m: SmallTime, fmt: StringSlice) -> String: 129 | """Replaces the tokens in the given format string with the corresponding values. 130 | 131 | Args: 132 | m: Time value. 133 | fmt: Format string. 134 | 135 | Returns: 136 | Formatted time string. 137 | """ 138 | if len(fmt) == 0: 139 | return "" 140 | 141 | var result: String = "" 142 | var matched_byte = 0 143 | var matched_count = 0 144 | for i in range(len(fmt)): 145 | var c = ord(fmt[i]) 146 | 147 | # If the current character is not a token, add it to the result. 148 | if c > 127 or self._sub_chrs[c] == 0: 149 | if matched_byte > 0: 150 | result += self.replace_token(m, matched_byte, matched_count) 151 | matched_byte = 0 152 | result += fmt[i] 153 | continue 154 | 155 | # If the current character is the same as the previous one, increment the count. 156 | if c == matched_byte: 157 | matched_count += 1 158 | continue 159 | 160 | # If the current character is different from the previous one, replace the previous tokens 161 | # and move onto the next token to track. 162 | result += self.replace_token(m, matched_byte, matched_count) 163 | matched_byte = c 164 | matched_count = 1 165 | 166 | # If no tokens were found, append an empty string and return the original. 167 | if matched_byte > 0: 168 | result += self.replace_token(m, matched_byte, matched_count) 169 | return result 170 | 171 | fn replace_token(self, m: SmallTime, token: Int, token_count: Int) -> String: 172 | if token == _Y: 173 | if token_count == 1: 174 | return "Y" 175 | if token_count == 2: 176 | return String(m.year).rjust(4, "0")[2:4] 177 | if token_count == 4: 178 | return String(m.year).rjust(4, "0") 179 | elif token == _M: 180 | if token_count == 1: 181 | return String(m.month) 182 | if token_count == 2: 183 | return String(m.month).rjust(2, "0") 184 | if token_count == 3: 185 | return MONTH_ABBREVIATIONS[m.month] 186 | if token_count == 4: 187 | return MONTH_NAMES[m.month] 188 | elif token == _D: 189 | if token_count == 1: 190 | return String(m.day) 191 | if token_count == 2: 192 | return String(m.day).rjust(2, "0") 193 | elif token == _H: 194 | if token_count == 1: 195 | return String(m.hour) 196 | if token_count == 2: 197 | return String(m.hour).rjust(2, "0") 198 | elif token == _h: 199 | var h_12 = m.hour 200 | if m.hour > 12: 201 | h_12 -= 12 202 | if token_count == 1: 203 | return String(h_12) 204 | if token_count == 2: 205 | return String(h_12).rjust(2, "0") 206 | elif token == _m: 207 | if token_count == 1: 208 | return String(m.minute) 209 | if token_count == 2: 210 | return String(m.minute).rjust(2, "0") 211 | elif token == _s: 212 | if token_count == 1: 213 | return String(m.second) 214 | if token_count == 2: 215 | return String(m.second).rjust(2, "0") 216 | elif token == _S: 217 | if token_count == 1: 218 | return String(m.microsecond // 100000) 219 | if token_count == 2: 220 | return String(m.microsecond // 10000).rjust(2, "0") 221 | if token_count == 3: 222 | return String(m.microsecond // 1000).rjust(3, "0") 223 | if token_count == 4: 224 | return String(m.microsecond // 100).rjust(4, "0") 225 | if token_count == 5: 226 | return String(m.microsecond // 10).rjust(5, "0") 227 | if token_count == 6: 228 | return String(m.microsecond).rjust(6, "0") 229 | elif token == _d: 230 | if token_count == 1: 231 | return String(m.iso_weekday()) 232 | if token_count == 3: 233 | return DAY_ABBREVIATIONS[m.iso_weekday()] 234 | if token_count == 4: 235 | return DAY_NAMES[m.iso_weekday()] 236 | elif token == _Z: 237 | if token_count == 3: 238 | return String(UTC_TZ) if not m.tz else String(m.tz) 239 | var separator = "" if token_count == 1 else ":" 240 | if not m.tz: 241 | return UTC_TZ.format(separator) 242 | else: 243 | return m.tz.format(separator) 244 | 245 | elif token == _a: 246 | return "am" if m.hour < 12 else "pm" 247 | elif token == _A: 248 | return "AM" if m.hour < 12 else "PM" 249 | return "" 250 | 251 | 252 | alias _Y = ord("Y") 253 | alias _M = ord("M") 254 | alias _D = ord("D") 255 | alias _d = ord("d") 256 | alias _H = ord("H") 257 | alias _h = ord("h") 258 | alias _m = ord("m") 259 | alias _s = ord("s") 260 | alias _S = ord("S") 261 | alias _X = ord("X") 262 | alias _x = ord("x") 263 | alias _Z = ord("Z") 264 | alias _A = ord("A") 265 | alias _a = ord("a") 266 | -------------------------------------------------------------------------------- /lightbug_http/external/small_time/time_delta.mojo: -------------------------------------------------------------------------------- 1 | # small_time library, courtesy @thatstoasty , 2025 2 | # https://github.com/thatstoasty/small-time/ 3 | alias SECONDS_OF_DAY = 24 * 3600 4 | 5 | 6 | @register_passable("trivial") 7 | struct TimeDelta(Stringable): 8 | """Time delta.""" 9 | var days: Int 10 | """Days.""" 11 | var seconds: Int 12 | """Seconds.""" 13 | var microseconds: Int 14 | """Microseconds.""" 15 | 16 | fn __init__( 17 | out self, 18 | days: Int = 0, 19 | seconds: Int = 0, 20 | microseconds: Int = 0, 21 | milliseconds: Int = 0, 22 | minutes: Int = 0, 23 | hours: Int = 0, 24 | weeks: Int = 0, 25 | ): 26 | """Initializes a new time delta. 27 | 28 | Args: 29 | days: Days. 30 | seconds: Seconds. 31 | microseconds: Microseconds. 32 | milliseconds: Milliseconds. 33 | minutes: Minutes. 34 | hours: Hours. 35 | weeks: Weeks. 36 | """ 37 | self.days = 0 38 | self.seconds = 0 39 | self.microseconds = 0 40 | 41 | var days_ = days 42 | var seconds_ = seconds 43 | var microseconds_ = microseconds 44 | 45 | # Normalize everything to days, seconds, microseconds. 46 | days_ += weeks * 7 47 | seconds_ += minutes * 60 + hours * 3600 48 | microseconds_ += milliseconds * 1000 49 | 50 | self.days = days_ 51 | days_ = seconds_ // SECONDS_OF_DAY 52 | seconds_ = seconds_ % SECONDS_OF_DAY 53 | self.days += days_ 54 | self.seconds += seconds_ 55 | 56 | seconds_ = microseconds_ // 1000000 57 | microseconds_ = microseconds_ % 1000000 58 | days_ = seconds_ // SECONDS_OF_DAY 59 | seconds_ = seconds_ % SECONDS_OF_DAY 60 | self.days += days_ 61 | self.seconds += seconds_ 62 | 63 | seconds_ = microseconds_ // 1000000 64 | self.microseconds = microseconds_ % 1000000 65 | self.seconds += seconds_ 66 | days_ = self.seconds // SECONDS_OF_DAY 67 | self.seconds = self.seconds % SECONDS_OF_DAY 68 | self.days += days_ 69 | 70 | fn __str__(self) -> String: 71 | """String representation of the duration. 72 | 73 | Returns: 74 | String representation of the duration. 75 | """ 76 | var mm = self.seconds // 60 77 | var ss = String(self.seconds % 60) 78 | var hh = String(mm // 60) 79 | mm = mm % 60 80 | var s = String(hh, ":", String(mm).rjust(2, "0"), ":", ss.rjust(2, "0")) 81 | if self.days: 82 | if abs(self.days) != 1: 83 | s = String(self.days, " days, ", s) 84 | else: 85 | s = String(self.days, " day, ", s) 86 | if self.microseconds: 87 | s.write(String(self.microseconds).rjust(6, "0")) 88 | return s^ 89 | 90 | fn total_seconds(self) -> Float64: 91 | """Total seconds in the duration. 92 | 93 | Returns: 94 | Total seconds in the duration. 95 | """ 96 | return ((self.days * 86400 + self.seconds) * 10**6 + self.microseconds) / 10**6 97 | 98 | fn __add__(self, other: Self) -> Self: 99 | """Adds two time deltas. 100 | 101 | Args: 102 | other: Time delta to add. 103 | 104 | Returns: 105 | Sum of the two time deltas. 106 | """ 107 | return Self( 108 | self.days + other.days, 109 | self.seconds + other.seconds, 110 | self.microseconds + other.microseconds, 111 | ) 112 | 113 | fn __radd__(self, other: Self) -> Self: 114 | """Adds two time deltas. 115 | 116 | Args: 117 | other: Time delta to add. 118 | 119 | Returns: 120 | Sum of the two time deltas. 121 | """ 122 | return self.__add__(other) 123 | 124 | fn __sub__(self, other: Self) -> Self: 125 | """Subtracts two time deltas. 126 | 127 | Args: 128 | other: Time delta to subtract. 129 | 130 | Returns: 131 | Difference of the two time deltas. 132 | """ 133 | return Self( 134 | self.days - other.days, 135 | self.seconds - other.seconds, 136 | self.microseconds - other.microseconds, 137 | ) 138 | 139 | fn __rsub__(self, other: Self) -> Self: 140 | """Subtracts two time deltas. 141 | 142 | Args: 143 | other: Time delta to subtract. 144 | 145 | Returns: 146 | Difference of the two time deltas. 147 | """ 148 | return Self( 149 | other.days - self.days, 150 | other.seconds - self.seconds, 151 | other.microseconds - self.microseconds, 152 | ) 153 | 154 | fn __neg__(self) -> Self: 155 | """Negates the time delta. 156 | 157 | Returns: 158 | Negated time delta. 159 | """ 160 | return Self(-self.days, -self.seconds, -self.microseconds) 161 | 162 | fn __pos__(self) -> Self: 163 | """Returns the time delta. 164 | 165 | Returns: 166 | Time delta. 167 | """ 168 | return self 169 | 170 | def __abs__(self) -> Self: 171 | """Returns the absolute value of the time delta. 172 | 173 | Returns: 174 | Absolute value of the time delta. 175 | """ 176 | if self.days < 0: 177 | return -self 178 | else: 179 | return self 180 | 181 | fn __mul__(self, other: Int) -> Self: 182 | """Multiplies the time delta by a scalar. 183 | 184 | Args: 185 | other: Scalar to multiply by. 186 | 187 | Returns: 188 | Scaled time delta. 189 | """ 190 | return Self( 191 | self.days * other, 192 | self.seconds * other, 193 | self.microseconds * other, 194 | ) 195 | 196 | fn __rmul__(self, other: Int) -> Self: 197 | """Multiplies the time delta by a scalar. 198 | 199 | Args: 200 | other: Scalar to multiply by. 201 | 202 | Returns: 203 | Scaled time delta. 204 | """ 205 | return self.__mul__(other) 206 | 207 | fn _to_microseconds(self) -> Int: 208 | """Converts the time delta to microseconds. 209 | 210 | Returns: 211 | Time delta in microseconds. 212 | """ 213 | return (self.days * SECONDS_OF_DAY + self.seconds) * 1000000 + self.microseconds 214 | 215 | fn __mod__(self, other: Self) -> Self: 216 | """Returns the remainder of the division of two time deltas. 217 | 218 | Args: 219 | other: Time delta to divide by. 220 | 221 | Returns: 222 | Remainder of the division of two time deltas. 223 | """ 224 | return Self(0, 0, self._to_microseconds() % other._to_microseconds()) 225 | 226 | fn __eq__(self, other: Self) -> Bool: 227 | """Checks if two time deltas are equal. 228 | 229 | Args: 230 | other: Time delta to compare with. 231 | 232 | Returns: 233 | True if the time deltas are equal, False otherwise. 234 | """ 235 | return self.days == other.days and self.seconds == other.seconds and self.microseconds == other.microseconds 236 | 237 | fn __le__(self, other: Self) -> Bool: 238 | """Checks if the time delta is less than or equal to the other time delta. 239 | 240 | Args: 241 | other: Time delta to compare with. 242 | 243 | Returns: 244 | True if the time delta is less than or equal to the other time delta, False otherwise. 245 | """ 246 | if self.days < other.days: 247 | return True 248 | elif self.days == other.days: 249 | if self.seconds < other.seconds: 250 | return True 251 | elif self.seconds == other.seconds and self.microseconds <= other.microseconds: 252 | return True 253 | return False 254 | 255 | fn __lt__(self, other: Self) -> Bool: 256 | """Checks if the time delta is less than the other time delta. 257 | 258 | Args: 259 | other: Time delta to compare with. 260 | 261 | Returns: 262 | True if the time delta is less than the other time delta, False otherwise. 263 | """ 264 | if self.days < other.days: 265 | return True 266 | elif self.days == other.days: 267 | if self.seconds < other.seconds: 268 | return True 269 | elif self.seconds == other.seconds and self.microseconds < other.microseconds: 270 | return True 271 | return False 272 | 273 | fn __ge__(self, other: Self) -> Bool: 274 | """Checks if the time delta is greater than or equal to the other time delta. 275 | 276 | Args: 277 | other: Time delta to compare with. 278 | 279 | Returns: 280 | True if the time delta is greater than or equal to the other time delta, False otherwise. 281 | """ 282 | return not self.__lt__(other) 283 | 284 | fn __gt__(self, other: Self) -> Bool: 285 | """Checks if the time delta is greater than the other time delta. 286 | 287 | Args: 288 | other: Time delta to compare with. 289 | 290 | Returns: 291 | True if the time delta is greater than the other time delta, False otherwise. 292 | """ 293 | return not self.__le__(other) 294 | 295 | fn __bool__(self) -> Bool: 296 | """Checks if the time delta is non-zero. 297 | 298 | Returns: 299 | True if the time delta is non-zero, False otherwise. 300 | """ 301 | return self.days != 0 or self.seconds != 0 or self.microseconds != 0 302 | 303 | 304 | alias MIN = TimeDelta(-99999999) 305 | """Minimum time delta.""" 306 | alias MAX = TimeDelta(days=99999999) 307 | """Maximum time delta.""" 308 | alias RESOLUTION = TimeDelta(microseconds=1) 309 | """Resolution of the time delta.""" 310 | -------------------------------------------------------------------------------- /lightbug_http/external/small_time/time_zone.mojo: -------------------------------------------------------------------------------- 1 | # small_time library, courtesy @thatstoasty , 2025 2 | # https://github.com/thatstoasty/small-time/ 3 | from collections import Optional 4 | import lightbug_http.external.small_time.c 5 | 6 | alias UTC = "UTC" 7 | alias UTC_TZ = TimeZone(0, UTC) 8 | """UTC Timezone.""" 9 | 10 | alias DASH = "-" 11 | alias PLUS = "+" 12 | alias COLON = ":" 13 | 14 | fn local() -> TimeZone: 15 | """Returns the local timezone. 16 | 17 | Returns: 18 | Local timezone. 19 | """ 20 | var local_t = c.localtime(0) 21 | return TimeZone(Int(local_t.tm_gmtoff), "local") 22 | 23 | 24 | fn _is_numeric(c: Byte) -> Bool: 25 | """Checks if a character is numeric. 26 | 27 | Args: 28 | c: Character. 29 | 30 | Returns: 31 | True if the character is numeric, False otherwise. 32 | """ 33 | return c >= ord("0") and c <= ord("9") 34 | 35 | 36 | fn from_utc(utc_str: String) raises -> TimeZone: 37 | """Creates a timezone from a string. 38 | 39 | Args: 40 | utc_str: UTC string. 41 | 42 | Returns: 43 | Timezone. 44 | 45 | Raises: 46 | Error: If the UTC string is invalid. 47 | """ 48 | var timezone = utc_str.as_string_slice() 49 | if len(timezone) == 0: 50 | raise Error("utc_str is empty") 51 | 52 | if timezone == "utc" or timezone == "UTC" or timezone == "Z": 53 | return TimeZone(0, String("utc")) 54 | 55 | var i = 0 56 | # Skip the UTC prefix. 57 | if len(timezone) > 3 and timezone[0:3] == UTC: 58 | i = 3 59 | 60 | var sign = -1 if timezone[i] == DASH else 1 61 | if timezone[i] == PLUS or timezone[i] == DASH: 62 | i += 1 63 | 64 | if len(timezone) < i + 2 or not _is_numeric(ord(timezone[i])) or not _is_numeric(ord(timezone[i + 1])): 65 | raise Error("utc_str format is invalid") 66 | var hours = atol(timezone[i : i + 2]) 67 | i += 2 68 | 69 | var minutes: Int 70 | if len(timezone) <= i: 71 | minutes = 0 72 | elif len(timezone) == i + 3 and timezone[i] == COLON: 73 | minutes = atol(timezone[i + 1 : i + 3]) 74 | elif len(timezone) == i + 2 and _is_numeric(ord(timezone[i])): 75 | minutes = atol(timezone[i : i + 2]) 76 | else: 77 | raise Error("utc_str format is invalid") 78 | 79 | var offset = sign * (hours * 3600 + minutes * 60) 80 | return TimeZone(offset) 81 | 82 | 83 | @value 84 | struct TimeZone(Stringable): 85 | """Timezone.""" 86 | var offset: Int 87 | """Offset in seconds.""" 88 | var name: Optional[String] 89 | """Name of the timezone.""" 90 | 91 | fn __init__(out self, offset: Int = 0, name: String = "utc"): 92 | """Initializes a new timezone. 93 | 94 | Args: 95 | offset: Offset in seconds. 96 | name: Name of the timezone. 97 | """ 98 | self.offset = offset 99 | self.name = name 100 | 101 | fn __str__(self) -> String: 102 | """String representation of the timezone. 103 | 104 | Returns: 105 | String representation. 106 | """ 107 | if self.name: 108 | return self.name.value() 109 | return "" 110 | 111 | fn __bool__(self) -> Bool: 112 | """Checks if the timezone is valid. 113 | 114 | Returns: 115 | True if the timezone is valid, False otherwise. 116 | """ 117 | return self.name.__bool__() 118 | 119 | fn format(self, sep: String = ":") -> String: 120 | """Formats the timezone. 121 | 122 | Args: 123 | sep: Separator between hours and minutes. 124 | 125 | Returns: 126 | Formatted timezone. 127 | """ 128 | var sign: String 129 | var offset_abs: Int 130 | if self.offset < 0: 131 | sign = "-" 132 | offset_abs = -self.offset 133 | else: 134 | sign = "+" 135 | offset_abs = self.offset 136 | var hh = String(offset_abs // 3600) 137 | var mm = String(offset_abs % 3600) 138 | return String(sign, hh.rjust(2, "0"), sep, mm.rjust(2, "0")) 139 | -------------------------------------------------------------------------------- /lightbug_http/header.mojo: -------------------------------------------------------------------------------- 1 | from collections import Dict, Optional 2 | from lightbug_http.io.bytes import Bytes, ByteReader, ByteWriter, is_newline, is_space 3 | from lightbug_http.strings import BytesConstant 4 | from lightbug_http._logger import logger 5 | from lightbug_http.strings import rChar, nChar, lineBreak, to_string 6 | 7 | 8 | struct HeaderKey: 9 | # TODO: Fill in more of these 10 | alias CONNECTION = "connection" 11 | alias CONTENT_TYPE = "content-type" 12 | alias CONTENT_LENGTH = "content-length" 13 | alias CONTENT_ENCODING = "content-encoding" 14 | alias TRANSFER_ENCODING = "transfer-encoding" 15 | alias DATE = "date" 16 | alias LOCATION = "location" 17 | alias HOST = "host" 18 | alias SERVER = "server" 19 | alias SET_COOKIE = "set-cookie" 20 | alias COOKIE = "cookie" 21 | 22 | 23 | @value 24 | struct Header(Writable, Stringable): 25 | var key: String 26 | var value: String 27 | 28 | fn __str__(self) -> String: 29 | return String.write(self) 30 | 31 | fn write_to[T: Writer, //](self, mut writer: T): 32 | writer.write(self.key + ": ", self.value, lineBreak) 33 | 34 | 35 | @always_inline 36 | fn write_header[T: Writer](mut writer: T, key: String, value: String): 37 | writer.write(key + ": ", value, lineBreak) 38 | 39 | 40 | @value 41 | struct Headers(Writable, Stringable): 42 | """Represents the header key/values in an http request/response. 43 | 44 | Header keys are normalized to lowercase 45 | """ 46 | 47 | var _inner: Dict[String, String] 48 | 49 | fn __init__(out self): 50 | self._inner = Dict[String, String]() 51 | 52 | fn __init__(out self, owned *headers: Header): 53 | self._inner = Dict[String, String]() 54 | for header in headers: 55 | self[header[].key.lower()] = header[].value 56 | 57 | @always_inline 58 | fn empty(self) -> Bool: 59 | return len(self._inner) == 0 60 | 61 | @always_inline 62 | fn __contains__(self, key: String) -> Bool: 63 | return key.lower() in self._inner 64 | 65 | @always_inline 66 | fn __getitem__(self, key: String) raises -> String: 67 | try: 68 | return self._inner[key.lower()] 69 | except: 70 | raise Error("KeyError: Key not found in headers: " + key) 71 | 72 | @always_inline 73 | fn get(self, key: String) -> Optional[String]: 74 | return self._inner.get(key.lower()) 75 | 76 | @always_inline 77 | fn __setitem__(mut self, key: String, value: String): 78 | self._inner[key.lower()] = value 79 | 80 | fn content_length(self) -> Int: 81 | try: 82 | return Int(self[HeaderKey.CONTENT_LENGTH]) 83 | except: 84 | return 0 85 | 86 | fn parse_raw(mut self, mut r: ByteReader) raises -> (String, String, String, List[String]): 87 | var first_byte = r.peek() 88 | if not first_byte: 89 | raise Error("Headers.parse_raw: Failed to read first byte from response header") 90 | 91 | var first = r.read_word() 92 | r.increment() 93 | var second = r.read_word() 94 | r.increment() 95 | var third = r.read_line() 96 | var cookies = List[String]() 97 | 98 | while not is_newline(r.peek()): 99 | var key = r.read_until(BytesConstant.colon) 100 | r.increment() 101 | if is_space(r.peek()): 102 | r.increment() 103 | # TODO (bgreni): Handle possible trailing whitespace 104 | var value = r.read_line() 105 | var k = String(key).lower() 106 | if k == HeaderKey.SET_COOKIE: 107 | cookies.append(String(value)) 108 | continue 109 | 110 | self._inner[k] = String(value) 111 | return (String(first), String(second), String(third), cookies) 112 | 113 | fn write_to[T: Writer, //](self, mut writer: T): 114 | for header in self._inner.items(): 115 | write_header(writer, header[].key, header[].value) 116 | 117 | fn __str__(self) -> String: 118 | return String.write(self) 119 | -------------------------------------------------------------------------------- /lightbug_http/http/__init__.mojo: -------------------------------------------------------------------------------- 1 | from .common_response import * 2 | from .response import * 3 | from .request import * 4 | from .http_version import HttpVersion 5 | 6 | 7 | trait Encodable: 8 | fn encode(owned self) -> Bytes: 9 | ... 10 | 11 | 12 | @always_inline 13 | fn encode[T: Encodable](owned data: T) -> Bytes: 14 | return data^.encode() 15 | -------------------------------------------------------------------------------- /lightbug_http/http/common_response.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.io.bytes import Bytes 2 | 3 | 4 | fn OK(body: String, content_type: String = "text/plain") -> HTTPResponse: 5 | return HTTPResponse( 6 | headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), 7 | body_bytes=bytes(body), 8 | ) 9 | 10 | 11 | fn OK(body: Bytes, content_type: String = "text/plain") -> HTTPResponse: 12 | return HTTPResponse( 13 | headers=Headers(Header(HeaderKey.CONTENT_TYPE, content_type)), 14 | body_bytes=body, 15 | ) 16 | 17 | 18 | fn OK(body: Bytes, content_type: String, content_encoding: String) -> HTTPResponse: 19 | return HTTPResponse( 20 | headers=Headers( 21 | Header(HeaderKey.CONTENT_TYPE, content_type), 22 | Header(HeaderKey.CONTENT_ENCODING, content_encoding), 23 | ), 24 | body_bytes=body, 25 | ) 26 | 27 | 28 | fn NotFound(path: String) -> HTTPResponse: 29 | return HTTPResponse( 30 | status_code=404, 31 | status_text="Not Found", 32 | headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), 33 | body_bytes=bytes("path " + path + " not found"), 34 | ) 35 | 36 | 37 | fn InternalError() -> HTTPResponse: 38 | return HTTPResponse( 39 | bytes("Failed to process request"), 40 | status_code=500, 41 | headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), 42 | status_text="Internal Server Error", 43 | ) 44 | 45 | fn BadRequest() -> HTTPResponse: 46 | return HTTPResponse( 47 | bytes("Bad Request"), 48 | status_code=400, 49 | headers=Headers(Header(HeaderKey.CONTENT_TYPE, "text/plain")), 50 | status_text="Bad Request", 51 | ) 52 | -------------------------------------------------------------------------------- /lightbug_http/http/http_version.mojo: -------------------------------------------------------------------------------- 1 | # TODO: Apply this to request/response structs 2 | @value 3 | @register_passable("trivial") 4 | struct HttpVersion(EqualityComparable, Stringable): 5 | var _v: Int 6 | 7 | fn __init__(out self, version: String) raises: 8 | self._v = Int(version[version.find("/") + 1]) 9 | 10 | fn __eq__(self, other: Self) -> Bool: 11 | return self._v == other._v 12 | 13 | fn __ne__(self, other: Self) -> Bool: 14 | return self._v != other._v 15 | 16 | fn __eq__(self, other: Int) -> Bool: 17 | return self._v == other 18 | 19 | fn __ne__(self, other: Int) -> Bool: 20 | return self._v != other 21 | 22 | fn __str__(self) -> String: 23 | # Only support version 1.1 so don't need to account for 1.0 24 | v = "1.1" if self._v == 1 else String(self._v) 25 | return "HTTP/" + v 26 | -------------------------------------------------------------------------------- /lightbug_http/http/request.mojo: -------------------------------------------------------------------------------- 1 | from memory import Span 2 | from lightbug_http.io.bytes import Bytes, bytes, ByteReader, ByteWriter 3 | from lightbug_http.header import Headers, HeaderKey, Header, write_header 4 | from lightbug_http.cookie import RequestCookieJar 5 | from lightbug_http.uri import URI 6 | from lightbug_http._logger import logger 7 | from lightbug_http.io.sync import Duration 8 | from lightbug_http.strings import ( 9 | strHttp11, 10 | strHttp, 11 | strSlash, 12 | whitespace, 13 | rChar, 14 | nChar, 15 | lineBreak, 16 | to_string, 17 | ) 18 | 19 | 20 | @value 21 | struct RequestMethod: 22 | var value: String 23 | 24 | alias get = RequestMethod("GET") 25 | alias post = RequestMethod("POST") 26 | alias put = RequestMethod("PUT") 27 | alias delete = RequestMethod("DELETE") 28 | alias head = RequestMethod("HEAD") 29 | alias patch = RequestMethod("PATCH") 30 | alias options = RequestMethod("OPTIONS") 31 | 32 | 33 | @value 34 | struct HTTPRequest(Writable, Stringable): 35 | var headers: Headers 36 | var cookies: RequestCookieJar 37 | var uri: URI 38 | var body_raw: Bytes 39 | 40 | var method: String 41 | var protocol: String 42 | 43 | var server_is_tls: Bool 44 | var timeout: Duration 45 | 46 | @staticmethod 47 | fn from_bytes(addr: String, max_body_size: Int, b: Span[Byte]) raises -> HTTPRequest: 48 | var reader = ByteReader(b) 49 | var headers = Headers() 50 | var method: String 51 | var protocol: String 52 | var uri: String 53 | try: 54 | var rest = headers.parse_raw(reader) 55 | method, uri, protocol = rest[0], rest[1], rest[2] 56 | except e: 57 | raise Error("HTTPRequest.from_bytes: Failed to parse request headers: " + String(e)) 58 | 59 | var cookies = RequestCookieJar() 60 | try: 61 | cookies.parse_cookies(headers) 62 | except e: 63 | raise Error("HTTPRequest.from_bytes: Failed to parse cookies: " + String(e)) 64 | 65 | var content_length = headers.content_length() 66 | if content_length > 0 and max_body_size > 0 and content_length > max_body_size: 67 | raise Error("HTTPRequest.from_bytes: Request body too large.") 68 | 69 | var request = HTTPRequest( 70 | URI.parse(addr + uri), headers=headers, method=method, protocol=protocol, cookies=cookies 71 | ) 72 | 73 | if content_length > 0: 74 | try: 75 | reader.skip_carriage_return() 76 | request.read_body(reader, content_length, max_body_size) 77 | except e: 78 | raise Error("HTTPRequest.from_bytes: Failed to read request body: " + String(e)) 79 | 80 | return request 81 | 82 | fn __init__( 83 | out self, 84 | uri: URI, 85 | headers: Headers = Headers(), 86 | cookies: RequestCookieJar = RequestCookieJar(), 87 | method: String = "GET", 88 | protocol: String = strHttp11, 89 | body: Bytes = Bytes(), 90 | server_is_tls: Bool = False, 91 | timeout: Duration = Duration(), 92 | ): 93 | self.headers = headers 94 | self.cookies = cookies 95 | self.method = method 96 | self.protocol = protocol 97 | self.uri = uri 98 | self.body_raw = body 99 | self.server_is_tls = server_is_tls 100 | self.timeout = timeout 101 | self.set_content_length(len(body)) 102 | if HeaderKey.CONNECTION not in self.headers: 103 | self.headers[HeaderKey.CONNECTION] = "keep-alive" 104 | if HeaderKey.HOST not in self.headers: 105 | if uri.port: 106 | var host = String.write(uri.host, ":", String(uri.port.value())) 107 | self.headers[HeaderKey.HOST] = host 108 | else: 109 | self.headers[HeaderKey.HOST] = uri.host 110 | 111 | fn get_body(self) -> StringSlice[__origin_of(self.body_raw)]: 112 | return StringSlice(unsafe_from_utf8=Span(self.body_raw)) 113 | 114 | fn set_connection_close(mut self): 115 | self.headers[HeaderKey.CONNECTION] = "close" 116 | 117 | fn set_content_length(mut self, l: Int): 118 | self.headers[HeaderKey.CONTENT_LENGTH] = String(l) 119 | 120 | fn connection_close(self) -> Bool: 121 | var result = self.headers.get(HeaderKey.CONNECTION) 122 | if not result: 123 | return False 124 | return result.value() == "close" 125 | 126 | @always_inline 127 | fn read_body(mut self, mut r: ByteReader, content_length: Int, max_body_size: Int) raises -> None: 128 | if content_length > max_body_size: 129 | raise Error("Request body too large") 130 | 131 | try: 132 | self.body_raw = r.read_bytes(content_length).to_bytes() 133 | self.set_content_length(len(self.body_raw)) 134 | except OutOfBoundsError: 135 | logger.debug( 136 | "Failed to read full request body as per content-length header. Proceeding with the available bytes." 137 | ) 138 | var available_bytes = len(r._inner) - r.read_pos 139 | if available_bytes > 0: 140 | self.body_raw = r.read_bytes(available_bytes).to_bytes() 141 | self.set_content_length(len(self.body_raw)) 142 | else: 143 | logger.debug("No body bytes available. Setting content-length to 0.") 144 | self.body_raw = Bytes() 145 | self.set_content_length(0) 146 | 147 | fn write_to[T: Writer, //](self, mut writer: T): 148 | path = self.uri.path if len(self.uri.path) > 1 else strSlash 149 | if len(self.uri.query_string) > 0: 150 | path.write("?", self.uri.query_string) 151 | 152 | writer.write( 153 | self.method, 154 | whitespace, 155 | path, 156 | whitespace, 157 | self.protocol, 158 | lineBreak, 159 | self.headers, 160 | self.cookies, 161 | lineBreak, 162 | to_string(self.body_raw), 163 | ) 164 | 165 | fn encode(owned self) -> Bytes: 166 | """Encodes request as bytes. 167 | 168 | This method consumes the data in this request and it should 169 | no longer be considered valid. 170 | """ 171 | var path = self.uri.path if len(self.uri.path) > 1 else strSlash 172 | if len(self.uri.query_string) > 0: 173 | path.write("?", self.uri.query_string) 174 | 175 | var writer = ByteWriter() 176 | writer.write( 177 | self.method, 178 | whitespace, 179 | path, 180 | whitespace, 181 | self.protocol, 182 | lineBreak, 183 | self.headers, 184 | self.cookies, 185 | lineBreak, 186 | ) 187 | writer.consuming_write(self^.body_raw) 188 | return writer^.consume() 189 | 190 | fn __str__(self) -> String: 191 | return String.write(self) 192 | -------------------------------------------------------------------------------- /lightbug_http/http/response.mojo: -------------------------------------------------------------------------------- 1 | from collections import Optional 2 | from lightbug_http.external.small_time.small_time import now 3 | from lightbug_http.uri import URI 4 | from lightbug_http.io.bytes import Bytes, bytes, byte, ByteReader, ByteWriter 5 | from lightbug_http.connection import TCPConnection, default_buffer_size 6 | from lightbug_http.strings import ( 7 | strHttp11, 8 | strHttp, 9 | strSlash, 10 | whitespace, 11 | rChar, 12 | nChar, 13 | lineBreak, 14 | to_string, 15 | ) 16 | 17 | 18 | struct StatusCode: 19 | alias OK = 200 20 | alias MOVED_PERMANENTLY = 301 21 | alias FOUND = 302 22 | alias TEMPORARY_REDIRECT = 307 23 | alias PERMANENT_REDIRECT = 308 24 | alias NOT_FOUND = 404 25 | alias INTERNAL_ERROR = 500 26 | 27 | 28 | @value 29 | struct HTTPResponse(Writable, Stringable): 30 | var headers: Headers 31 | var cookies: ResponseCookieJar 32 | var body_raw: Bytes 33 | 34 | var status_code: Int 35 | var status_text: String 36 | var protocol: String 37 | 38 | @staticmethod 39 | fn from_bytes(b: Span[Byte]) raises -> HTTPResponse: 40 | var reader = ByteReader(b) 41 | var headers = Headers() 42 | var cookies = ResponseCookieJar() 43 | var protocol: String 44 | var status_code: String 45 | var status_text: String 46 | 47 | try: 48 | var properties = headers.parse_raw(reader) 49 | protocol, status_code, status_text = properties[0], properties[1], properties[2] 50 | cookies.from_headers(properties[3]) 51 | reader.skip_carriage_return() 52 | except e: 53 | raise Error("Failed to parse response headers: " + String(e)) 54 | 55 | try: 56 | return HTTPResponse( 57 | reader=reader, 58 | headers=headers, 59 | cookies=cookies, 60 | protocol=protocol, 61 | status_code=Int(status_code), 62 | status_text=status_text, 63 | ) 64 | except e: 65 | logger.error(e) 66 | raise Error("Failed to read request body") 67 | 68 | @staticmethod 69 | fn from_bytes(b: Span[Byte], conn: TCPConnection) raises -> HTTPResponse: 70 | var reader = ByteReader(b) 71 | var headers = Headers() 72 | var cookies = ResponseCookieJar() 73 | var protocol: String 74 | var status_code: String 75 | var status_text: String 76 | 77 | try: 78 | var properties = headers.parse_raw(reader) 79 | protocol, status_code, status_text = properties[0], properties[1], properties[2] 80 | cookies.from_headers(properties[3]) 81 | reader.skip_carriage_return() 82 | except e: 83 | raise Error("Failed to parse response headers: " + String(e)) 84 | 85 | var response = HTTPResponse( 86 | Bytes(), 87 | headers=headers, 88 | cookies=cookies, 89 | protocol=protocol, 90 | status_code=Int(status_code), 91 | status_text=status_text, 92 | ) 93 | 94 | var transfer_encoding = response.headers.get(HeaderKey.TRANSFER_ENCODING) 95 | if transfer_encoding and transfer_encoding.value() == "chunked": 96 | var b = reader.read_bytes().to_bytes() 97 | var buff = Bytes(capacity=default_buffer_size) 98 | try: 99 | while conn.read(buff) > 0: 100 | b += buff 101 | 102 | if ( 103 | buff[-5] == byte("0") 104 | and buff[-4] == byte("\r") 105 | and buff[-3] == byte("\n") 106 | and buff[-2] == byte("\r") 107 | and buff[-1] == byte("\n") 108 | ): 109 | break 110 | 111 | buff.clear() 112 | response.read_chunks(b) 113 | return response 114 | except e: 115 | logger.error(e) 116 | raise Error("Failed to read chunked response.") 117 | 118 | try: 119 | response.read_body(reader) 120 | return response 121 | except e: 122 | logger.error(e) 123 | raise Error("Failed to read request body: ") 124 | 125 | fn __init__( 126 | out self, 127 | body_bytes: Span[Byte], 128 | headers: Headers = Headers(), 129 | cookies: ResponseCookieJar = ResponseCookieJar(), 130 | status_code: Int = 200, 131 | status_text: String = "OK", 132 | protocol: String = strHttp11, 133 | ): 134 | self.headers = headers 135 | self.cookies = cookies 136 | if HeaderKey.CONTENT_TYPE not in self.headers: 137 | self.headers[HeaderKey.CONTENT_TYPE] = "application/octet-stream" 138 | self.status_code = status_code 139 | self.status_text = status_text 140 | self.protocol = protocol 141 | self.body_raw = Bytes(body_bytes) 142 | if HeaderKey.CONNECTION not in self.headers: 143 | self.set_connection_keep_alive() 144 | if HeaderKey.CONTENT_LENGTH not in self.headers: 145 | self.set_content_length(len(body_bytes)) 146 | if HeaderKey.DATE not in self.headers: 147 | try: 148 | var current_time = String(now(utc=True)) 149 | self.headers[HeaderKey.DATE] = current_time 150 | except: 151 | logger.debug("DATE header not set, unable to get current time and it was instead omitted.") 152 | 153 | fn __init__( 154 | out self, 155 | mut reader: ByteReader, 156 | headers: Headers = Headers(), 157 | cookies: ResponseCookieJar = ResponseCookieJar(), 158 | status_code: Int = 200, 159 | status_text: String = "OK", 160 | protocol: String = strHttp11, 161 | ) raises: 162 | self.headers = headers 163 | self.cookies = cookies 164 | if HeaderKey.CONTENT_TYPE not in self.headers: 165 | self.headers[HeaderKey.CONTENT_TYPE] = "application/octet-stream" 166 | self.status_code = status_code 167 | self.status_text = status_text 168 | self.protocol = protocol 169 | self.body_raw = reader.read_bytes().to_bytes() 170 | self.set_content_length(len(self.body_raw)) 171 | if HeaderKey.CONNECTION not in self.headers: 172 | self.set_connection_keep_alive() 173 | if HeaderKey.CONTENT_LENGTH not in self.headers: 174 | self.set_content_length(len(self.body_raw)) 175 | if HeaderKey.DATE not in self.headers: 176 | try: 177 | var current_time = String(now(utc=True)) 178 | self.headers[HeaderKey.DATE] = current_time 179 | except: 180 | pass 181 | 182 | fn get_body(self) -> StringSlice[__origin_of(self.body_raw)]: 183 | return StringSlice(unsafe_from_utf8=Span(self.body_raw)) 184 | 185 | @always_inline 186 | fn set_connection_close(mut self): 187 | self.headers[HeaderKey.CONNECTION] = "close" 188 | 189 | fn connection_close(self) -> Bool: 190 | var result = self.headers.get(HeaderKey.CONNECTION) 191 | if not result: 192 | return False 193 | return result.value() == "close" 194 | 195 | @always_inline 196 | fn set_connection_keep_alive(mut self): 197 | self.headers[HeaderKey.CONNECTION] = "keep-alive" 198 | 199 | @always_inline 200 | fn set_content_length(mut self, l: Int): 201 | self.headers[HeaderKey.CONTENT_LENGTH] = String(l) 202 | 203 | @always_inline 204 | fn content_length(self) -> Int: 205 | try: 206 | return Int(self.headers[HeaderKey.CONTENT_LENGTH]) 207 | except: 208 | return 0 209 | 210 | @always_inline 211 | fn is_redirect(self) -> Bool: 212 | return ( 213 | self.status_code == StatusCode.MOVED_PERMANENTLY 214 | or self.status_code == StatusCode.FOUND 215 | or self.status_code == StatusCode.TEMPORARY_REDIRECT 216 | or self.status_code == StatusCode.PERMANENT_REDIRECT 217 | ) 218 | 219 | @always_inline 220 | fn read_body(mut self, mut r: ByteReader) raises -> None: 221 | self.body_raw = r.read_bytes(self.content_length()).to_bytes() 222 | self.set_content_length(len(self.body_raw)) 223 | 224 | fn read_chunks(mut self, chunks: Span[Byte]) raises: 225 | var reader = ByteReader(chunks) 226 | while True: 227 | var size = atol(String(reader.read_line()), 16) 228 | if size == 0: 229 | break 230 | var data = reader.read_bytes(size).to_bytes() 231 | reader.skip_carriage_return() 232 | self.set_content_length(self.content_length() + len(data)) 233 | self.body_raw += data 234 | 235 | fn write_to[T: Writer](self, mut writer: T): 236 | writer.write(self.protocol, whitespace, self.status_code, whitespace, self.status_text, lineBreak) 237 | 238 | if HeaderKey.SERVER not in self.headers: 239 | writer.write("server: lightbug_http", lineBreak) 240 | 241 | writer.write(self.headers, self.cookies, lineBreak, to_string(self.body_raw)) 242 | 243 | fn encode(owned self) -> Bytes: 244 | """Encodes response as bytes. 245 | 246 | This method consumes the data in this request and it should 247 | no longer be considered valid. 248 | """ 249 | var writer = ByteWriter() 250 | writer.write( 251 | self.protocol, 252 | whitespace, 253 | String(self.status_code), 254 | whitespace, 255 | self.status_text, 256 | lineBreak, 257 | "server: lightbug_http", 258 | lineBreak, 259 | ) 260 | if HeaderKey.DATE not in self.headers: 261 | try: 262 | write_header(writer, HeaderKey.DATE, String(now(utc=True))) 263 | except: 264 | pass 265 | writer.write(self.headers, self.cookies, lineBreak) 266 | writer.consuming_write(self.body_raw^) 267 | self.body_raw = Bytes() 268 | return writer^.consume() 269 | 270 | fn __str__(self) -> String: 271 | return String.write(self) 272 | -------------------------------------------------------------------------------- /lightbug_http/io/__init__.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.io.bytes import Bytes 2 | from lightbug_http.io.sync import Duration 3 | -------------------------------------------------------------------------------- /lightbug_http/io/bytes.mojo: -------------------------------------------------------------------------------- 1 | from memory.span import Span, _SpanIter, UnsafePointer 2 | from lightbug_http.strings import BytesConstant 3 | from lightbug_http.connection import default_buffer_size 4 | 5 | 6 | alias Bytes = List[Byte, True] 7 | 8 | 9 | @always_inline 10 | fn byte(s: String) -> Byte: 11 | return ord(s) 12 | 13 | 14 | @always_inline 15 | fn bytes(s: String) -> Bytes: 16 | return Bytes(s.as_bytes()) 17 | 18 | 19 | @always_inline 20 | fn is_newline(b: Byte) -> Bool: 21 | return b == BytesConstant.nChar or b == BytesConstant.rChar 22 | 23 | 24 | @always_inline 25 | fn is_space(b: Byte) -> Bool: 26 | return b == BytesConstant.whitespace 27 | 28 | 29 | struct ByteWriter(Writer): 30 | var _inner: Bytes 31 | 32 | fn __init__(out self, capacity: Int = default_buffer_size): 33 | self._inner = Bytes(capacity=capacity) 34 | 35 | @always_inline 36 | fn write_bytes(mut self, bytes: Span[Byte]) -> None: 37 | """Writes the contents of `bytes` into the internal buffer. 38 | 39 | Args: 40 | bytes: The bytes to write. 41 | """ 42 | self._inner.extend(bytes) 43 | 44 | fn write[*Ts: Writable](mut self, *args: *Ts) -> None: 45 | """Write data to the `Writer`. 46 | 47 | Parameters: 48 | Ts: The types of data to write. 49 | 50 | Args: 51 | args: The data to write. 52 | """ 53 | 54 | @parameter 55 | fn write_arg[T: Writable](arg: T): 56 | arg.write_to(self) 57 | 58 | args.each[write_arg]() 59 | 60 | @always_inline 61 | fn consuming_write(mut self, owned b: Bytes): 62 | self._inner.extend(b^) 63 | 64 | @always_inline 65 | fn write_byte(mut self, b: Byte): 66 | self._inner.append(b) 67 | 68 | fn consume(owned self) -> Bytes: 69 | var ret = self._inner^ 70 | self._inner = Bytes() 71 | return ret^ 72 | 73 | 74 | alias EndOfReaderError = "No more bytes to read." 75 | alias OutOfBoundsError = "Tried to read past the end of the ByteReader." 76 | 77 | 78 | @value 79 | struct ByteView[origin: Origin]: 80 | """Convenience wrapper around a Span of Bytes.""" 81 | 82 | var _inner: Span[Byte, origin] 83 | 84 | @implicit 85 | fn __init__(out self, b: Span[Byte, origin]): 86 | self._inner = b 87 | 88 | fn __len__(self) -> Int: 89 | return len(self._inner) 90 | 91 | fn __bool__(self) -> Bool: 92 | return self._inner.__bool__() 93 | 94 | fn __contains__(self, b: Byte) -> Bool: 95 | for i in range(len(self._inner)): 96 | if self._inner[i] == b: 97 | return True 98 | return False 99 | 100 | fn __contains__(self, b: Bytes) -> Bool: 101 | if len(b) > len(self._inner): 102 | return False 103 | 104 | for i in range(len(self._inner) - len(b) + 1): 105 | var found = True 106 | for j in range(len(b)): 107 | if self._inner[i + j] != b[j]: 108 | found = False 109 | break 110 | if found: 111 | return True 112 | return False 113 | 114 | fn __getitem__(self, index: Int) -> Byte: 115 | return self._inner[index] 116 | 117 | fn __getitem__(self, slc: Slice) -> Self: 118 | return Self(self._inner[slc]) 119 | 120 | fn __str__(self) -> String: 121 | return String(StringSlice(unsafe_from_utf8=self._inner)) 122 | 123 | fn __eq__(self, other: Self) -> Bool: 124 | # both empty 125 | if not self._inner and not other._inner: 126 | return True 127 | if len(self) != len(other): 128 | return False 129 | 130 | for i in range(len(self)): 131 | if self[i] != other[i]: 132 | return False 133 | return True 134 | 135 | fn __eq__(self, other: Span[Byte]) -> Bool: 136 | # both empty 137 | if not self._inner and not other: 138 | return True 139 | if len(self) != len(other): 140 | return False 141 | 142 | for i in range(len(self)): 143 | if self[i] != other[i]: 144 | return False 145 | return True 146 | 147 | fn __eq__(self, other: Bytes) -> Bool: 148 | # Check if lengths match 149 | if len(self) != len(other): 150 | return False 151 | 152 | # Compare each byte 153 | for i in range(len(self)): 154 | if self[i] != other[i]: 155 | return False 156 | return True 157 | 158 | fn __ne__(self, other: Self) -> Bool: 159 | return not self == other 160 | 161 | fn __ne__(self, other: Span[Byte]) -> Bool: 162 | return not self == other 163 | 164 | fn __iter__(self) -> _SpanIter[Byte, origin]: 165 | return self._inner.__iter__() 166 | 167 | fn find(self, target: Byte) -> Int: 168 | """Finds the index of a byte in a byte span. 169 | 170 | Args: 171 | target: The byte to find. 172 | 173 | Returns: 174 | The index of the byte in the span, or -1 if not found. 175 | """ 176 | for i in range(len(self)): 177 | if self[i] == target: 178 | return i 179 | 180 | return -1 181 | 182 | fn rfind(self, target: Byte) -> Int: 183 | """Finds the index of the last occurrence of a byte in a byte span. 184 | 185 | Args: 186 | target: The byte to find. 187 | 188 | Returns: 189 | The index of the last occurrence of the byte in the span, or -1 if not found. 190 | """ 191 | # Start from the end and work backwards 192 | var i = len(self) - 1 193 | while i >= 0: 194 | if self[i] == target: 195 | return i 196 | i -= 1 197 | 198 | return -1 199 | 200 | fn to_bytes(self) -> Bytes: 201 | return Bytes(self._inner) 202 | 203 | 204 | struct ByteReader[origin: Origin]: 205 | var _inner: Span[Byte, origin] 206 | var read_pos: Int 207 | 208 | fn __init__(out self, b: Span[Byte, origin]): 209 | self._inner = b 210 | self.read_pos = 0 211 | 212 | fn copy(self) -> Self: 213 | return ByteReader(self._inner[self.read_pos :]) 214 | 215 | fn __contains__(self, b: Byte) -> Bool: 216 | for i in range(self.read_pos, len(self._inner)): 217 | if self._inner[i] == b: 218 | return True 219 | return False 220 | 221 | @always_inline 222 | fn available(self) -> Bool: 223 | return self.read_pos < len(self._inner) 224 | 225 | fn __len__(self) -> Int: 226 | return len(self._inner) - self.read_pos 227 | 228 | fn peek(self) raises -> Byte: 229 | if not self.available(): 230 | raise EndOfReaderError 231 | return self._inner[self.read_pos] 232 | 233 | fn read_bytes(mut self, n: Int = -1) raises -> ByteView[origin]: 234 | var count = n 235 | var start = self.read_pos 236 | if n == -1: 237 | count = len(self) 238 | 239 | if start + count > len(self._inner): 240 | raise OutOfBoundsError 241 | 242 | self.read_pos += count 243 | return self._inner[start : start + count] 244 | 245 | fn read_until(mut self, char: Byte) -> ByteView[origin]: 246 | var start = self.read_pos 247 | for i in range(start, len(self._inner)): 248 | if self._inner[i] == char: 249 | break 250 | self.increment() 251 | 252 | return self._inner[start : self.read_pos] 253 | 254 | @always_inline 255 | fn read_word(mut self) -> ByteView[origin]: 256 | return self.read_until(BytesConstant.whitespace) 257 | 258 | fn read_line(mut self) -> ByteView[origin]: 259 | var start = self.read_pos 260 | for i in range(start, len(self._inner)): 261 | if is_newline(self._inner[i]): 262 | break 263 | self.increment() 264 | 265 | # If we are at the end of the buffer, there is no newline to check for. 266 | var ret = self._inner[start : self.read_pos] 267 | if not self.available(): 268 | return ret 269 | 270 | if self._inner[self.read_pos] == BytesConstant.rChar: 271 | self.increment(2) 272 | else: 273 | self.increment() 274 | return ret 275 | 276 | @always_inline 277 | fn skip_whitespace(mut self): 278 | for i in range(self.read_pos, len(self._inner)): 279 | if is_space(self._inner[i]): 280 | self.increment() 281 | else: 282 | break 283 | 284 | @always_inline 285 | fn skip_carriage_return(mut self): 286 | for i in range(self.read_pos, len(self._inner)): 287 | if self._inner[i] == BytesConstant.rChar: 288 | self.increment(2) 289 | else: 290 | break 291 | 292 | @always_inline 293 | fn increment(mut self, v: Int = 1): 294 | self.read_pos += v 295 | 296 | @always_inline 297 | fn consume(owned self, bytes_len: Int = -1) -> Bytes: 298 | return Bytes(self^._inner[self.read_pos : self.read_pos + len(self) + 1]) 299 | -------------------------------------------------------------------------------- /lightbug_http/io/sync.mojo: -------------------------------------------------------------------------------- 1 | # Time in nanoseconds 2 | alias Duration = Int 3 | -------------------------------------------------------------------------------- /lightbug_http/pool_manager.mojo: -------------------------------------------------------------------------------- 1 | from collections import Dict 2 | from lightbug_http.connection import create_connection, TCPConnection, Connection 3 | from lightbug_http._logger import logger 4 | from lightbug_http._owning_list import OwningList 5 | from lightbug_http.uri import Scheme 6 | 7 | 8 | @value 9 | struct PoolKey(Hashable, KeyElement, Writable): 10 | var host: String 11 | var port: UInt16 12 | var scheme: Scheme 13 | 14 | fn __init__(out self, host: String, port: UInt16, scheme: Scheme): 15 | self.host = host 16 | self.port = port 17 | self.scheme = scheme 18 | 19 | fn __hash__(self) -> UInt: 20 | # TODO: Very rudimentary hash. We probably need to actually have an actual hash function here. 21 | # Since Tuple doesn't have one. 22 | return hash(hash(self.host) + hash(self.port) + hash(self.scheme)) 23 | 24 | fn __eq__(self, other: Self) -> Bool: 25 | return self.host == other.host and self.port == other.port and self.scheme == other.scheme 26 | 27 | fn __ne__(self, other: Self) -> Bool: 28 | return self.host != other.host or self.port != other.port or self.scheme != other.scheme 29 | 30 | fn __str__(self) -> String: 31 | var result = String() 32 | result.write(self.scheme.value, "://", self.host, ":", String(self.port)) 33 | return result 34 | 35 | fn __repr__(self) -> String: 36 | return String.write(self) 37 | 38 | fn write_to[W: Writer, //](self, mut writer: W) -> None: 39 | writer.write( 40 | "PoolKey(", 41 | "scheme=", 42 | repr(self.scheme.value), 43 | ", host=", 44 | repr(self.host), 45 | ", port=", 46 | String(self.port), 47 | ")", 48 | ) 49 | 50 | 51 | struct PoolManager[ConnectionType: Connection](): 52 | var _connections: OwningList[ConnectionType] 53 | var _capacity: Int 54 | var mapping: Dict[PoolKey, Int] 55 | 56 | fn __init__(out self, capacity: Int = 10): 57 | self._connections = OwningList[ConnectionType](capacity=capacity) 58 | self._capacity = capacity 59 | self.mapping = Dict[PoolKey, Int]() 60 | 61 | fn __del__(owned self): 62 | logger.debug( 63 | "PoolManager shutting down and closing remaining connections before destruction:", self._connections.size 64 | ) 65 | self.clear() 66 | 67 | fn give(mut self, key: PoolKey, owned value: ConnectionType) raises: 68 | if key in self.mapping: 69 | self._connections[self.mapping[key]] = value^ 70 | return 71 | 72 | if self._connections.size == self._capacity: 73 | raise Error("PoolManager.give: Cache is full.") 74 | 75 | self._connections.append(value^) 76 | self.mapping[key] = self._connections.size - 1 77 | logger.debug("Checked in connection for peer:", String(key) + ", at index:", self._connections.size) 78 | 79 | fn take(mut self, key: PoolKey) raises -> ConnectionType: 80 | var index: Int 81 | try: 82 | index = self.mapping[key] 83 | _ = self.mapping.pop(key) 84 | except: 85 | raise Error("PoolManager.take: Key not found.") 86 | 87 | var connection = self._connections.pop(index) 88 | # Shift everything over by one 89 | for kv in self.mapping.items(): 90 | if kv[].value > index: 91 | self.mapping[kv[].key] -= 1 92 | 93 | logger.debug("Checked out connection for peer:", String(key) + ", from index:", self._connections.size + 1) 94 | return connection^ 95 | 96 | fn clear(mut self): 97 | while self._connections: 98 | var connection = self._connections.pop(0) 99 | try: 100 | connection.teardown() 101 | except e: 102 | # TODO: This is used in __del__, would be nice if we didn't have to absorb the error. 103 | logger.error("Failed to tear down connection. Error:", e) 104 | self.mapping.clear() 105 | 106 | fn __contains__(self, key: PoolKey) -> Bool: 107 | return key in self.mapping 108 | 109 | fn __setitem__(mut self, key: PoolKey, owned value: ConnectionType) raises -> None: 110 | if key in self.mapping: 111 | self._connections[self.mapping[key]] = value^ 112 | else: 113 | self.give(key, value^) 114 | 115 | fn __getitem__(self, key: PoolKey) raises -> ref [self._connections] ConnectionType: 116 | return self._connections[self.mapping[key]] 117 | -------------------------------------------------------------------------------- /lightbug_http/server.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.io.sync import Duration 2 | from lightbug_http.io.bytes import Bytes, BytesConstant, ByteView, ByteReader, bytes 3 | from lightbug_http.address import NetworkType 4 | from lightbug_http._logger import logger 5 | from lightbug_http.connection import NoTLSListener, default_buffer_size, TCPConnection, ListenConfig 6 | from lightbug_http.socket import Socket 7 | from lightbug_http.http import HTTPRequest, encode 8 | from lightbug_http.http.common_response import InternalError, BadRequest 9 | from lightbug_http.uri import URI 10 | from lightbug_http.header import Headers 11 | from lightbug_http.service import HTTPService 12 | from lightbug_http.error import ErrorHandler 13 | 14 | 15 | alias DefaultConcurrency: Int = 256 * 1024 16 | alias default_max_request_body_size = 4 * 1024 * 1024 # 4MB 17 | 18 | 19 | struct Server(Movable): 20 | """A Mojo-based server that accept incoming requests and delivers HTTP services.""" 21 | 22 | var error_handler: ErrorHandler 23 | 24 | var name: String 25 | var _address: String 26 | var max_concurrent_connections: UInt 27 | var max_requests_per_connection: UInt 28 | 29 | var _max_request_body_size: UInt 30 | var tcp_keep_alive: Bool 31 | 32 | fn __init__( 33 | out self, 34 | error_handler: ErrorHandler = ErrorHandler(), 35 | name: String = "lightbug_http", 36 | address: String = "127.0.0.1", 37 | max_concurrent_connections: UInt = 1000, 38 | max_requests_per_connection: UInt = 0, 39 | max_request_body_size: UInt = default_max_request_body_size, 40 | tcp_keep_alive: Bool = False, 41 | ) raises: 42 | self.error_handler = error_handler 43 | self.name = name 44 | self._address = address 45 | self.max_requests_per_connection = max_requests_per_connection 46 | self._max_request_body_size = default_max_request_body_size 47 | self.tcp_keep_alive = tcp_keep_alive 48 | if max_concurrent_connections == 0: 49 | self.max_concurrent_connections = DefaultConcurrency 50 | else: 51 | self.max_concurrent_connections = max_concurrent_connections 52 | 53 | fn __moveinit__(out self, owned other: Server): 54 | self.error_handler = other.error_handler^ 55 | self.name = other.name^ 56 | self._address = other._address^ 57 | self.max_concurrent_connections = other.max_concurrent_connections 58 | self.max_requests_per_connection = other.max_requests_per_connection 59 | self._max_request_body_size = other._max_request_body_size 60 | self.tcp_keep_alive = other.tcp_keep_alive 61 | 62 | fn address(self) -> ref [self._address] String: 63 | return self._address 64 | 65 | fn set_address(mut self, own_address: String) -> None: 66 | self._address = own_address 67 | 68 | fn max_request_body_size(self) -> UInt: 69 | return self._max_request_body_size 70 | 71 | fn set_max_request_body_size(mut self, size: UInt) -> None: 72 | self._max_request_body_size = size 73 | 74 | fn get_concurrency(self) -> UInt: 75 | """Retrieve the concurrency level which is either 76 | the configured `max_concurrent_connections` or the `DefaultConcurrency`. 77 | 78 | Returns: 79 | Concurrency level for the server. 80 | """ 81 | return self.max_concurrent_connections 82 | 83 | fn listen_and_serve[T: HTTPService](mut self, address: String, mut handler: T) raises: 84 | """Listen for incoming connections and serve HTTP requests. 85 | 86 | Parameters: 87 | T: The type of HTTPService that handles incoming requests. 88 | 89 | Args: 90 | address: The address (host:port) to listen on. 91 | handler: An object that handles incoming HTTP requests. 92 | """ 93 | var config = ListenConfig() 94 | var listener = config.listen(address) 95 | self.set_address(address) 96 | self.serve(listener^, handler) 97 | 98 | fn serve[T: HTTPService](mut self, owned ln: NoTLSListener, mut handler: T) raises: 99 | """Serve HTTP requests. 100 | 101 | Parameters: 102 | T: The type of HTTPService that handles incoming requests. 103 | 104 | Args: 105 | ln: TCP server that listens for incoming connections. 106 | handler: An object that handles incoming HTTP requests. 107 | 108 | Raises: 109 | If there is an error while serving requests. 110 | """ 111 | while True: 112 | var conn = ln.accept() 113 | self.serve_connection(conn, handler) 114 | 115 | fn serve_connection[T: HTTPService](mut self, mut conn: TCPConnection, mut handler: T) raises -> None: 116 | """Serve a single connection. 117 | Parameters: 118 | T: The type of HTTPService that handles incoming requests. 119 | Args: 120 | conn: A connection object that represents a client connection. 121 | handler: An object that handles incoming HTTP requests. 122 | Raises: 123 | If there is an error while serving the connection. 124 | """ 125 | logger.debug( 126 | "Connection accepted! IP:", conn.socket._remote_address.ip, "Port:", conn.socket._remote_address.port 127 | ) 128 | var max_request_body_size = self.max_request_body_size() 129 | if max_request_body_size <= 0: 130 | max_request_body_size = default_max_request_body_size 131 | 132 | var req_number = 0 133 | while True: 134 | req_number += 1 135 | 136 | var request_buffer = Bytes() 137 | 138 | while True: 139 | try: 140 | var temp_buffer = Bytes(capacity=default_buffer_size) 141 | var bytes_read = conn.read(temp_buffer) 142 | logger.debug("Bytes read:", bytes_read) 143 | 144 | if bytes_read == 0: 145 | conn.teardown() 146 | return 147 | 148 | request_buffer.extend(temp_buffer^) 149 | logger.debug("Total buffer size:", len(request_buffer)) 150 | 151 | if BytesConstant.DOUBLE_CRLF in ByteView(request_buffer): 152 | logger.debug("Found end of headers") 153 | break 154 | 155 | except e: 156 | conn.teardown() 157 | # 0 bytes were read from the peer, which indicates their side of the connection was closed. 158 | if String(e) == "EOF": 159 | return 160 | else: 161 | logger.error("Server.serve_connection: Failed to read request. Expected EOF, got:", String(e)) 162 | return 163 | 164 | var request: HTTPRequest 165 | try: 166 | request = HTTPRequest.from_bytes(self.address(), max_request_body_size, request_buffer) 167 | var response: HTTPResponse 168 | var close_connection = (not self.tcp_keep_alive) or request.connection_close() 169 | try: 170 | response = handler.func(request) 171 | if close_connection: 172 | response.set_connection_close() 173 | logger.debug( 174 | conn.socket._remote_address.ip, 175 | String(conn.socket._remote_address.port), 176 | request.method, 177 | request.uri.path, 178 | response.status_code, 179 | ) 180 | try: 181 | _ = conn.write(encode(response^)) 182 | except e: 183 | logger.error("Failed to write encoded response to the connection:", String(e)) 184 | conn.teardown() 185 | break 186 | 187 | if close_connection: 188 | conn.teardown() 189 | break 190 | except e: 191 | logger.error("Handler error:", String(e)) 192 | if not conn.is_closed(): 193 | try: 194 | _ = conn.write(encode(InternalError())) 195 | except e: 196 | raise Error("Failed to send InternalError response") 197 | finally: 198 | conn.teardown() 199 | return 200 | except e: 201 | logger.error("Failed to parse HTTPRequest:", String(e)) 202 | try: 203 | _ = conn.write(encode(BadRequest())) 204 | except e: 205 | logger.error("Failed to write BadRequest response to the connection:", String(e)) 206 | conn.teardown() 207 | break 208 | -------------------------------------------------------------------------------- /lightbug_http/service.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.http import HTTPRequest, HTTPResponse, OK, NotFound 2 | from lightbug_http.io.bytes import Bytes, bytes 3 | from lightbug_http.strings import to_string 4 | from lightbug_http.header import HeaderKey 5 | 6 | 7 | trait HTTPService: 8 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 9 | ... 10 | 11 | 12 | @value 13 | struct Printer(HTTPService): 14 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 15 | print("Request URI:", req.uri.request_uri) 16 | print("Request protocol:", req.protocol) 17 | print("Request method:", req.method) 18 | if HeaderKey.CONTENT_TYPE in req.headers: 19 | print("Request Content-Type:", req.headers[HeaderKey.CONTENT_TYPE]) 20 | if req.body_raw: 21 | print("Request Body:", to_string(req.body_raw)) 22 | 23 | return OK(req.body_raw) 24 | 25 | 26 | @value 27 | struct Welcome(HTTPService): 28 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 29 | if req.uri.path == "/": 30 | with open("static/lightbug_welcome.html", "r") as f: 31 | return OK(Bytes(f.read_bytes()), "text/html; charset=utf-8") 32 | 33 | if req.uri.path == "/logo.png": 34 | with open("static/logo.png", "r") as f: 35 | return OK(Bytes(f.read_bytes()), "image/png") 36 | 37 | return NotFound(req.uri.path) 38 | 39 | 40 | @value 41 | struct ExampleRouter(HTTPService): 42 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 43 | if req.uri.path == "/": 44 | print("I'm on the index path!") 45 | if req.uri.path == "/first": 46 | print("I'm on /first!") 47 | elif req.uri.path == "/second": 48 | print("I'm on /second!") 49 | elif req.uri.path == "/echo": 50 | print(to_string(req.body_raw)) 51 | 52 | return OK(req.body_raw) 53 | 54 | 55 | @value 56 | struct TechEmpowerRouter(HTTPService): 57 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 58 | if req.uri.path == "/plaintext": 59 | return OK("Hello, World!", "text/plain") 60 | elif req.uri.path == "/json": 61 | return OK('{"message": "Hello, World!"}', "application/json") 62 | 63 | return OK("Hello world!") # text/plain is the default 64 | 65 | 66 | @value 67 | struct Counter(HTTPService): 68 | var counter: Int 69 | 70 | fn __init__(out self): 71 | self.counter = 0 72 | 73 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 74 | self.counter += 1 75 | return OK("I have been called: " + String(self.counter) + " times") 76 | -------------------------------------------------------------------------------- /lightbug_http/strings.mojo: -------------------------------------------------------------------------------- 1 | from memory import Span 2 | from lightbug_http.io.bytes import Bytes, bytes, byte 3 | 4 | alias strSlash = "/" 5 | alias strHttp = "http" 6 | alias http = "http" 7 | alias strHttps = "https" 8 | alias https = "https" 9 | alias strHttp11 = "HTTP/1.1" 10 | alias strHttp10 = "HTTP/1.0" 11 | 12 | alias strMethodGet = "GET" 13 | 14 | alias rChar = "\r" 15 | alias nChar = "\n" 16 | alias lineBreak = rChar + nChar 17 | alias colonChar = ":" 18 | 19 | alias empty_string = "" 20 | alias whitespace = " " 21 | alias whitespace_byte = ord(whitespace) 22 | alias tab = "\t" 23 | alias tab_byte = ord(tab) 24 | 25 | 26 | struct BytesConstant: 27 | alias whitespace = byte(whitespace) 28 | alias colon = byte(colonChar) 29 | alias rChar = byte(rChar) 30 | alias nChar = byte(nChar) 31 | 32 | alias CRLF = bytes(lineBreak) 33 | alias DOUBLE_CRLF = bytes(lineBreak + lineBreak) 34 | 35 | 36 | fn to_string[T: Writable](value: T) -> String: 37 | return String.write(value) 38 | 39 | 40 | fn to_string(b: Span[UInt8]) -> String: 41 | """Creates a String from a copy of the provided Span of bytes. 42 | 43 | Args: 44 | b: The Span of bytes to convert to a String. 45 | """ 46 | return String(StringSlice(unsafe_from_utf8=b)) 47 | 48 | 49 | fn to_string(owned bytes: Bytes) -> String: 50 | """Creates a String from the provided List of bytes. 51 | If you do not transfer ownership of the List, the List will be copied. 52 | 53 | Args: 54 | bytes: The List of bytes to convert to a String. 55 | """ 56 | var result = String() 57 | result.write_bytes(bytes) 58 | return result^ 59 | 60 | 61 | fn find_all(s: String, sub_str: String) -> List[Int]: 62 | match_idxs = List[Int]() 63 | var current_idx: Int = s.find(sub_str) 64 | while current_idx > -1: 65 | match_idxs.append(current_idx) 66 | current_idx = s.find(sub_str, start=current_idx + 1) 67 | return match_idxs^ 68 | -------------------------------------------------------------------------------- /lightbug_http/uri.mojo: -------------------------------------------------------------------------------- 1 | from collections import Optional, Dict 2 | from lightbug_http.io.bytes import Bytes, bytes, ByteReader 3 | from lightbug_http.strings import ( 4 | find_all, 5 | strSlash, 6 | strHttp11, 7 | strHttp10, 8 | strHttp, 9 | http, 10 | strHttps, 11 | https, 12 | ) 13 | 14 | 15 | fn unquote[expand_plus: Bool = False](input_str: String, disallowed_escapes: List[String] = List[String]()) -> String: 16 | var encoded_str = input_str.replace(QueryDelimiters.PLUS_ESCAPED_SPACE, " ") if expand_plus else input_str 17 | 18 | var percent_idxs: List[Int] = find_all(encoded_str, URIDelimiters.CHAR_ESCAPE) 19 | 20 | if len(percent_idxs) < 1: 21 | return encoded_str 22 | 23 | var sub_strings = List[String]() 24 | var current_idx = 0 25 | var slice_start = 0 26 | 27 | var str_bytes = List[UInt8]() 28 | while current_idx < len(percent_idxs): 29 | var slice_end = percent_idxs[current_idx] 30 | sub_strings.append(encoded_str[slice_start:slice_end]) 31 | 32 | var current_offset = slice_end 33 | while current_idx < len(percent_idxs): 34 | if (current_offset + 3) > len(encoded_str): 35 | # If the percent escape is not followed by two hex digits, we stop processing. 36 | break 37 | 38 | try: 39 | char_byte = atol( 40 | encoded_str[current_offset + 1 : current_offset + 3], 41 | base=16, 42 | ) 43 | str_bytes.append(char_byte) 44 | except: 45 | break 46 | 47 | if percent_idxs[current_idx + 1] != (current_offset + 3): 48 | current_offset += 3 49 | break 50 | 51 | current_idx += 1 52 | current_offset = percent_idxs[current_idx] 53 | 54 | if len(str_bytes) > 0: 55 | var sub_str_from_bytes = String() 56 | sub_str_from_bytes.write_bytes(str_bytes) 57 | for disallowed in disallowed_escapes: 58 | sub_str_from_bytes = sub_str_from_bytes.replace(disallowed[], "") 59 | sub_strings.append(sub_str_from_bytes) 60 | str_bytes.clear() 61 | 62 | slice_start = current_offset 63 | current_idx += 1 64 | 65 | sub_strings.append(encoded_str[slice_start:]) 66 | 67 | return StaticString("").join(sub_strings) 68 | 69 | 70 | alias QueryMap = Dict[String, String] 71 | 72 | 73 | struct QueryDelimiters: 74 | alias STRING_START = "?" 75 | alias ITEM = "&" 76 | alias ITEM_ASSIGN = "=" 77 | alias PLUS_ESCAPED_SPACE = "+" 78 | 79 | 80 | struct URIDelimiters: 81 | alias SCHEMA = "://" 82 | alias PATH = strSlash 83 | alias ROOT_PATH = strSlash 84 | alias CHAR_ESCAPE = "%" 85 | alias AUTHORITY = "@" 86 | alias QUERY = "?" 87 | alias SCHEME = ":" 88 | 89 | 90 | struct PortBounds: 91 | # For port parsing 92 | alias NINE: UInt8 = ord("9") 93 | alias ZERO: UInt8 = ord("0") 94 | 95 | 96 | @value 97 | struct Scheme(Hashable, EqualityComparable, Representable, Stringable, Writable): 98 | var value: String 99 | alias HTTP = Self("http") 100 | alias HTTPS = Self("https") 101 | 102 | fn __hash__(self) -> UInt: 103 | return hash(self.value) 104 | 105 | fn __eq__(self, other: Self) -> Bool: 106 | return self.value == other.value 107 | 108 | fn __ne__(self, other: Self) -> Bool: 109 | return self.value != other.value 110 | 111 | fn write_to[W: Writer, //](self, mut writer: W) -> None: 112 | writer.write("Scheme(value=", repr(self.value), ")") 113 | 114 | fn __repr__(self) -> String: 115 | return String.write(self) 116 | 117 | fn __str__(self) -> String: 118 | return self.value.upper() 119 | 120 | 121 | @value 122 | struct URI(Writable, Stringable, Representable): 123 | var _original_path: String 124 | var scheme: String 125 | var path: String 126 | var query_string: String 127 | var queries: QueryMap 128 | var _hash: String 129 | var host: String 130 | var port: Optional[UInt16] 131 | 132 | var full_uri: String 133 | var request_uri: String 134 | 135 | var username: String 136 | var password: String 137 | 138 | @staticmethod 139 | fn parse(owned uri: String) raises -> URI: 140 | """Parses a URI which is defined using the following format. 141 | 142 | `[scheme:][//[user_info@]host][/]path[?query][#fragment]` 143 | """ 144 | var reader = ByteReader(uri.as_bytes()) 145 | 146 | # Parse the scheme, if exists. 147 | # Assume http if no scheme is provided, fairly safe given the context of lightbug. 148 | var scheme: String = "http" 149 | if "://" in uri: 150 | scheme = String(reader.read_until(ord(URIDelimiters.SCHEME))) 151 | if reader.read_bytes(3) != "://".as_bytes(): 152 | raise Error("URI.parse: Invalid URI format, scheme should be followed by `://`. Received: " + uri) 153 | 154 | # Parse the user info, if exists. 155 | # TODO (@thatstoasty): Store the user information (username and password) if it exists. 156 | if ord(URIDelimiters.AUTHORITY) in reader: 157 | _ = reader.read_until(ord(URIDelimiters.AUTHORITY)) 158 | reader.increment(1) 159 | 160 | # TODOs (@thatstoasty) 161 | # Handle ipv4 and ipv6 literal 162 | # Handle string host 163 | # A query right after the domain is a valid uri, but it's equivalent to example.com/?query 164 | # so we should add the normalization of paths 165 | var host_and_port = reader.read_until(ord(URIDelimiters.PATH)) 166 | colon = host_and_port.find(ord(URIDelimiters.SCHEME)) 167 | var host: String 168 | var port: Optional[UInt16] = None 169 | if colon != -1: 170 | host = String(host_and_port[:colon]) 171 | var port_end = colon + 1 172 | # loop through the post colon chunk until we find a non-digit character 173 | for b in host_and_port[colon + 1 :]: 174 | if b[] < PortBounds.ZERO or b[] > PortBounds.NINE: 175 | break 176 | port_end += 1 177 | port = UInt16(atol(String(host_and_port[colon + 1 : port_end]))) 178 | else: 179 | host = String(host_and_port) 180 | 181 | # Reads until either the start of the query string, or the end of the uri. 182 | var unquote_reader = reader.copy() 183 | var original_path_bytes = unquote_reader.read_until(ord(URIDelimiters.QUERY)) 184 | var original_path: String 185 | if not original_path_bytes: 186 | original_path = "/" 187 | else: 188 | original_path = unquote(String(original_path_bytes), disallowed_escapes=List(String("/"))) 189 | 190 | # Parse the path 191 | var path: String = "/" 192 | var request_uri: String = "/" 193 | if reader.available() and reader.peek() == ord(URIDelimiters.PATH): 194 | # Copy the remaining bytes to read the request uri. 195 | var request_uri_reader = reader.copy() 196 | request_uri = String(request_uri_reader.read_bytes()) 197 | # Read until the query string, or the end if there is none. 198 | path = unquote(String(reader.read_until(ord(URIDelimiters.QUERY))), disallowed_escapes=List(String("/"))) 199 | 200 | # Parse query 201 | var query: String = "" 202 | if reader.available() and reader.peek() == ord(URIDelimiters.QUERY): 203 | # TODO: Handle fragments for anchors 204 | query = String(reader.read_bytes()[1:]) 205 | 206 | var queries = QueryMap() 207 | if query: 208 | var query_items = query.split(QueryDelimiters.ITEM) 209 | 210 | for item in query_items: 211 | var key_val = item[].split(QueryDelimiters.ITEM_ASSIGN, 1) 212 | var key = unquote[expand_plus=True](key_val[0]) 213 | 214 | if key: 215 | queries[key] = "" 216 | if len(key_val) == 2: 217 | queries[key] = unquote[expand_plus=True](key_val[1]) 218 | 219 | return URI( 220 | _original_path=original_path, 221 | scheme=scheme, 222 | path=path, 223 | query_string=query, 224 | queries=queries, 225 | _hash="", 226 | host=host, 227 | port=port, 228 | full_uri=uri, 229 | request_uri=request_uri, 230 | username="", 231 | password="", 232 | ) 233 | 234 | fn __str__(self) -> String: 235 | var result = String.write(self.scheme, URIDelimiters.SCHEMA, self.host, self.path) 236 | if len(self.query_string) > 0: 237 | result.write(QueryDelimiters.STRING_START, self.query_string) 238 | return result^ 239 | 240 | fn __repr__(self) -> String: 241 | return String.write(self) 242 | 243 | fn write_to[T: Writer](self, mut writer: T): 244 | writer.write( 245 | "URI(", 246 | "scheme=", 247 | repr(self.scheme), 248 | ", host=", 249 | repr(self.host), 250 | ", path=", 251 | repr(self.path), 252 | ", _original_path=", 253 | repr(self._original_path), 254 | ", query_string=", 255 | repr(self.query_string), 256 | ", full_uri=", 257 | repr(self.full_uri), 258 | ", request_uri=", 259 | repr(self.request_uri), 260 | ")", 261 | ) 262 | 263 | fn is_https(self) -> Bool: 264 | return self.scheme == https 265 | 266 | fn is_http(self) -> Bool: 267 | return self.scheme == http or len(self.scheme) == 0 268 | -------------------------------------------------------------------------------- /mojoproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = ["saviorand"] 3 | channels = ["conda-forge", "https://conda.modular.com/max", "https://repo.prefix.dev/modular-community"] 4 | description = "Simple and fast HTTP framework for Mojo!" 5 | name = "lightbug_http" 6 | platforms = ["osx-arm64", "linux-64", "linux-aarch64"] 7 | version = "25.3.0" 8 | 9 | [tasks] 10 | build = { cmd = "rattler-build build --recipe recipes -c https://conda.modular.com/max -c conda-forge --skip-existing=all", env = {MODULAR_MOJO_IMPORT_PATH = "$CONDA_PREFIX/lib/mojo"} } 11 | publish = { cmd = "bash scripts/publish.sh", env = { PREFIX_API_KEY = "$PREFIX_API_KEY" } } 12 | format = { cmd = "magic run mojo format -l 120 lightbug_http" } 13 | 14 | [feature.unit-tests.tasks] 15 | test = { cmd = "magic run mojo test -I . tests/lightbug_http" } 16 | 17 | [feature.integration-tests.tasks] 18 | integration_tests_py = { cmd = "bash scripts/integration_test.sh" } 19 | integration_tests_external = { cmd = "magic run mojo test -I . tests/integration" } 20 | integration_tests_udp = { cmd = "bash scripts/udp_test.sh" } 21 | 22 | [feature.bench.tasks] 23 | bench = { cmd = "magic run mojo -I . benchmark/bench.mojo" } 24 | bench_server = { cmd = "bash scripts/bench_server.sh" } 25 | 26 | [dependencies] 27 | max = ">=25.3.0,<25.4.0" 28 | 29 | [feature.integration-tests.dependencies] 30 | requests = ">=2.32.3,<3" 31 | fastapi = ">=0.114.2,<0.115" 32 | 33 | [environments] 34 | default = { solve-group = "default" } 35 | unit-tests = { features = ["unit-tests"], solve-group = "default" } 36 | integration-tests = { features = ["integration-tests"], solve-group = "default" } 37 | bench = { features = ["bench"], solve-group = "default" } 38 | -------------------------------------------------------------------------------- /recipes/recipe.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/prefix-dev/recipe-format/main/schema.json 2 | 3 | context: 4 | version: "25.3.0" 5 | 6 | package: 7 | name: "lightbug_http" 8 | version: 25.3.0 9 | 10 | source: 11 | - path: ../lightbug_http 12 | - path: ../LICENSE 13 | 14 | build: 15 | script: 16 | - mkdir -p ${PREFIX}/lib/mojo 17 | - magic run mojo package . -o ${PREFIX}/lib/mojo/lightbug_http.mojopkg 18 | 19 | requirements: 20 | run: 21 | - max >=25.3.0,<25.4.0 22 | 23 | about: 24 | homepage: https://github.com/saviorand/lightbug_http 25 | license: MIT 26 | license_file: LICENSE 27 | summary: Lightbug is a simple and sweet HTTP framework for Mojo 28 | repository: https://github.com/saviorand/lightbug_http 29 | -------------------------------------------------------------------------------- /scripts/bench_server.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | magic run mojo build -I . benchmark/bench_server.mojo || exit 1 4 | 5 | echo "running server..." 6 | ./bench_server& 7 | 8 | 9 | sleep 2 10 | 11 | echo "Running benchmark" 12 | wrk -t1 -c1 -d10s http://localhost:8080/ --header "User-Agent: wrk" 13 | 14 | kill $! 15 | wait $! 2>/dev/null 16 | 17 | rm bench_server -------------------------------------------------------------------------------- /scripts/integration_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "[INFO] Building mojo binaries.." 3 | 4 | kill_server() { 5 | pid=$(ps aux | grep "$1" | grep -v grep | awk '{print $2}' | head -n 1) 6 | kill $pid 7 | wait $pid 2>/dev/null 8 | } 9 | 10 | test_server() { 11 | (magic run mojo build -D LB_LOG_LEVEL=DEBUG -I . --debug-level full tests/integration/integration_test_server.mojo) || exit 1 12 | 13 | echo "[INFO] Starting Mojo server..." 14 | ./integration_test_server & 15 | 16 | sleep 5 17 | 18 | echo "[INFO] Testing server with Python client" 19 | magic run python3 tests/integration/integration_client.py 20 | 21 | rm ./integration_test_server 22 | kill_server "integration_test_server" || echo "Failed to kill Mojo server" 23 | } 24 | 25 | test_client() { 26 | echo "[INFO] Testing Mojo client with Python server" 27 | (magic run mojo build -D LB_LOG_LEVEL=DEBUG -I . --debug-level full tests/integration/integration_test_client.mojo) || exit 1 28 | 29 | echo "[INFO] Starting Python server..." 30 | magic run fastapi run tests/integration/integration_server.py & 31 | sleep 5 32 | 33 | ./integration_test_client 34 | rm ./integration_test_client 35 | kill_server "fastapi run" || echo "Failed to kill fastapi server" 36 | } 37 | 38 | test_server 39 | test_client 40 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ignore errors because we want to ignore duplicate packages 4 | for file in $CONDA_BLD_PATH/**/*.conda; do 5 | magic run rattler-build upload prefix -c "mojo-community" "$file" || true 6 | done 7 | 8 | rm $CONDA_BLD_PATH/**/*.conda -------------------------------------------------------------------------------- /scripts/udp_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "[INFO] Building mojo binaries.." 3 | 4 | kill_server() { 5 | pid=$(ps aux | grep "$1" | grep -v grep | awk '{print $2}' | head -n 1) 6 | kill $pid 7 | wait $pid 2>/dev/null 8 | } 9 | 10 | (magic run mojo build -D LB_LOG_LEVEL=DEBUG -I . --debug-level full tests/integration/udp/udp_server.mojo) 11 | (magic run mojo build -D LB_LOG_LEVEL=DEBUG -I . --debug-level full tests/integration/udp/udp_client.mojo) 12 | 13 | echo "[INFO] Starting UDP server..." 14 | ./udp_server & 15 | sleep 5 16 | 17 | echo "[INFO] Testing server with UDP client" 18 | ./udp_client 19 | 20 | rm ./udp_server 21 | rm ./udp_client 22 | kill_server "udp_server" || echo "Failed to kill udp server" 23 | -------------------------------------------------------------------------------- /static/lightbug_welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Welcome to Lightbug! 6 | 54 | 55 | 56 | 57 |
Welcome to Lightbug!
58 |
A Mojo HTTP framework with wings
59 |
To get started, edit lightbug.🔥
60 | Lightbug Image 61 | 62 | 63 | -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/static/logo.png -------------------------------------------------------------------------------- /static/roadmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/static/roadmap.png -------------------------------------------------------------------------------- /static/test.txt: -------------------------------------------------------------------------------- 1 | Hello, Mojo!Hello, Mojo!Hello, Mojo!Hello, Mojo!Hello, Mojo! 日本人 中國的 ~=[]()%+{}@;’#!$_&- éè ;∞¥₤€ -------------------------------------------------------------------------------- /tests/integration/integration_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import socket 3 | import time 4 | 5 | session = requests.Session() 6 | 7 | print("\n~~~ Testing redirect ~~~") 8 | response = session.get('http://127.0.0.1:8080/redirect', allow_redirects=True) 9 | assert response.status_code == 200 10 | assert response.text == "yay you made it" 11 | 12 | print("\n~~~ Testing close connection ~~~") 13 | response = session.get('http://127.0.0.1:8080/close-connection', headers={'connection': 'close'}) 14 | assert response.status_code == 200 15 | assert response.text == "connection closed" 16 | 17 | print("\n~~~ Testing internal server error ~~~") 18 | response = session.get('http://127.0.0.1:8080/error', headers={'connection': 'keep-alive'}) 19 | assert response.status_code == 500 20 | 21 | print("\n~~~ Testing large headers ~~~") 22 | large_headers = { 23 | f'X-Custom-Header-{i}': 'value' * 100 # long value 24 | for i in range(8) # minimum number to exceed default buffer size (4096) 25 | } 26 | response = session.get('http://127.0.0.1:8080/large-headers', headers=large_headers) 27 | assert response.status_code == 200 28 | 29 | print("\n~~~ Testing content-length mismatch (smaller) ~~~") 30 | def test_content_length_smaller(): 31 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 32 | s.connect(('127.0.0.1', 8080)) 33 | s.sendall(b'POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nOnly sending 20 bytes') 34 | time.sleep(1) 35 | s.close() 36 | 37 | test_content_length_smaller() 38 | time.sleep(1) 39 | 40 | print("\n~~~ All tests completed ~~~") 41 | -------------------------------------------------------------------------------- /tests/integration/integration_server.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi import FastAPI, Response 4 | from fastapi.responses import RedirectResponse, PlainTextResponse 5 | 6 | app = FastAPI() 7 | 8 | 9 | @app.get("/redirect") 10 | async def redirect(response: Response): 11 | return RedirectResponse( 12 | url="/rd-destination", status_code=308, headers={"Location": "/rd-destination"} 13 | ) 14 | 15 | 16 | @app.get("/rd-destination") 17 | async def rd_destination(response: Response): 18 | response.headers["Content-Type"] = "text/plain" 19 | return PlainTextResponse("yay you made it") 20 | 21 | 22 | @app.get("/close-connection") 23 | async def close_connection(response: Response): 24 | response.headers["Content-Type"] = "text/plain" 25 | response.headers["Connection"] = "close" 26 | return PlainTextResponse("connection closed") 27 | 28 | 29 | @app.get("/error", status_code=500) 30 | async def error(response: Response): 31 | return PlainTextResponse("Internal Server Error", status_code=500) 32 | -------------------------------------------------------------------------------- /tests/integration/integration_test_client.mojo: -------------------------------------------------------------------------------- 1 | from collections import Dict 2 | from lightbug_http import * 3 | from lightbug_http.client import Client 4 | from lightbug_http._logger import logger 5 | from testing import * 6 | 7 | 8 | fn u(s: String) raises -> URI: 9 | return URI.parse("http://127.0.0.1:8000/" + s) 10 | 11 | 12 | struct IntegrationTest: 13 | var client: Client 14 | var results: Dict[String, String] 15 | 16 | fn __init__(out self): 17 | self.client = Client(allow_redirects=True) 18 | self.results = Dict[String, String]() 19 | 20 | fn mark_successful(mut self, name: String): 21 | self.results[name] = "✅" 22 | 23 | fn mark_failed(mut self, name: String): 24 | self.results[name] = "❌" 25 | 26 | fn test_redirect(mut self): 27 | alias name = "test_redirect" 28 | print("\n~~~ Testing redirect ~~~") 29 | var h = Headers(Header(HeaderKey.CONNECTION, "keep-alive")) 30 | try: 31 | var res = self.client.do(HTTPRequest(u("redirect"), headers=h)) 32 | assert_equal(res.status_code, StatusCode.OK) 33 | assert_equal(to_string(res.body_raw), "yay you made it") 34 | var conn = res.headers.get(HeaderKey.CONNECTION) 35 | if conn: 36 | assert_equal(conn.value(), "keep-alive") 37 | self.mark_successful(name) 38 | except e: 39 | logger.error("IntegrationTest.test_redirect has run into an error.") 40 | logger.error(e) 41 | self.mark_failed(name) 42 | return 43 | 44 | fn test_close_connection(mut self): 45 | alias name = "test_close_connection" 46 | print("\n~~~ Testing close connection ~~~") 47 | var h = Headers(Header(HeaderKey.CONNECTION, "close")) 48 | try: 49 | var res = self.client.do(HTTPRequest(u("close-connection"), headers=h)) 50 | assert_equal(res.status_code, StatusCode.OK) 51 | assert_equal(to_string(res.body_raw), "connection closed") 52 | assert_equal(res.headers[HeaderKey.CONNECTION], "close") 53 | self.mark_successful(name) 54 | except e: 55 | logger.error("IntegrationTest.test_close_connection has run into an error.") 56 | logger.error(e) 57 | self.mark_failed(name) 58 | return 59 | 60 | fn test_server_error(mut self): 61 | alias name = "test_server_error" 62 | print("\n~~~ Testing internal server error ~~~") 63 | try: 64 | var res = self.client.do(HTTPRequest(u("error"))) 65 | assert_equal(res.status_code, StatusCode.INTERNAL_ERROR) 66 | assert_equal(res.status_text, "Internal Server Error") 67 | self.mark_successful(name) 68 | except e: 69 | logger.error("IntegrationTest.test_server_error has run into an error.") 70 | logger.error(e) 71 | self.mark_failed(name) 72 | return 73 | 74 | fn run_tests(mut self) -> Dict[String, String]: 75 | logger.info("Running Client Integration Tests...") 76 | self.test_redirect() 77 | self.test_close_connection() 78 | self.test_server_error() 79 | 80 | return self.results 81 | 82 | 83 | fn main(): 84 | var test = IntegrationTest() 85 | var results = test.run_tests() 86 | for test in results.items(): 87 | print(test[].key + ":", test[].value) 88 | -------------------------------------------------------------------------------- /tests/integration/integration_test_server.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http import * 2 | 3 | 4 | @value 5 | struct IntegrationTestService(HTTPService): 6 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 7 | var p = req.uri.path 8 | if p == "/": 9 | return OK("hello") 10 | elif p == "/redirect": 11 | return HTTPResponse( 12 | "get off my lawn".as_bytes(), 13 | headers=Headers(Header(HeaderKey.LOCATION, "/rd-destination")), 14 | status_code=StatusCode.PERMANENT_REDIRECT, 15 | ) 16 | elif p == "/rd-destination": 17 | return OK("yay you made it") 18 | elif p == "/close-connection": 19 | return OK("connection closed") 20 | elif p == "/large-headers": 21 | return OK("alright") 22 | elif p == "/error": 23 | raise Error("oops") 24 | 25 | return NotFound("wrong") 26 | 27 | 28 | fn main() raises: 29 | var server = Server(tcp_keep_alive=True) 30 | var service = IntegrationTestService() 31 | server.listen_and_serve("127.0.0.1:8080", service) 32 | -------------------------------------------------------------------------------- /tests/integration/test_client.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.client import Client 3 | from lightbug_http.http import HTTPRequest, encode 4 | from lightbug_http.uri import URI 5 | from lightbug_http.header import Header, Headers 6 | from lightbug_http.io.bytes import bytes 7 | 8 | 9 | fn test_mojo_client_redirect_external_req_google() raises: 10 | var client = Client() 11 | var req = HTTPRequest( 12 | uri=URI.parse("http://google.com"), 13 | headers=Headers( 14 | Header("Connection", "close")), 15 | method="GET", 16 | ) 17 | try: 18 | var res = client.do(req) 19 | testing.assert_equal(res.status_code, 200) 20 | except e: 21 | print(e) 22 | 23 | fn test_mojo_client_redirect_external_req_302() raises: 24 | var client = Client() 25 | var req = HTTPRequest( 26 | uri=URI.parse("http://httpbin.org/status/302"), 27 | headers=Headers( 28 | Header("Connection", "close")), 29 | method="GET", 30 | ) 31 | try: 32 | var res = client.do(req) 33 | testing.assert_equal(res.status_code, 200) 34 | except e: 35 | print(e) 36 | 37 | fn test_mojo_client_redirect_external_req_308() raises: 38 | var client = Client() 39 | var req = HTTPRequest( 40 | uri=URI.parse("http://httpbin.org/status/308"), 41 | headers=Headers( 42 | Header("Connection", "close")), 43 | method="GET", 44 | ) 45 | try: 46 | var res = client.do(req) 47 | testing.assert_equal(res.status_code, 200) 48 | except e: 49 | print(e) 50 | 51 | fn test_mojo_client_redirect_external_req_307() raises: 52 | var client = Client() 53 | var req = HTTPRequest( 54 | uri=URI.parse("http://httpbin.org/status/307"), 55 | headers=Headers( 56 | Header("Connection", "close")), 57 | method="GET", 58 | ) 59 | try: 60 | var res = client.do(req) 61 | testing.assert_equal(res.status_code, 200) 62 | except e: 63 | print(e) 64 | 65 | fn test_mojo_client_redirect_external_req_301() raises: 66 | var client = Client() 67 | var req = HTTPRequest( 68 | uri=URI.parse("http://httpbin.org/status/301"), 69 | headers=Headers( 70 | Header("Connection", "close")), 71 | method="GET", 72 | ) 73 | try: 74 | var res = client.do(req) 75 | testing.assert_equal(res.status_code, 200) 76 | testing.assert_equal(res.headers.content_length(), 228) 77 | except e: 78 | print(e) 79 | 80 | fn test_mojo_client_lightbug_external_req_200() raises: 81 | try: 82 | var client = Client() 83 | var req = HTTPRequest( 84 | uri=URI.parse("http://httpbin.org/status/200"), 85 | headers=Headers( 86 | Header("Connection", "close")), 87 | method="GET", 88 | ) 89 | var res = client.do(req) 90 | testing.assert_equal(res.status_code, 200) 91 | except e: 92 | print(e) 93 | raise 94 | -------------------------------------------------------------------------------- /tests/integration/test_pool_manager.mojo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/tests/integration/test_pool_manager.mojo -------------------------------------------------------------------------------- /tests/integration/test_server.mojo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/tests/integration/test_server.mojo -------------------------------------------------------------------------------- /tests/integration/test_socket.mojo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/tests/integration/test_socket.mojo -------------------------------------------------------------------------------- /tests/integration/udp/udp_client.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.connection import dial_udp 2 | from lightbug_http.address import UDPAddr 3 | 4 | alias test_string = "Hello, lightbug!" 5 | 6 | 7 | fn main() raises: 8 | print("Dialing UDP server...") 9 | alias host = "127.0.0.1" 10 | alias port = 12000 11 | var udp = dial_udp(host, port) 12 | 13 | print("Sending " + String(len(test_string)) + " messages to the server...") 14 | for i in range(len(test_string)): 15 | _ = udp.write_to(String(test_string[i]).as_bytes(), host, port) 16 | 17 | try: 18 | response, _, _ = udp.read_from(16) 19 | print("Response received:", StringSlice(unsafe_from_utf8=response)) 20 | except e: 21 | if String(e) != String("EOF"): 22 | raise e 23 | -------------------------------------------------------------------------------- /tests/integration/udp/udp_server.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.connection import listen_udp 2 | from lightbug_http.address import UDPAddr 3 | 4 | 5 | fn main() raises: 6 | var listener = listen_udp("127.0.0.1", 12000) 7 | 8 | while True: 9 | response, host, port = listener.read_from(16) 10 | var message = StringSlice(unsafe_from_utf8=response) 11 | print("Message received:", message) 12 | 13 | # Response with the same message in uppercase 14 | _ = listener.write_to(String.upper(String(message)).as_bytes(), host, port) 15 | -------------------------------------------------------------------------------- /tests/lightbug_http/cookie/test_cookie.mojo: -------------------------------------------------------------------------------- 1 | from lightbug_http.cookie import SameSite, Cookie, Duration, Expiration 2 | from lightbug_http.external.small_time.small_time import SmallTime, now 3 | from testing import assert_true, assert_equal 4 | from collections import Optional 5 | 6 | 7 | fn test_set_cookie() raises: 8 | cookie = Cookie( 9 | name="mycookie", 10 | value="myvalue", 11 | max_age=Duration(minutes=20), 12 | expires=Expiration.from_datetime(SmallTime(2037, 1, 22, 12, 0, 10, 0)), 13 | path=String("/"), 14 | domain=String("localhost"), 15 | secure=True, 16 | http_only=True, 17 | same_site=SameSite.none, 18 | partitioned=False, 19 | ) 20 | var header = cookie.to_header() 21 | var header_value = header.value 22 | var expected = "mycookie=myvalue; Expires=Thu, 22 Jan 2037 12:00:10 GMT; Max-Age=1200; Domain=localhost; Path=/; Secure; HttpOnly; SameSite=none" 23 | assert_equal("set-cookie", header.key) 24 | assert_equal(header_value, expected) 25 | 26 | 27 | fn test_set_cookie_partial_arguments() raises: 28 | cookie = Cookie(name="mycookie", value="myvalue", same_site=SameSite.lax) 29 | var header = cookie.to_header() 30 | var header_value = header.value 31 | var expected = "mycookie=myvalue; SameSite=lax" 32 | assert_equal("set-cookie", header.key) 33 | assert_equal(header_value, expected) 34 | 35 | 36 | fn test_expires_http_timestamp_format() raises: 37 | var expected = "Thu, 22 Jan 2037 12:00:10 GMT" 38 | var http_date = Expiration.from_datetime(SmallTime(2037, 1, 22, 12, 0, 10, 0)).http_date_timestamp() 39 | assert_true(http_date is not None, msg="Http date is None") 40 | assert_equal(expected, http_date.value()) 41 | -------------------------------------------------------------------------------- /tests/lightbug_http/cookie/test_cookie_jar.mojo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lightbug-HQ/lightbug_http/b1d889f920c302ffca3bc2a43ba77108fbff3e61/tests/lightbug_http/cookie/test_cookie_jar.mojo -------------------------------------------------------------------------------- /tests/lightbug_http/cookie/test_duration.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.cookie.duration import Duration 3 | 4 | 5 | def test_from_string(): 6 | testing.assert_equal(Duration.from_string("10").value().total_seconds, 10) 7 | testing.assert_false(Duration.from_string("10s").__bool__()) 8 | 9 | 10 | def test_ctor(): 11 | testing.assert_equal(Duration(seconds=1, minutes=1, hours=1, days=1).total_seconds, 90061) 12 | -------------------------------------------------------------------------------- /tests/lightbug_http/cookie/test_expiration.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.cookie.expiration import Expiration 3 | from lightbug_http.external.small_time import SmallTime 4 | 5 | 6 | def test_ctors(): 7 | # TODO: The string parsing is not correct, possibly a smalltime bug. I will look into it later. (@thatstoasty) 8 | # print(Expiration.from_string("Thu, 22 Jan 2037 12:00:10 GMT").value().datetime.value(), Expiration.from_datetime(SmallTime(2037, 1, 22, 12, 0, 10, 0)).datetime.value()) 9 | # testing.assert_true(Expiration.from_string("Thu, 22 Jan 2037 12:00:10 GMT").value() == Expiration.from_datetime(SmallTime(2037, 1, 22, 12, 0, 10, 0))) 10 | # Failure returns None 11 | # testing.assert_false(Expiration.from_string("abc").__bool__()) 12 | pass 13 | -------------------------------------------------------------------------------- /tests/lightbug_http/http/test_http.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from testing import assert_true, assert_equal 3 | from collections import Dict, List 4 | from lightbug_http.io.bytes import Bytes, bytes 5 | from lightbug_http.http import HTTPRequest, HTTPResponse, encode, HttpVersion 6 | from lightbug_http.header import Header, Headers, HeaderKey 7 | from lightbug_http.cookie import Cookie, ResponseCookieJar, RequestCookieJar, Duration, ResponseCookieKey 8 | from lightbug_http.uri import URI 9 | from lightbug_http.strings import to_string 10 | 11 | alias default_server_conn_string = "http://localhost:8080" 12 | 13 | 14 | def test_encode_http_request(): 15 | var uri = URI.parse(default_server_conn_string + "/foobar?baz") 16 | var req = HTTPRequest( 17 | uri, 18 | body=Bytes(String("Hello world!").as_bytes()), 19 | cookies=RequestCookieJar( 20 | Cookie(name="session_id", value="123", path=String("/"), secure=True, max_age=Duration(minutes=10)), 21 | Cookie(name="token", value="abc", domain=String("localhost"), path=String("/api"), http_only=True), 22 | ), 23 | headers=Headers(Header("Connection", "keep-alive")), 24 | ) 25 | 26 | var as_str = String(req) 27 | var req_encoded = to_string(encode(req^)) 28 | 29 | var expected = "GET /foobar?baz HTTP/1.1\r\nconnection: keep-alive\r\ncontent-length: 12\r\nhost: localhost:8080\r\ncookie: session_id=123; token=abc\r\n\r\nHello world!" 30 | 31 | testing.assert_equal(req_encoded, expected) 32 | testing.assert_equal(req_encoded, as_str) 33 | 34 | 35 | def test_encode_http_response(): 36 | var res = HTTPResponse(bytes("Hello, World!")) 37 | res.headers[HeaderKey.DATE] = "2024-06-02T13:41:50.766880+00:00" 38 | 39 | res.cookies = ResponseCookieJar( 40 | Cookie(name="session_id", value="123", path=String("/api"), secure=True), 41 | Cookie(name="session_id", value="abc", path=String("/"), secure=True, max_age=Duration(minutes=10)), 42 | Cookie(name="token", value="123", domain=String("localhost"), path=String("/api"), http_only=True), 43 | ) 44 | var as_str = String(res) 45 | var res_encoded = to_string(encode(res^)) 46 | var expected_full = "HTTP/1.1 200 OK\r\nserver: lightbug_http\r\ncontent-type: application/octet-stream\r\nconnection: keep-alive\r\ncontent-length: 13\r\ndate: 2024-06-02T13:41:50.766880+00:00\r\nset-cookie: session_id=123; Path=/api; Secure\r\nset-cookie: session_id=abc; Max-Age=600; Path=/; Secure\r\nset-cookie: token=123; Domain=localhost; Path=/api; HttpOnly\r\n\r\nHello, World!" 47 | 48 | testing.assert_equal(res_encoded, expected_full) 49 | testing.assert_equal(res_encoded, as_str) 50 | 51 | 52 | def test_decoding_http_response(): 53 | var res = String( 54 | "HTTP/1.1 200 OK\r\n" 55 | "server: lightbug_http\r\n" 56 | "content-type: application/octet-stream\r\n" 57 | "connection: keep-alive\r\ncontent-length: 13\r\n" 58 | "date: 2024-06-02T13:41:50.766880+00:00\r\n" 59 | "set-cookie: session_id=123; Path=/; Secure\r\n" 60 | "\r\n" 61 | "Hello, World!" 62 | ).as_bytes() 63 | 64 | var response = HTTPResponse.from_bytes(res) 65 | var expected_cookie_key = ResponseCookieKey("session_id", "", "/") 66 | 67 | assert_equal(1, len(response.cookies)) 68 | assert_true(expected_cookie_key in response.cookies, msg="request should contain a session_id header") 69 | var session_id = response.cookies.get(expected_cookie_key) 70 | assert_true(session_id is not None) 71 | assert_equal(session_id.value().path.value(), "/") 72 | assert_equal(200, response.status_code) 73 | assert_equal("OK", response.status_text) 74 | 75 | 76 | def test_http_version_parse(): 77 | var v1 = HttpVersion("HTTP/1.1") 78 | testing.assert_equal(v1._v, 1) 79 | var v2 = HttpVersion("HTTP/2") 80 | testing.assert_equal(v2._v, 2) 81 | -------------------------------------------------------------------------------- /tests/lightbug_http/http/test_request.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from memory import Span 3 | from collections.string import StringSlice 4 | from lightbug_http.http import HTTPRequest, StatusCode 5 | from lightbug_http.strings import to_string 6 | 7 | 8 | def test_request_from_bytes(): 9 | alias data = "GET /redirect HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: python-requests/2.32.3\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept: */*\r\nconnection: keep-alive\r\n\r\n" 10 | var request = HTTPRequest.from_bytes("127.0.0.1", 4096, data.as_bytes()) 11 | testing.assert_equal(request.protocol, "HTTP/1.1") 12 | testing.assert_equal(request.method, "GET") 13 | testing.assert_equal(request.uri.request_uri, "/redirect") 14 | testing.assert_equal(request.headers["Host"], "127.0.0.1:8080") 15 | testing.assert_equal(request.headers["User-Agent"], "python-requests/2.32.3") 16 | 17 | testing.assert_false(request.connection_close()) 18 | request.set_connection_close() 19 | testing.assert_true(request.connection_close()) 20 | 21 | 22 | def test_read_body(): 23 | alias data = "GET /redirect HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUser-Agent: python-requests/2.32.3\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept: */\r\nContent-Length: 17\r\nconnection: keep-alive\r\n\r\nThis is the body!" 24 | var request = HTTPRequest.from_bytes("127.0.0.1", 4096, data.as_bytes()) 25 | testing.assert_equal(request.protocol, "HTTP/1.1") 26 | testing.assert_equal(request.method, "GET") 27 | testing.assert_equal(request.uri.request_uri, "/redirect") 28 | testing.assert_equal(request.headers["Host"], "127.0.0.1:8080") 29 | testing.assert_equal(request.headers["User-Agent"], "python-requests/2.32.3") 30 | 31 | testing.assert_equal(String(request.get_body()), String("This is the body!")) 32 | 33 | 34 | def test_encode(): 35 | ... 36 | -------------------------------------------------------------------------------- /tests/lightbug_http/http/test_response.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.http import HTTPResponse, StatusCode 3 | from lightbug_http.strings import to_string 4 | 5 | 6 | def test_response_from_bytes(): 7 | alias data = "HTTP/1.1 200 OK\r\nServer: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Encoding: gzip\r\nContent-Length: 17\r\n\r\nThis is the body!" 8 | var response = HTTPResponse.from_bytes(data.as_bytes()) 9 | testing.assert_equal(response.protocol, "HTTP/1.1") 10 | testing.assert_equal(response.status_code, 200) 11 | testing.assert_equal(response.status_text, "OK") 12 | testing.assert_equal(response.headers["Server"], "example.com") 13 | testing.assert_equal(response.headers["Content-Type"], "text/html") 14 | testing.assert_equal(response.headers["Content-Encoding"], "gzip") 15 | 16 | testing.assert_equal(response.content_length(), 17) 17 | response.set_content_length(10) 18 | testing.assert_equal(response.content_length(), 10) 19 | 20 | testing.assert_false(response.connection_close()) 21 | response.set_connection_close() 22 | testing.assert_true(response.connection_close()) 23 | response.set_connection_keep_alive() 24 | testing.assert_false(response.connection_close()) 25 | testing.assert_equal(String(response.get_body()), String("This is the body!")) 26 | 27 | 28 | def test_is_redirect(): 29 | alias data = "HTTP/1.1 200 OK\r\nServer: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Encoding: gzip\r\nContent-Length: 17\r\n\r\nThis is the body!" 30 | var response = HTTPResponse.from_bytes(data.as_bytes()) 31 | testing.assert_false(response.is_redirect()) 32 | 33 | response.status_code = StatusCode.MOVED_PERMANENTLY 34 | testing.assert_true(response.is_redirect()) 35 | 36 | response.status_code = StatusCode.FOUND 37 | testing.assert_true(response.is_redirect()) 38 | 39 | response.status_code = StatusCode.TEMPORARY_REDIRECT 40 | testing.assert_true(response.is_redirect()) 41 | 42 | response.status_code = StatusCode.PERMANENT_REDIRECT 43 | testing.assert_true(response.is_redirect()) 44 | 45 | 46 | def test_read_body(): 47 | ... 48 | 49 | 50 | def test_read_chunks(): 51 | ... 52 | 53 | 54 | def test_encode(): 55 | ... 56 | -------------------------------------------------------------------------------- /tests/lightbug_http/io/test_byte_reader.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.io.bytes import Bytes, ByteReader, EndOfReaderError 3 | from lightbug_http.strings import to_string 4 | 5 | alias example = "Hello, World!" 6 | 7 | 8 | def test_peek(): 9 | var r = ByteReader("H".as_bytes()) 10 | testing.assert_equal(r.peek(), 72) 11 | 12 | # Peeking does not move the reader. 13 | testing.assert_equal(r.peek(), 72) 14 | 15 | # Trying to peek past the end of the reader should raise an Error 16 | r.read_pos = 1 17 | with testing.assert_raises(contains="No more bytes to read."): 18 | _ = r.peek() 19 | def test_read_until(): 20 | var r = ByteReader(example.as_bytes()) 21 | testing.assert_equal(r.read_pos, 0) 22 | testing.assert_equal(to_string(r.read_until(ord(",")).to_bytes()), to_string(Bytes(72, 101, 108, 108, 111))) 23 | testing.assert_equal(r.read_pos, 5) 24 | 25 | 26 | def test_read_bytes(): 27 | var r = ByteReader(example.as_bytes()) 28 | testing.assert_equal(to_string(r.read_bytes().to_bytes()), to_string(Bytes(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33))) 29 | 30 | r = ByteReader(example.as_bytes()) 31 | testing.assert_equal(to_string(r.read_bytes(7).to_bytes()), to_string(Bytes(72, 101, 108, 108, 111, 44, 32))) 32 | testing.assert_equal(to_string(r.read_bytes().to_bytes()), to_string(Bytes(87, 111, 114, 108, 100, 33))) 33 | 34 | 35 | def test_read_word(): 36 | var r = ByteReader(example.as_bytes()) 37 | testing.assert_equal(r.read_pos, 0) 38 | testing.assert_equal(to_string(r.read_word().to_bytes()), to_string(Bytes(72, 101, 108, 108, 111, 44))) 39 | testing.assert_equal(r.read_pos, 6) 40 | 41 | 42 | def test_read_line(): 43 | # No newline, go to end of line 44 | var r = ByteReader(example.as_bytes()) 45 | testing.assert_equal(r.read_pos, 0) 46 | testing.assert_equal(to_string(r.read_line().to_bytes()), to_string(Bytes(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33))) 47 | testing.assert_equal(r.read_pos, 13) 48 | 49 | # Newline, go to end of line. Should cover carriage return and newline 50 | var r2 = ByteReader("Hello\r\nWorld\n!".as_bytes()) 51 | testing.assert_equal(r2.read_pos, 0) 52 | testing.assert_equal(to_string(r2.read_line().to_bytes()), to_string(Bytes(72, 101, 108, 108, 111))) 53 | testing.assert_equal(r2.read_pos, 7) 54 | testing.assert_equal(to_string(r2.read_line().to_bytes()), to_string(Bytes(87, 111, 114, 108, 100))) 55 | testing.assert_equal(r2.read_pos, 13) 56 | 57 | 58 | def test_skip_whitespace(): 59 | var r = ByteReader(" Hola".as_bytes()) 60 | r.skip_whitespace() 61 | testing.assert_equal(r.read_pos, 1) 62 | testing.assert_equal(to_string(r.read_word().to_bytes()), to_string(Bytes(72, 111, 108, 97))) 63 | 64 | 65 | def test_skip_carriage_return(): 66 | var r = ByteReader("\r\nHola".as_bytes()) 67 | r.skip_carriage_return() 68 | testing.assert_equal(r.read_pos, 2) 69 | testing.assert_equal(to_string(r.read_bytes(4).to_bytes()), to_string(Bytes(72, 111, 108, 97))) 70 | 71 | 72 | def test_consume(): 73 | var r = ByteReader(example.as_bytes()) 74 | testing.assert_equal(to_string(r^.consume()), to_string(Bytes(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33))) 75 | -------------------------------------------------------------------------------- /tests/lightbug_http/io/test_byte_writer.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.io.bytes import Bytes, ByteWriter 3 | from lightbug_http.strings import to_string 4 | 5 | 6 | def test_write_byte(): 7 | var w = ByteWriter() 8 | w.write_byte(0x01) 9 | testing.assert_equal(to_string(w^.consume()), to_string(Bytes(0x01))) 10 | 11 | w = ByteWriter() 12 | w.write_byte(2) 13 | testing.assert_equal(to_string(w^.consume()), to_string(Bytes(2))) 14 | 15 | 16 | def test_consuming_write(): 17 | var w = ByteWriter() 18 | var my_string: String = "World" 19 | w.consuming_write(List[Byte, True]("Hello ".as_bytes())) 20 | w.consuming_write(List[Byte, True](my_string.as_bytes())) 21 | var result = w^.consume() 22 | 23 | testing.assert_equal(to_string(result), "Hello World") 24 | 25 | 26 | def test_write(): 27 | var w = ByteWriter() 28 | w.write("Hello", ", ") 29 | w.write_bytes("World!".as_bytes()) 30 | testing.assert_equal( 31 | to_string(w^.consume()), to_string(Bytes(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33)) 32 | ) 33 | -------------------------------------------------------------------------------- /tests/lightbug_http/io/test_bytes.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from collections import Dict, List 3 | from lightbug_http.io.bytes import Bytes, ByteView, bytes 4 | from lightbug_http.strings import to_string 5 | 6 | 7 | fn test_string_literal_to_bytes() raises: 8 | var cases = Dict[StaticString, Bytes]() 9 | cases[""] = Bytes() 10 | cases["Hello world!"] = Bytes(72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33) 11 | cases["\0"] = Bytes(0) 12 | cases["\0\0\0\0"] = Bytes(0, 0, 0, 0) 13 | cases["OK"] = Bytes(79, 75) 14 | cases["HTTP/1.1 200 OK"] = Bytes(72, 84, 84, 80, 47, 49, 46, 49, 32, 50, 48, 48, 32, 79, 75) 15 | 16 | for c in cases.items(): 17 | testing.assert_equal(to_string(Bytes(c[].key.as_bytes())), to_string(c[].value)) 18 | 19 | 20 | fn test_string_to_bytes() raises: 21 | var cases = Dict[String, Bytes]() 22 | cases[String("")] = Bytes() 23 | cases[String("Hello world!")] = Bytes(72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33) 24 | cases[String("\0")] = Bytes(0) 25 | cases[String("\0\0\0\0")] = Bytes(0, 0, 0, 0) 26 | cases[String("OK")] = Bytes(79, 75) 27 | cases[String("HTTP/1.1 200 OK")] = Bytes(72, 84, 84, 80, 47, 49, 46, 49, 32, 50, 48, 48, 32, 79, 75) 28 | 29 | for c in cases.items(): 30 | testing.assert_equal(to_string(Bytes(c[].key.as_bytes())), to_string(c[].value)) 31 | -------------------------------------------------------------------------------- /tests/lightbug_http/test_header.mojo: -------------------------------------------------------------------------------- 1 | from testing import assert_equal, assert_true 2 | from memory import Span 3 | from lightbug_http.header import Headers, Header 4 | from lightbug_http.io.bytes import Bytes, bytes, ByteReader 5 | 6 | 7 | def test_header_case_insensitive(): 8 | var headers = Headers(Header("Host", "SomeHost")) 9 | assert_true("host" in headers) 10 | assert_true("HOST" in headers) 11 | assert_true("hOST" in headers) 12 | assert_equal(headers["Host"], "SomeHost") 13 | assert_equal(headers["host"], "SomeHost") 14 | 15 | 16 | def test_parse_request_header(): 17 | var headers_str = "GET /index.html HTTP/1.1\r\nHost:example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Length: 1234\r\nConnection: close\r\nTrailer: end-of-message\r\n\r\n" 18 | var header = Headers() 19 | var reader = ByteReader(headers_str.as_bytes()) 20 | var method: String 21 | var protocol: String 22 | var uri: String 23 | var properties = header.parse_raw(reader) 24 | method, uri, protocol = properties[0], properties[1], properties[2] 25 | assert_equal(uri, "/index.html") 26 | assert_equal(protocol, "HTTP/1.1") 27 | assert_equal(method, "GET") 28 | assert_equal(header["Host"], "example.com") 29 | assert_equal(header["User-Agent"], "Mozilla/5.0") 30 | assert_equal(header["Content-Type"], "text/html") 31 | assert_equal(header["Content-Length"], "1234") 32 | assert_equal(header["Connection"], "close") 33 | 34 | 35 | def test_parse_response_header(): 36 | var headers_str = "HTTP/1.1 200 OK\r\nServer: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/html\r\nContent-Encoding: gzip\r\nContent-Length: 1234\r\nConnection: close\r\nTrailer: end-of-message\r\n\r\n" 37 | var header = Headers() 38 | var protocol: String 39 | var status_code: String 40 | var status_text: String 41 | var reader = ByteReader(headers_str.as_bytes()) 42 | var properties = header.parse_raw(reader) 43 | protocol, status_code, status_text = properties[0], properties[1], properties[2] 44 | assert_equal(protocol, "HTTP/1.1") 45 | assert_equal(status_code, "200") 46 | assert_equal(status_text, "OK") 47 | assert_equal(header["Server"], "example.com") 48 | assert_equal(header["Content-Type"], "text/html") 49 | assert_equal(header["Content-Encoding"], "gzip") 50 | assert_equal(header["Content-Length"], "1234") 51 | assert_equal(header["Connection"], "close") 52 | assert_equal(header["Trailer"], "end-of-message") 53 | -------------------------------------------------------------------------------- /tests/lightbug_http/test_host_port.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.address import TCPAddr, NetworkType, join_host_port, parse_address 3 | 4 | 5 | def test_split_host_port(): 6 | # TCP4 7 | var hp = parse_address(NetworkType.tcp4, "127.0.0.1:8080") 8 | testing.assert_equal(hp[0], "127.0.0.1") 9 | testing.assert_equal(hp[1], 8080) 10 | 11 | # TCP4 with localhost 12 | hp = parse_address(NetworkType.tcp4, "localhost:8080") 13 | testing.assert_equal(hp[0], "127.0.0.1") 14 | testing.assert_equal(hp[1], 8080) 15 | 16 | # TCP6 17 | hp = parse_address(NetworkType.tcp6, "[::1]:8080") 18 | testing.assert_equal(hp[0], "::1") 19 | testing.assert_equal(hp[1], 8080) 20 | 21 | # TCP6 with localhost 22 | hp = parse_address(NetworkType.tcp6, "localhost:8080") 23 | testing.assert_equal(hp[0], "::1") 24 | testing.assert_equal(hp[1], 8080) 25 | 26 | # UDP4 27 | hp = parse_address(NetworkType.udp4, "192.168.1.1:53") 28 | testing.assert_equal(hp[0], "192.168.1.1") 29 | testing.assert_equal(hp[1], 53) 30 | 31 | # UDP4 with localhost 32 | hp = parse_address(NetworkType.udp4, "localhost:53") 33 | testing.assert_equal(hp[0], "127.0.0.1") 34 | testing.assert_equal(hp[1], 53) 35 | 36 | # UDP6 37 | hp = parse_address(NetworkType.udp6, "[2001:db8::1]:53") 38 | testing.assert_equal(hp[0], "2001:db8::1") 39 | testing.assert_equal(hp[1], 53) 40 | 41 | # UDP6 with localhost 42 | hp = parse_address(NetworkType.udp6, "localhost:53") 43 | testing.assert_equal(hp[0], "::1") 44 | testing.assert_equal(hp[1], 53) 45 | 46 | # IP4 (no port) 47 | hp = parse_address(NetworkType.ip4, "192.168.1.1") 48 | testing.assert_equal(hp[0], "192.168.1.1") 49 | testing.assert_equal(hp[1], 0) 50 | 51 | # IP4 with localhost 52 | hp = parse_address(NetworkType.ip4, "localhost") 53 | testing.assert_equal(hp[0], "127.0.0.1") 54 | testing.assert_equal(hp[1], 0) 55 | 56 | # IP6 (no port) 57 | hp = parse_address(NetworkType.ip6, "2001:db8::1") 58 | testing.assert_equal(hp[0], "2001:db8::1") 59 | testing.assert_equal(hp[1], 0) 60 | 61 | # IP6 with localhost 62 | hp = parse_address(NetworkType.ip6, "localhost") 63 | testing.assert_equal(hp[0], "::1") 64 | testing.assert_equal(hp[1], 0) 65 | 66 | # TODO: IPv6 long form - Not supported yet. 67 | # hp = parse_address("0:0:0:0:0:0:0:1:8080") 68 | # testing.assert_equal(hp[0], "0:0:0:0:0:0:0:1") 69 | # testing.assert_equal(hp[1], 8080) 70 | 71 | # Error cases 72 | # IP protocol with port 73 | try: 74 | _ = parse_address(NetworkType.ip4, "192.168.1.1:80") 75 | testing.assert_false("Should have raised an error for IP protocol with port") 76 | except Error: 77 | testing.assert_true(True) 78 | 79 | # Missing port 80 | try: 81 | _ = parse_address(NetworkType.tcp4, "192.168.1.1") 82 | testing.assert_false("Should have raised MissingPortError") 83 | except MissingPortError: 84 | testing.assert_true(True) 85 | 86 | # Missing port 87 | try: 88 | _ = parse_address(NetworkType.tcp6, "[::1]") 89 | testing.assert_false("Should have raised MissingPortError") 90 | except MissingPortError: 91 | testing.assert_true(True) 92 | 93 | # Port out of range 94 | try: 95 | _ = parse_address(NetworkType.tcp4, "192.168.1.1:70000") 96 | testing.assert_false("Should have raised error for invalid port") 97 | except Error: 98 | testing.assert_true(True) 99 | 100 | # Missing closing bracket 101 | try: 102 | _ = parse_address(NetworkType.tcp6, "[::1:8080") 103 | testing.assert_false("Should have raised error for missing bracket") 104 | except Error: 105 | testing.assert_true(True) 106 | 107 | 108 | def test_join_host_port(): 109 | # IPv4 110 | testing.assert_equal(join_host_port("127.0.0.1", "8080"), "127.0.0.1:8080") 111 | 112 | # IPv6 113 | testing.assert_equal(join_host_port("::1", "8080"), "[::1]:8080") 114 | 115 | # TODO: IPv6 long form - Not supported yet. 116 | -------------------------------------------------------------------------------- /tests/lightbug_http/test_server.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.server import Server 3 | 4 | 5 | def test_server(): 6 | var server = Server() 7 | server.set_address("0.0.0.0") 8 | testing.assert_equal(server.address(), "0.0.0.0") 9 | server.set_max_request_body_size(1024) 10 | testing.assert_equal(server.max_request_body_size(), 1024) 11 | testing.assert_equal(server.get_concurrency(), 1000) 12 | 13 | server = Server(max_concurrent_connections=10) 14 | testing.assert_equal(server.get_concurrency(), 10) 15 | -------------------------------------------------------------------------------- /tests/lightbug_http/test_service.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.service import Printer, Welcome, ExampleRouter, TechEmpowerRouter, Counter 3 | 4 | 5 | def test_printer(): 6 | pass 7 | 8 | 9 | def test_welcome(): 10 | pass 11 | 12 | 13 | def test_example_router(): 14 | pass 15 | 16 | 17 | def test_tech_empower_router(): 18 | pass 19 | 20 | 21 | def test_counter(): 22 | pass 23 | -------------------------------------------------------------------------------- /tests/lightbug_http/test_uri.mojo: -------------------------------------------------------------------------------- 1 | import testing 2 | from lightbug_http.uri import URI 3 | from lightbug_http.strings import empty_string, to_string 4 | from lightbug_http.io.bytes import Bytes 5 | 6 | 7 | def test_uri_no_parse_defaults(): 8 | var uri = URI.parse("http://example.com") 9 | testing.assert_equal(uri.full_uri, "http://example.com") 10 | testing.assert_equal(uri.scheme, "http") 11 | testing.assert_equal(uri.path, "/") 12 | 13 | 14 | def test_uri_parse_http_with_port(): 15 | var uri = URI.parse("http://example.com:8080/index.html") 16 | testing.assert_equal(uri.scheme, "http") 17 | testing.assert_equal(uri.host, "example.com") 18 | testing.assert_equal(uri.port.value(), 8080) 19 | testing.assert_equal(uri.path, "/index.html") 20 | testing.assert_equal(uri._original_path, "/index.html") 21 | testing.assert_equal(uri.request_uri, "/index.html") 22 | testing.assert_equal(uri.is_https(), False) 23 | testing.assert_equal(uri.is_http(), True) 24 | testing.assert_equal(uri.query_string, empty_string) 25 | 26 | 27 | def test_uri_parse_https_with_port(): 28 | var uri = URI.parse("https://example.com:8080/index.html") 29 | testing.assert_equal(uri.scheme, "https") 30 | testing.assert_equal(uri.host, "example.com") 31 | testing.assert_equal(uri.port.value(), 8080) 32 | testing.assert_equal(uri.path, "/index.html") 33 | testing.assert_equal(uri._original_path, "/index.html") 34 | testing.assert_equal(uri.request_uri, "/index.html") 35 | testing.assert_equal(uri.is_https(), True) 36 | testing.assert_equal(uri.is_http(), False) 37 | testing.assert_equal(uri.query_string, empty_string) 38 | 39 | 40 | def test_uri_parse_http_with_path(): 41 | var uri = URI.parse("http://example.com/index.html") 42 | testing.assert_equal(uri.scheme, "http") 43 | testing.assert_equal(uri.host, "example.com") 44 | testing.assert_equal(uri.path, "/index.html") 45 | testing.assert_equal(uri._original_path, "/index.html") 46 | testing.assert_equal(uri.request_uri, "/index.html") 47 | testing.assert_equal(uri.is_https(), False) 48 | testing.assert_equal(uri.is_http(), True) 49 | testing.assert_equal(uri.query_string, empty_string) 50 | 51 | 52 | def test_uri_parse_https_with_path(): 53 | var uri = URI.parse("https://example.com/index.html") 54 | testing.assert_equal(uri.scheme, "https") 55 | testing.assert_equal(uri.host, "example.com") 56 | testing.assert_equal(uri.path, "/index.html") 57 | testing.assert_equal(uri._original_path, "/index.html") 58 | testing.assert_equal(uri.request_uri, "/index.html") 59 | testing.assert_equal(uri.is_https(), True) 60 | testing.assert_equal(uri.is_http(), False) 61 | testing.assert_equal(uri.query_string, empty_string) 62 | 63 | 64 | def test_uri_parse_path_with_encoding(): 65 | var uri = URI.parse("https://example.com/test%20test/index.html") 66 | testing.assert_equal(uri.path, "/test test/index.html") 67 | 68 | 69 | def test_uri_parse_path_with_encoding_ignore_slashes(): 70 | var uri = URI.parse("https://example.com/trying_to%2F_be_clever/42.html") 71 | testing.assert_equal(uri.path, "/trying_to_be_clever/42.html") 72 | 73 | 74 | def test_uri_parse_http_basic(): 75 | var uri = URI.parse("http://example.com") 76 | testing.assert_equal(uri.scheme, "http") 77 | testing.assert_equal(uri.host, "example.com") 78 | testing.assert_equal(uri.path, "/") 79 | testing.assert_equal(uri._original_path, "/") 80 | testing.assert_equal(uri.request_uri, "/") 81 | testing.assert_equal(uri.query_string, empty_string) 82 | 83 | 84 | def test_uri_parse_http_basic_www(): 85 | var uri = URI.parse("http://www.example.com") 86 | testing.assert_equal(uri.scheme, "http") 87 | testing.assert_equal(uri.host, "www.example.com") 88 | testing.assert_equal(uri.path, "/") 89 | testing.assert_equal(uri._original_path, "/") 90 | testing.assert_equal(uri.request_uri, "/") 91 | testing.assert_equal(uri.query_string, empty_string) 92 | 93 | 94 | def test_uri_parse_http_with_query_string(): 95 | var uri = URI.parse("http://www.example.com/job?title=engineer") 96 | testing.assert_equal(uri.scheme, "http") 97 | testing.assert_equal(uri.host, "www.example.com") 98 | testing.assert_equal(uri.path, "/job") 99 | testing.assert_equal(uri._original_path, "/job") 100 | testing.assert_equal(uri.request_uri, "/job?title=engineer") 101 | testing.assert_equal(uri.query_string, "title=engineer") 102 | testing.assert_equal(uri.queries["title"], "engineer") 103 | 104 | 105 | def test_uri_parse_multiple_query_parameters(): 106 | var uri = URI.parse("http://example.com/search?q=python&page=1&limit=20") 107 | testing.assert_equal(uri.scheme, "http") 108 | testing.assert_equal(uri.host, "example.com") 109 | testing.assert_equal(uri.path, "/search") 110 | testing.assert_equal(uri.query_string, "q=python&page=1&limit=20") 111 | testing.assert_equal(uri.queries["q"], "python") 112 | testing.assert_equal(uri.queries["page"], "1") 113 | testing.assert_equal(uri.queries["limit"], "20") 114 | testing.assert_equal(uri.request_uri, "/search?q=python&page=1&limit=20") 115 | 116 | 117 | def test_uri_parse_query_with_special_characters(): 118 | var uri = URI.parse("https://example.com/path?name=John+Doe&email=john%40example.com&escaped%40%20name=42") 119 | testing.assert_equal(uri.scheme, "https") 120 | testing.assert_equal(uri.host, "example.com") 121 | testing.assert_equal(uri.path, "/path") 122 | testing.assert_equal(uri.query_string, "name=John+Doe&email=john%40example.com&escaped%40%20name=42") 123 | testing.assert_equal(uri.queries["name"], "John Doe") 124 | testing.assert_equal(uri.queries["email"], "john@example.com") 125 | testing.assert_equal(uri.queries["escaped@ name"], "42") 126 | 127 | 128 | def test_uri_parse_empty_query_values(): 129 | var uri = URI.parse("http://example.com/api?key=&token=&empty") 130 | testing.assert_equal(uri.query_string, "key=&token=&empty") 131 | testing.assert_equal(uri.queries["key"], "") 132 | testing.assert_equal(uri.queries["token"], "") 133 | testing.assert_equal(uri.queries["empty"], "") 134 | 135 | 136 | def test_uri_parse_complex_query(): 137 | var uri = URI.parse("https://example.com/search?q=test&filter[category]=books&filter[price]=10-20&sort=desc&page=1") 138 | testing.assert_equal(uri.scheme, "https") 139 | testing.assert_equal(uri.host, "example.com") 140 | testing.assert_equal(uri.path, "/search") 141 | testing.assert_equal(uri.query_string, "q=test&filter[category]=books&filter[price]=10-20&sort=desc&page=1") 142 | testing.assert_equal(uri.queries["q"], "test") 143 | testing.assert_equal(uri.queries["filter[category]"], "books") 144 | testing.assert_equal(uri.queries["filter[price]"], "10-20") 145 | testing.assert_equal(uri.queries["sort"], "desc") 146 | testing.assert_equal(uri.queries["page"], "1") 147 | 148 | 149 | def test_uri_parse_query_with_unicode(): 150 | var uri = URI.parse("http://example.com/search?q=%E2%82%AC&lang=%F0%9F%87%A9%F0%9F%87%AA") 151 | testing.assert_equal(uri.query_string, "q=%E2%82%AC&lang=%F0%9F%87%A9%F0%9F%87%AA") 152 | testing.assert_equal(uri.queries["q"], "€") 153 | testing.assert_equal(uri.queries["lang"], "🇩🇪") 154 | 155 | 156 | # def test_uri_parse_query_with_fragments(): 157 | # var uri = URI.parse("http://example.com/page?id=123#section1") 158 | # testing.assert_equal(uri.query_string, "id=123") 159 | # testing.assert_equal(uri.queries["id"], "123") 160 | # testing.assert_equal(...) - how do we treat fragments? 161 | 162 | 163 | def test_uri_parse_no_scheme(): 164 | var uri = URI.parse("www.example.com") 165 | testing.assert_equal(uri.scheme, "http") 166 | testing.assert_equal(uri.host, "www.example.com") 167 | 168 | 169 | def test_uri_ip_address_no_scheme(): 170 | var uri = URI.parse("168.22.0.1/path/to/favicon.ico") 171 | testing.assert_equal(uri.scheme, "http") 172 | testing.assert_equal(uri.host, "168.22.0.1") 173 | testing.assert_equal(uri.path, "/path/to/favicon.ico") 174 | 175 | 176 | def test_uri_ip_address(): 177 | var uri = URI.parse("http://168.22.0.1:8080/path/to/favicon.ico") 178 | testing.assert_equal(uri.scheme, "http") 179 | testing.assert_equal(uri.host, "168.22.0.1") 180 | testing.assert_equal(uri.path, "/path/to/favicon.ico") 181 | testing.assert_equal(uri.port.value(), 8080) 182 | 183 | 184 | # def test_uri_parse_http_with_hash(): 185 | # ... 186 | -------------------------------------------------------------------------------- /testutils/__init__.mojo: -------------------------------------------------------------------------------- 1 | from .utils import * -------------------------------------------------------------------------------- /testutils/utils.mojo: -------------------------------------------------------------------------------- 1 | from python import Python, PythonObject 2 | from lightbug_http.io.bytes import Bytes 3 | from lightbug_http.error import ErrorHandler 4 | from lightbug_http.uri import URI 5 | from lightbug_http.http import HTTPRequest, HTTPResponse 6 | from lightbug_http.connection import Listener, Connection 7 | from lightbug_http.address import Addr, TCPAddr 8 | from lightbug_http.service import HTTPService, OK 9 | from lightbug_http.server import ServerTrait 10 | from lightbug_http.client import Client 11 | from lightbug_http.io.bytes import bytes 12 | from lightbug_http.header import Headers, Header 13 | 14 | alias default_server_conn_string = "http://localhost:8080" 15 | 16 | alias defaultExpectedGetResponse = bytes( 17 | "HTTP/1.1 200 OK\r\nServer: lightbug_http\r\nContent-Type:" 18 | " text/plain\r\nContent-Length: 12\r\nConnection: close\r\nDate:" 19 | " \r\n\r\nHello world!" 20 | ) 21 | 22 | 23 | @parameter 24 | fn new_httpx_client() -> PythonObject: 25 | try: 26 | var httpx = Python.import_module("httpx") 27 | return httpx 28 | except e: 29 | print("Could not set up httpx client: " + e.__str__()) 30 | return None 31 | 32 | 33 | fn new_fake_listener(request_count: Int, request: Bytes) -> FakeListener: 34 | return FakeListener(request_count, request) 35 | 36 | 37 | struct ReqInfo: 38 | var full_uri: URI 39 | var host: String 40 | var is_tls: Bool 41 | 42 | fn __init__(out self, full_uri: URI, host: String, is_tls: Bool): 43 | self.full_uri = full_uri 44 | self.host = host 45 | self.is_tls = is_tls 46 | 47 | 48 | struct FakeClient(Client): 49 | """FakeClient doesn't actually send any requests, but it extracts useful information from the input. 50 | """ 51 | 52 | var name: String 53 | var host: StringLiteral 54 | var port: Int 55 | var req_full_uri: URI 56 | var req_host: String 57 | var req_is_tls: Bool 58 | 59 | fn __init__(out self) raises: 60 | self.host = "127.0.0.1" 61 | self.port = 8888 62 | self.name = "lightbug_http_fake_client" 63 | self.req_full_uri = URI("") 64 | self.req_host = "" 65 | self.req_is_tls = False 66 | 67 | fn __init__(out self, host: StringLiteral, port: Int) raises: 68 | self.host = host 69 | self.port = port 70 | self.name = "lightbug_http_fake_client" 71 | self.req_full_uri = URI("") 72 | self.req_host = "" 73 | self.req_is_tls = False 74 | 75 | fn do(self, owned req: HTTPRequest) raises -> HTTPResponse: 76 | return OK(String(defaultExpectedGetResponse)) 77 | 78 | fn extract(mut self, req: HTTPRequest) raises -> ReqInfo: 79 | var full_uri = req.uri() 80 | try: 81 | _ = full_uri.parse() 82 | except e: 83 | print("error parsing uri: " + e.__str__()) 84 | 85 | self.req_full_uri = full_uri 86 | 87 | var host = String(full_uri.host()) 88 | 89 | if host == "": 90 | raise Error("URI host is nil") 91 | 92 | self.req_host = host 93 | 94 | var is_tls = full_uri.is_https() 95 | self.req_is_tls = is_tls 96 | 97 | return ReqInfo(full_uri, host, is_tls) 98 | 99 | 100 | struct FakeServer(ServerTrait): 101 | var __listener: FakeListener 102 | var __handler: FakeResponder 103 | 104 | fn __init__(out self, listener: FakeListener, handler: FakeResponder): 105 | self.__listener = listener 106 | self.__handler = handler 107 | 108 | fn __init__( 109 | mut self, 110 | addr: String, 111 | service: HTTPService, 112 | error_handler: ErrorHandler, 113 | ): 114 | self.__listener = FakeListener() 115 | self.__handler = FakeResponder() 116 | 117 | fn get_concurrency(self) -> Int: 118 | return 1 119 | 120 | fn listen_and_serve( 121 | self, address: String, handler: HTTPService 122 | ) raises -> None: 123 | ... 124 | 125 | fn serve(mut self) -> None: 126 | while not self.__listener.closed: 127 | try: 128 | _ = self.__listener.accept() 129 | except e: 130 | print(e) 131 | 132 | fn serve(self, ln: Listener, handler: HTTPService) raises -> None: 133 | ... 134 | 135 | 136 | @value 137 | struct FakeResponder(HTTPService): 138 | fn func(mut self, req: HTTPRequest) raises -> HTTPResponse: 139 | var method = req.method 140 | if method != "GET": 141 | raise Error("Did not expect a non-GET request! Got: " + method) 142 | return OK(bytes("Hello, world!")) 143 | 144 | 145 | @value 146 | struct FakeConnection(Connection): 147 | fn __init__(out self, laddr: String, raddr: String) raises: 148 | ... 149 | 150 | fn __init__(out self, laddr: TCPAddr, raddr: TCPAddr) raises: 151 | ... 152 | 153 | fn read(self, mut buf: Bytes) raises -> Int: 154 | return 0 155 | 156 | fn write(self, buf: Bytes) raises -> Int: 157 | return 0 158 | 159 | fn close(self) raises: 160 | ... 161 | 162 | fn local_addr(mut self) raises -> TCPAddr: 163 | return TCPAddr() 164 | 165 | fn remote_addr(self) raises -> TCPAddr: 166 | return TCPAddr() 167 | 168 | 169 | @value 170 | struct FakeListener: 171 | var request_count: Int 172 | var request: Bytes 173 | var closed: Bool 174 | 175 | fn __init__(out self): 176 | self.request_count = 0 177 | self.request = Bytes() 178 | self.closed = False 179 | 180 | fn __init__(out self, addr: TCPAddr): 181 | self.request_count = 0 182 | self.request = Bytes() 183 | self.closed = False 184 | 185 | fn __init__(out self, request_count: Int, request: Bytes): 186 | self.request_count = request_count 187 | self.request = request 188 | self.closed = False 189 | 190 | @always_inline 191 | fn accept(self) raises -> FakeConnection: 192 | return FakeConnection() 193 | 194 | fn close(self) raises: 195 | pass 196 | 197 | fn addr(self) -> TCPAddr: 198 | return TCPAddr() 199 | 200 | 201 | @value 202 | struct TestStruct: 203 | var a: String 204 | var b: String 205 | var c: Bytes 206 | var d: Int 207 | var e: TestStructNested 208 | 209 | fn __init__(out self, a: String, b: String) -> None: 210 | self.a = a 211 | self.b = b 212 | self.c = bytes("c") 213 | self.d = 1 214 | self.e = TestStructNested("a", 1) 215 | 216 | fn set_a_direct(mut self, a: String) -> Self: 217 | self.a = a 218 | return self 219 | 220 | fn set_a_copy(self, a: String) -> Self: 221 | return Self(a, self.b) 222 | 223 | 224 | @value 225 | struct TestStructNested: 226 | var a: String 227 | var b: Int 228 | 229 | fn __init__(out self, a: String, b: Int) -> None: 230 | self.a = a 231 | self.b = b 232 | 233 | fn set_a_direct(mut self, a: String) -> Self: 234 | self.a = a 235 | return self 236 | 237 | fn set_a_copy(self, a: String) -> Self: 238 | return Self(a, self.b) 239 | --------------------------------------------------------------------------------