├── .flake8
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── publish.yml
│ └── tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── inspira
├── __init__.py
├── auth
│ ├── __init__.py
│ ├── auth_utils.py
│ ├── decorators
│ │ ├── __init__.py
│ │ └── login_required.py
│ └── mixins
│ │ ├── __init__.py
│ │ └── user_mixin.py
├── cli
│ ├── __init__.py
│ ├── cli.py
│ ├── create_app.py
│ ├── create_controller.py
│ ├── generate_database_file.py
│ ├── generate_model_file.py
│ ├── generate_repository_file.py
│ ├── generate_service_file.py
│ ├── init_file.py
│ └── templates
│ │ ├── __init__.py
│ │ ├── app_template.txt
│ │ ├── controller_template.jinja2
│ │ ├── database_template.txt
│ │ ├── model_template.jinja2
│ │ ├── repository_template.jinja2
│ │ ├── service_template.jinja2
│ │ └── websocket_controller_template.jinja2
├── config.py
├── constants.py
├── decorators
│ ├── __init__.py
│ ├── http_methods.py
│ ├── path.py
│ └── websocket.py
├── enums.py
├── globals.py
├── helpers
│ ├── __init__.py
│ ├── error_handlers.py
│ ├── error_templates.py
│ └── static_file_handler.py
├── inspira.py
├── logging.py
├── middlewares
│ ├── __init__.py
│ ├── cors.py
│ ├── sessions.py
│ └── user_loader.py
├── migrations
│ ├── __init__.py
│ ├── migrations.py
│ └── utils.py
├── requests.py
├── responses.py
├── testclient.py
├── utils
│ ├── __init__.py
│ ├── controller_parser.py
│ ├── dependency_resolver.py
│ ├── handler_invoker.py
│ ├── inflection.py
│ ├── naming.py
│ ├── param_converter.py
│ ├── secret_key.py
│ └── session_utils.py
└── websockets
│ ├── __init__.py
│ ├── handle_websocket.py
│ ├── websocket.py
│ └── websocket_controller_registry.py
├── pyproject.toml
├── requirements.txt
└── tests
├── __init__.py
├── conftest.py
├── migrations
├── 0001_test.sql
├── 0002_another.sql
├── 003_missing.sql
└── not_a_migration.txt
├── static
└── example.css
├── templates
└── example.html
├── test_auth.py
├── test_cli.py
├── test_config.py
├── test_dependency_resolver.py
├── test_globals.py
├── test_helpers.py
├── test_inspira.py
├── test_logging.py
├── test_middleware.py
├── test_migrations.py
├── test_path_decorator.py
├── test_requests.py
├── test_responses.py
├── test_session_utils.py
├── test_utils.py
└── test_websockets.py
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | exclude =
3 | .venv
4 | __pycache__
--------------------------------------------------------------------------------
/.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 |
15 |
16 | **Expected behavior**
17 | A clear and concise description of what you expected to happen.
18 |
19 | **Screenshots**
20 | If applicable, add screenshots to help explain your problem.
21 |
22 | **Additional context**
23 | Add any other context about the problem here.
24 |
--------------------------------------------------------------------------------
/.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/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types:
6 | - created
7 |
8 | jobs:
9 | build-n-publish:
10 | name: Build and publish to PyPI
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout source
15 | uses: actions/checkout@v2
16 |
17 | - name: Set up Python
18 | uses: actions/setup-python@v2
19 | with:
20 | python-version: "3.x"
21 |
22 | - name: Build source and wheel distributions
23 | run: |
24 | python -m pip install --upgrade build twine
25 | python -m build
26 | twine check --strict dist/*
27 | - name: Publish distribution to PyPI
28 | uses: pypa/gh-action-pypi-publish@master
29 | with:
30 | user: __token__
31 | password: ${{ secrets.PYPI_API_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Set up Python 3.10
20 | uses: actions/setup-python@v3
21 | with:
22 | python-version: "3.10"
23 | - name: Install dependencies
24 | run: |
25 | python -m pip install --upgrade pip
26 | pip install flake8 pytest
27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
28 | - name: Lint with flake8
29 | run: |
30 | # stop the build if there are Python syntax errors or undefined names
31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
34 | - name: Test with pytest
35 | run: |
36 | pytest
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 |
162 | .DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 Hayri Cicek
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Inspira
2 |
3 | [](LICENSE)
4 |
5 | Inspira is a lightweight framework for building asynchronous web applications.
6 |
7 | ## Quick Start
8 |
9 | ### Prerequisites
10 |
11 | Make sure you have Python and `pip` installed on your system.
12 |
13 | ### Create a Python Virtual Environment
14 |
15 | ```bash
16 | # Create a new directory for your project
17 | $ mkdir myproject
18 | $ cd myproject
19 | ```
20 |
21 | **Create and activate a virtual environment**
22 |
23 | ```bash
24 | $ python -m venv venv
25 | $ source venv/bin/activate
26 | ```
27 |
28 | **Install Inspira**
29 |
30 | ```bash
31 | $ pip install inspira
32 | ```
33 |
34 | ## Generating an App
35 |
36 | To generate a new app for your project, run the following command:
37 |
38 | ```bash
39 | $ inspira init
40 | ```
41 |
42 | ## Generated Directory Structure
43 |
44 | After running the `init` command, the directory structure of your project should look like the following:
45 |
46 | ```bash
47 | ├── main.py
48 | ├── src
49 | │ ├── __init__.py
50 | │ ├── controller
51 | │ │ └── __init__.py
52 | │ ├── model
53 | │ │ └── __init__.py
54 | │ ├── repository
55 | │ │ └── __init__.py
56 | │ └── service
57 | │ └── __init__.py
58 | └── tests
59 | └── __init__.py
60 | ```
61 |
62 | ## Generate Database file
63 |
64 | Use the following command to generate a database file:
65 |
66 | ```bash
67 | $ inspira new database --name mydb --type sqlite
68 | ```
69 |
70 | This command will create a new database file named `mydb` with `SQLite` as the database type.
71 |
72 | The generated database file (`database.py`) will typically contain initial configurations and may look like this:
73 |
74 | ```python
75 | from sqlalchemy import create_engine
76 | from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
77 |
78 | engine = create_engine("sqlite:///mydb.db")
79 | db_session = scoped_session(
80 | sessionmaker(autocommit=False, autoflush=False, bind=engine)
81 | )
82 | Base = declarative_base()
83 | Base.query = db_session.query_property()
84 | ```
85 |
86 | ## Generating Controller
87 |
88 | To generate necessary controller for your project, run the following command:
89 |
90 | ```bash
91 | $ inspira new controller order
92 | ```
93 |
94 | ## Generating Repository
95 |
96 | To generate repository file, run the following command:
97 |
98 | ```bash
99 | $ inspira new repository order
100 | ```
101 |
102 | ## Generating Service
103 |
104 | To generate service file, run the following command:
105 |
106 | ```bash
107 | $ inspira new service order
108 | ```
109 |
110 | ## Generating Model
111 |
112 | To generate model file, run the following command:
113 |
114 | ```bash
115 | $ inspira new model order
116 | ```
117 |
118 | ## Starting the Server
119 |
120 | After generating your app and setting up the necessary resources, start the server with the following command:
121 |
122 | ```bash
123 | $ uvicorn main:app --reload
124 | ```
125 |
126 | ## Links
127 | Documentation: https://www.inspiraframework.com/
128 |
129 |
130 | ## License
131 |
132 | This project is licensed under the terms of the MIT license.
--------------------------------------------------------------------------------
/inspira/__init__.py:
--------------------------------------------------------------------------------
1 | from .inspira import Inspira
2 |
--------------------------------------------------------------------------------
/inspira/auth/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/auth/__init__.py
--------------------------------------------------------------------------------
/inspira/auth/auth_utils.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | import jwt
4 |
5 | from inspira.globals import get_global_app
6 | from inspira.requests import RequestContext
7 |
8 | app = get_global_app()
9 |
10 | if app:
11 | SECRET_KEY = app.secret_key
12 | TOKEN_EXPIRATION_TIME = app.config["TOKEN_EXPIRATION_TIME"]
13 | else:
14 | SECRET_KEY = "dummy"
15 | TOKEN_EXPIRATION_TIME = 3600
16 |
17 |
18 | def login_user(user_id):
19 | token = encode_auth_token(user_id)
20 | request = RequestContext.get_request()
21 | request.set_session("token", token)
22 |
23 |
24 | def logout_user():
25 | request = RequestContext.get_request()
26 | session = request.session
27 |
28 | if session and "token" in session:
29 | request.remove_session("token")
30 |
31 |
32 | def encode_auth_token(user_id):
33 | payload = {
34 | "iat": datetime.utcnow(),
35 | "exp": datetime.utcnow() + timedelta(seconds=TOKEN_EXPIRATION_TIME),
36 | "sub": user_id,
37 | }
38 | return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
39 |
40 |
41 | def decode_auth_token(token):
42 | try:
43 | payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
44 | return payload["sub"]
45 | except jwt.ExpiredSignatureError:
46 | return None
47 | except jwt.InvalidTokenError:
48 | return None
49 |
--------------------------------------------------------------------------------
/inspira/auth/decorators/__init__.py:
--------------------------------------------------------------------------------
1 | from .login_required import login_required
2 |
--------------------------------------------------------------------------------
/inspira/auth/decorators/login_required.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from inspira.auth.auth_utils import decode_auth_token
4 | from inspira.requests import RequestContext
5 | from inspira.responses import HttpResponse
6 |
7 |
8 | def login_required(func):
9 | @wraps(func)
10 | async def wrapper(*args, **kwargs):
11 | request = RequestContext.get_request()
12 |
13 | token = request.get_session("token")
14 |
15 | if not token or decode_auth_token(token) is None:
16 | return HttpResponse("Unauthorized", status_code=401)
17 |
18 | return await func(*args, **kwargs)
19 |
20 | return wrapper
21 |
--------------------------------------------------------------------------------
/inspira/auth/mixins/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/auth/mixins/__init__.py
--------------------------------------------------------------------------------
/inspira/auth/mixins/user_mixin.py:
--------------------------------------------------------------------------------
1 | import bcrypt
2 |
3 | from inspira.constants import UTF8
4 |
5 |
6 | class UserMixin:
7 | def __init__(self):
8 | self.password = None
9 |
10 | @property
11 | def is_active(self):
12 | return True
13 |
14 | @property
15 | def is_authenticated(self):
16 | return self.is_active
17 |
18 | @property
19 | def is_anonymous(self):
20 | return False
21 |
22 | def set_password(self, password):
23 | hashed_password = bcrypt.hashpw(password.encode(UTF8), bcrypt.gensalt())
24 | self.password = hashed_password.decode(UTF8)
25 |
26 | def check_password_hash(self, password):
27 | return bcrypt.checkpw(password.encode(UTF8), self.password.encode(UTF8))
28 |
29 |
30 | class AnonymousUserMixin:
31 | @property
32 | def is_authenticated(self):
33 | return False
34 |
35 | @property
36 | def is_active(self):
37 | return False
38 |
39 | @property
40 | def is_anonymous(self):
41 | return True
42 |
--------------------------------------------------------------------------------
/inspira/cli/__init__.py:
--------------------------------------------------------------------------------
1 | from .cli import cli
2 |
--------------------------------------------------------------------------------
/inspira/cli/cli.py:
--------------------------------------------------------------------------------
1 | import click
2 |
3 | from inspira.cli.create_app import generate_project
4 | from inspira.cli.create_controller import create_controller_file
5 | from inspira.cli.generate_database_file import create_database_file
6 | from inspira.cli.generate_model_file import generate_model_file
7 | from inspira.cli.generate_repository_file import generate_repository_file
8 | from inspira.cli.generate_service_file import generate_service_file
9 | from inspira.migrations.migrations import create_migrations, run_migrations
10 |
11 | DATABASE_TYPES = ["postgres", "mysql", "sqlite", "mssql"]
12 |
13 |
14 | @click.group()
15 | def cli():
16 | pass
17 |
18 |
19 | @cli.group()
20 | def new():
21 | pass
22 |
23 |
24 | @new.command()
25 | @click.argument("name")
26 | @click.option("--websocket", "is_websocket", is_flag=True, required=False)
27 | def controller(name, is_websocket):
28 | if not name:
29 | click.echo("Please provide a name for the controller")
30 | return
31 |
32 | try:
33 | create_controller_file(name, is_websocket)
34 | except FileExistsError:
35 | click.echo(f"Controller '{name}' already exists.")
36 |
37 |
38 | @new.command()
39 | @click.argument("name")
40 | def repository(name):
41 | if not name:
42 | click.echo("Please provide a name of the repository")
43 | return
44 |
45 | try:
46 | generate_repository_file(name)
47 | except FileExistsError:
48 | click.echo(f"Repository '{name}' already exists.")
49 |
50 |
51 | @new.command()
52 | @click.argument("name")
53 | def service(name):
54 | if not name:
55 | click.echo("Please provide a name of the service")
56 | return
57 |
58 | try:
59 | generate_service_file(name)
60 | except FileExistsError:
61 | click.echo(f"Service '{name}' already exists.")
62 |
63 |
64 | @new.command()
65 | @click.argument("name")
66 | def model(name):
67 | if not name:
68 | click.echo("Please provide a name for the model.")
69 | return
70 |
71 | try:
72 | generate_model_file(name)
73 | except FileExistsError:
74 | click.echo(f"Model '{name}' already exists.")
75 |
76 |
77 | @new.command()
78 | @click.option("--name", required=True, help="Name of the database.")
79 | @click.option("--type", required=True, help="Database type")
80 | def database(name, type):
81 | """
82 | Create a new database file with the given name and type.
83 |
84 | This command takes two required parameters:
85 |
86 | :param str name: Name of the database.
87 |
88 | :param str type: Type of the database.
89 |
90 | Example usage:
91 | ```
92 | inspira new database --name my_database --type sqlite
93 | ```
94 |
95 | This command will create a new database file named 'my_database' of type 'sqlite'.
96 | """
97 | create_database_file(name, type)
98 |
99 |
100 | @new.command()
101 | @click.argument("migration_name", required=True)
102 | def migration(migration_name):
103 | """
104 | Create migration
105 |
106 | :param migration_name: Name of the migration e.g. add_column_name_to_order.\n
107 | """
108 |
109 | try:
110 | create_migrations(migration_name)
111 | except click.UsageError as e:
112 | click.echo(f"Error: {e}")
113 | click.echo("Use 'migration --help' for usage information.")
114 |
115 |
116 | @cli.command()
117 | @click.option("--down", is_flag=True, help="Run Down migrations.")
118 | def migrate(down):
119 | """
120 | Run migrations from the migrations folder.
121 | """
122 | try:
123 | run_migrations(down=down)
124 | except Exception as e:
125 | click.echo(f"Error: {e}")
126 | click.echo("Migration failed. Check logs for more details.")
127 |
128 |
129 | @cli.command()
130 | @click.option("--only-controller", "only_controller", is_flag=True, required=False, help="Generates only controller module")
131 | def init(only_controller):
132 | generate_project(only_controller)
133 | click.echo("App file created successfully.")
134 |
135 |
136 | if __name__ == "__main__":
137 | cli()
138 |
--------------------------------------------------------------------------------
/inspira/cli/create_app.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 |
5 | from inspira.cli.init_file import create_init_file
6 | from inspira.constants import SRC_DIRECTORY, INIT_DOT_PY
7 | from inspira.utils import get_random_secret_key
8 |
9 |
10 | def generate_project(only_controller):
11 | create_app_file()
12 | create_directory_structure(only_controller)
13 | create_test_directory()
14 | click.echo("Project created successfully")
15 |
16 |
17 | def create_test_directory():
18 | test_directory = os.path.join("tests")
19 | os.makedirs(test_directory)
20 |
21 | create_init_file(test_directory)
22 |
23 |
24 | def create_directory_structure(only_controller):
25 | base_dir = SRC_DIRECTORY
26 | dirs = [
27 | base_dir,
28 | os.path.join(base_dir, INIT_DOT_PY),
29 | os.path.join(base_dir, "controller"),
30 | os.path.join(base_dir, "controller", INIT_DOT_PY),
31 | ]
32 |
33 | if not only_controller:
34 | subdirectories = ["model", "repository", "service"]
35 | for subdirectory in subdirectories:
36 | dirs.append(os.path.join(base_dir, subdirectory))
37 | dirs.append(os.path.join(base_dir, subdirectory, INIT_DOT_PY))
38 |
39 | for dir_path in dirs:
40 | if not os.path.exists(dir_path):
41 | if "." in dir_path:
42 | module_name = os.path.basename(os.path.dirname(dir_path))
43 | import_statement = ""
44 |
45 | if module_name != base_dir:
46 | import_statement = f"from src import {module_name}\n"
47 |
48 | with open(dir_path, "a") as init_file:
49 | init_file.write(import_statement)
50 | else:
51 | os.makedirs(dir_path)
52 |
53 |
54 | def create_app_file():
55 | template_path = os.path.join(
56 | os.path.dirname(__file__), "templates", "app_template.txt"
57 | )
58 | output_path = "main.py"
59 |
60 | with open(template_path, "r") as template_file, open(
61 | output_path, "w"
62 | ) as output_file:
63 | content = template_file.read().replace(
64 | "{{secret_key}}", get_random_secret_key()
65 | )
66 | output_file.write(content)
67 |
--------------------------------------------------------------------------------
/inspira/cli/create_controller.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 | from jinja2 import Template
5 |
6 | from inspira.cli.create_app import generate_project
7 | from inspira.cli.init_file import create_init_file
8 | from inspira.constants import SRC_DIRECTORY
9 | from inspira.utils import pluralize_word, singularize
10 |
11 |
12 | def create_src_directory():
13 | app_file_path = "main.py"
14 |
15 | if not os.path.exists(app_file_path):
16 | generate_project()
17 | if not os.path.exists(SRC_DIRECTORY):
18 | os.makedirs(SRC_DIRECTORY)
19 | create_init_file(SRC_DIRECTORY)
20 |
21 |
22 | def create_controller_file(name, is_websocket):
23 | controller_directory = os.path.join(SRC_DIRECTORY, "controller")
24 | singularize_name = singularize(name.lower())
25 | controller_file_name = f"{singularize_name}_controller.py"
26 | controller_template_file = "controller_template.jinja2"
27 |
28 | controller_file_path = os.path.join(controller_directory, controller_file_name)
29 |
30 | if os.path.exists(controller_file_path):
31 | click.echo(f"Controller '{controller_file_name}' already exists.")
32 | return
33 |
34 | if is_websocket:
35 | controller_template_file = "websocket_controller_template.jinja2"
36 |
37 | template_path = os.path.join(
38 | os.path.dirname(__file__), "templates", controller_template_file
39 | )
40 |
41 | with open(template_path, "r") as template_file, open(
42 | controller_file_path, "w"
43 | ) as output_file:
44 | template_content = template_file.read()
45 | template = Template(template_content)
46 |
47 | context = {
48 | "controller_name": singularize_name.capitalize(),
49 | "root_path": pluralize_word(name.lower()),
50 | }
51 |
52 | content = template.render(context)
53 | output_file.write(content)
54 |
55 | click.echo(f"Controller '{singularize_name}' created successfully.")
--------------------------------------------------------------------------------
/inspira/cli/generate_database_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 |
5 |
6 | def create_database_file(database_name, database_type):
7 | template_path = os.path.join(
8 | os.path.dirname(__file__), "templates", "database_template.txt"
9 | )
10 | output_path = "database.py"
11 |
12 | if database_type == "postgres":
13 | database_url = f"postgresql://USERNAME:PASSWORD@localhost:5432/{database_name}"
14 | elif database_type == "mysql":
15 | database_url = f"mysql://USERNAME:PASSWORD@localhost:3306/{database_name}"
16 | elif database_type == "sqlite":
17 | database_url = f"sqlite:///{database_name}.db"
18 | elif database_type == "mssql":
19 | # Assuming a Windows authentication connection
20 | database_url = (
21 | f"mssql+pyodbc://"
22 | f"@localhost/{database_name}?driver=ODBC+Driver+17+for+SQL+Server"
23 | )
24 |
25 | with open(template_path, "r") as template_file, open(
26 | output_path, "w"
27 | ) as output_file:
28 | template_content = template_file.read()
29 | template_content = template_content.replace("{{database_url}}", database_url)
30 | output_file.write(template_content)
31 |
32 | click.echo("Database file created successfully.")
33 |
--------------------------------------------------------------------------------
/inspira/cli/generate_model_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 | from jinja2 import Template
5 |
6 | from inspira.constants import SRC_DIRECTORY
7 | from inspira.utils import pluralize_word, singularize
8 |
9 |
10 | def generate_model_file(module_name):
11 | model_directory = os.path.join(SRC_DIRECTORY, "model")
12 | model_file_name = f"{singularize(module_name.lower())}.py"
13 | template_path = os.path.join(
14 | os.path.dirname(__file__), "templates", "model_template.jinja2"
15 | )
16 | model_file_path = os.path.join(model_directory, model_file_name)
17 |
18 | if os.path.exists(model_file_path):
19 | click.echo(f"Model '{model_file_name}' already exists.")
20 | return
21 |
22 | with open(template_path, "r") as template_file, open(
23 | model_file_path, "w"
24 | ) as output_file:
25 | template_content = template_file.read()
26 | template = Template(template_content)
27 |
28 | context = {
29 | "module_name_capitalize": singularize(module_name.capitalize()),
30 | "module_name_plural": pluralize_word(module_name),
31 | }
32 | content = template.render(context)
33 | output_file.write(content)
34 |
35 | click.echo(f"Model '{model_file_name}' created successfully.")
36 |
37 |
38 | def database_file_exists() -> bool:
39 | main_script_path = "database.py"
40 |
41 | if not os.path.isfile(main_script_path):
42 | click.echo("Main script (database.py) not found.")
43 | return False
44 |
45 | return True
46 |
--------------------------------------------------------------------------------
/inspira/cli/generate_repository_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 | from jinja2 import Template
5 |
6 | from inspira.constants import SRC_DIRECTORY
7 | from inspira.utils import singularize
8 |
9 |
10 | def generate_repository_file(module_name):
11 | repository_directory = os.path.join(SRC_DIRECTORY, "repository")
12 | repository_file_name = f"{singularize(module_name.lower())}_repository.py"
13 |
14 | template_path = os.path.join(
15 | os.path.dirname(__file__), "templates", "repository_template.jinja2"
16 | )
17 | repository_file_path = os.path.join(repository_directory, repository_file_name)
18 |
19 | if os.path.exists(repository_file_path):
20 | click.echo(f"Repository '{repository_file_name}' already exists.")
21 | return
22 |
23 | with open(template_path, "r") as template_file, open(
24 | repository_file_path, "w"
25 | ) as output_file:
26 | template_content = template_file.read()
27 | template = Template(template_content)
28 |
29 | context = {
30 | "model_name_upper": singularize(module_name.capitalize())
31 | }
32 |
33 | content = template.render(context)
34 | output_file.write(content)
35 |
36 | click.echo(f"Repository '{repository_file_name}' created successfully.")
37 |
--------------------------------------------------------------------------------
/inspira/cli/generate_service_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import click
4 | from jinja2 import Template
5 |
6 | from inspira.constants import SRC_DIRECTORY
7 | from inspira.utils import singularize
8 |
9 |
10 | def generate_service_file(module_name):
11 | service_directory = os.path.join(SRC_DIRECTORY, "service")
12 | service_file_name = f"{singularize(module_name.lower())}_service.py"
13 |
14 | template_path = os.path.join(
15 | os.path.dirname(__file__), "templates", "service_template.jinja2"
16 | )
17 | service_file_path = os.path.join(service_directory, service_file_name)
18 |
19 | if os.path.exists(service_file_path):
20 | click.echo(f"Service '{service_file_name}' already exists.")
21 | return
22 |
23 | with open(template_path, "r") as template_file, open(
24 | service_file_path, "w"
25 | ) as output_file:
26 | template_content = template_file.read()
27 | template = Template(template_content)
28 |
29 | context = {
30 | "model_name_upper": singularize(module_name.capitalize()),
31 | }
32 |
33 | content = template.render(context)
34 | output_file.write(content)
35 |
36 | click.echo(f"Service '{service_file_name}' created successfully.")
37 |
--------------------------------------------------------------------------------
/inspira/cli/init_file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def create_init_file(directory):
5 | init_file = os.path.join(directory, "__init__.py")
6 | with open(init_file, "w"):
7 | pass
8 |
--------------------------------------------------------------------------------
/inspira/cli/templates/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/cli/templates/__init__.py
--------------------------------------------------------------------------------
/inspira/cli/templates/app_template.txt:
--------------------------------------------------------------------------------
1 | from inspira import Inspira
2 |
3 |
4 | app = Inspira(secret_key="{{secret_key}}")
5 |
6 |
--------------------------------------------------------------------------------
/inspira/cli/templates/controller_template.jinja2:
--------------------------------------------------------------------------------
1 | from inspira.decorators.http_methods import get
2 | from inspira.decorators.path import path
3 | from inspira.responses import JsonResponse
4 | from inspira.requests import Request
5 |
6 |
7 | @path("/{{root_path}}")
8 | class {{controller_name}}Controller:
9 |
10 | @get()
11 | async def index(self, request: Request):
12 | context = {"variable": "value"}
13 |
14 | return JsonResponse(context)
15 |
--------------------------------------------------------------------------------
/inspira/cli/templates/database_template.txt:
--------------------------------------------------------------------------------
1 | from sqlalchemy import create_engine
2 | from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
3 | from sqlalchemy_utils import database_exists, create_database
4 |
5 |
6 | engine = create_engine("{{database_url}}")
7 |
8 | if not database_exists(engine.url):
9 | create_database(engine.url)
10 |
11 | db_session = scoped_session(
12 | sessionmaker(autocommit=False, autoflush=False, bind=engine)
13 | )
14 | Base = declarative_base()
15 | Base.query = db_session.query_property()
16 |
--------------------------------------------------------------------------------
/inspira/cli/templates/model_template.jinja2:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer
2 | from database import Base
3 |
4 |
5 | class {{module_name_capitalize}}(Base):
6 | __tablename__ = "{{module_name_plural}}"
7 | id = Column(Integer, primary_key=True)
8 |
--------------------------------------------------------------------------------
/inspira/cli/templates/repository_template.jinja2:
--------------------------------------------------------------------------------
1 | from database import db_session
2 |
3 |
4 | class {{model_name_upper}}Repository:
5 |
6 | pass
--------------------------------------------------------------------------------
/inspira/cli/templates/service_template.jinja2:
--------------------------------------------------------------------------------
1 | class {{model_name_upper}}Service:
2 | pass
3 |
--------------------------------------------------------------------------------
/inspira/cli/templates/websocket_controller_template.jinja2:
--------------------------------------------------------------------------------
1 | from inspira.decorators.websocket import websocket
2 | from inspira.websockets import WebSocket
3 | from inspira.logging import log
4 |
5 |
6 | @websocket("/{{root_path}}")
7 | class {{controller_name}}Controller:
8 |
9 | async def on_open(self, websocket: WebSocket):
10 | log.info("Inside On Open")
11 | await websocket.on_open()
12 |
13 | async def on_message(self, websocket: WebSocket, message):
14 | log.info(f"Inside On Message. Received message: {message}")
15 |
16 | # Modify the message before echoing back
17 | modified_message = f"Server response to: {message.get('text', '')}"
18 |
19 | await websocket.send_text(modified_message)
20 |
21 | async def on_close(self, websocket: WebSocket):
22 | log.info("Inside On Close")
23 | await websocket.on_close()
24 |
--------------------------------------------------------------------------------
/inspira/config.py:
--------------------------------------------------------------------------------
1 | def default_max_age():
2 | return 24 * 60 * 60 * 31
3 |
4 |
5 | class Config:
6 | def __init__(self):
7 | self.config_data = {
8 | "SESSION_COOKIE_NAME": "session",
9 | "SESSION_MAX_AGE": default_max_age(),
10 | "SESSION_COOKIE_DOMAIN": None,
11 | "SESSION_COOKIE_PATH": None,
12 | "SESSION_COOKIE_HTTPONLY": True,
13 | "SESSION_COOKIE_SECURE": True,
14 | "SESSION_COOKIE_SAMESITE": None,
15 | "TOKEN_EXPIRATION_TIME": 3600,
16 | "SECRET_KEY": "change_me",
17 | }
18 |
19 | def __getitem__(self, key):
20 | return self.config_data.get(key, None)
21 |
22 | def __setitem__(self, key, value):
23 | self.config_data[key] = value
24 |
--------------------------------------------------------------------------------
/inspira/constants.py:
--------------------------------------------------------------------------------
1 | UTF8 = "utf-8"
2 |
3 | APPLICATION_JSON = "application/json"
4 | TEXT_PLAIN = "text/plain"
5 | TEXT_HTML = "text/html"
6 |
7 | NOT_FOUND = "Not Found"
8 |
9 | WEBSOCKET_SEND_TYPE = "websocket.send"
10 | WEBSOCKET_ACCEPT_TYPE = "websocket.accept"
11 | WEBSOCKET_CLOSE_TYPE = "websocket.close"
12 | WEBSOCKET_RECEIVE_TYPE = "websocket.receive"
13 | WEBSOCKET_DISCONNECT_TYPE = "websocket.disconnect"
14 | WEBSOCKET_TYPE = "websocket"
15 |
16 | SRC_DIRECTORY = "src"
17 | MIGRATION_DIRECTORY = "migrations"
18 | INIT_DOT_PY = "__init__.py"
19 |
--------------------------------------------------------------------------------
/inspira/decorators/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/decorators/__init__.py
--------------------------------------------------------------------------------
/inspira/decorators/http_methods.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Type
2 |
3 | from inspira.enums import HttpMethod
4 |
5 |
6 | def get(path: str = "") -> Callable[[Type], Type]:
7 | def decorator(handler: Type) -> Type:
8 | handler.__method__ = HttpMethod.GET
9 | handler.__path__ = path
10 | handler.__is_handler__ = True
11 | return handler
12 |
13 | return decorator
14 |
15 |
16 | def post(path: str = "") -> Callable[[Type], Type]:
17 | def decorator(handler: Type) -> Type:
18 | handler.__method__ = HttpMethod.POST
19 | handler.__path__ = path
20 | handler.__is_handler__ = True
21 | return handler
22 |
23 | return decorator
24 |
25 |
26 | def put(path: str = "") -> Callable[[Type], Type]:
27 | def decorator(handler: Type) -> Type:
28 | handler.__method__ = HttpMethod.PUT
29 | handler.__path__ = path
30 | handler.__is_handler__ = True
31 | return handler
32 |
33 | return decorator
34 |
35 |
36 | def patch(path: str = "") -> Callable[[Type], Type]:
37 | def decorator(handler: Type) -> Type:
38 | handler.__method__ = HttpMethod.PATCH
39 | handler.__path__ = path
40 | handler.__is_handler__ = True
41 | return handler
42 |
43 | return decorator
44 |
45 |
46 | def delete(path: str = "") -> Callable[[Type], Type]:
47 | def decorator(handler: Type) -> Type:
48 | handler.__method__ = HttpMethod.DELETE
49 | handler.__path__ = path
50 | handler.__is_handler__ = True
51 | return handler
52 |
53 | return decorator
54 |
--------------------------------------------------------------------------------
/inspira/decorators/path.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Type
2 |
3 |
4 | def path(path: str) -> Callable[[Type], Type]:
5 | def decorator(cls: Type) -> Type:
6 | cls.__path__ = path
7 | cls.__is_controller__ = True
8 | return cls
9 |
10 | return decorator
11 |
--------------------------------------------------------------------------------
/inspira/decorators/websocket.py:
--------------------------------------------------------------------------------
1 | from inspira.websockets import WebSocketControllerRegistry
2 |
3 |
4 | def websocket(path: str):
5 | def decorator(cls):
6 | WebSocketControllerRegistry.register_controller(path, cls)
7 | return cls
8 |
9 | return decorator
10 |
--------------------------------------------------------------------------------
/inspira/enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class HttpMethod(Enum):
5 | GET = "GET"
6 | POST = "POST"
7 | DELETE = "DELETE"
8 | PUT = "PUT"
9 | PATCH = "PATCH"
10 | OPTIONS = "OPTIONS"
11 |
12 |
13 | SQLALCHEMY_TYPE_MAPPING = {
14 | "string": "String",
15 | "boolean": "Boolean",
16 | "enum": "Enum",
17 | "integer": "Integer",
18 | "numeric": "Numeric",
19 | "float": "Float",
20 | "date": "Date",
21 | "datetime": "DateTime",
22 | "time": "Time",
23 | "interval": "Interval",
24 | "text": "Text",
25 | "unicode": "Unicode",
26 | "unicodetext": "UnicodeText",
27 | "largebinary": "LargeBinary",
28 | "pickletype": "PickleType",
29 | "json": "JSON",
30 | }
31 |
--------------------------------------------------------------------------------
/inspira/globals.py:
--------------------------------------------------------------------------------
1 | global_app_instance = None
2 |
3 |
4 | def set_global_app(app, secret_key):
5 | global global_app_instance
6 | global_app_instance = app
7 | global_app_instance.secret_key = secret_key
8 |
9 |
10 | def get_global_app():
11 | return global_app_instance
12 |
--------------------------------------------------------------------------------
/inspira/helpers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/helpers/__init__.py
--------------------------------------------------------------------------------
/inspira/helpers/error_handlers.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Any, Callable, Dict
3 |
4 | from inspira.helpers.error_templates import (
5 | format_forbidden_exception,
6 | format_internal_server_error,
7 | format_method_not_allowed_exception,
8 | format_not_found_exception,
9 | format_unauthorized_exception,
10 | )
11 |
12 |
13 | async def handle_method_not_allowed(
14 | scope: Dict[str, Any], receive: Callable, send: Callable
15 | ) -> None:
16 | method_not_allowed_response = format_method_not_allowed_exception()
17 | await method_not_allowed_response(scope, receive, send)
18 |
19 |
20 | async def handle_not_found(
21 | scope: Dict[str, Any], receive: Callable, send: Callable
22 | ) -> None:
23 | not_found_response = format_not_found_exception()
24 | await not_found_response(scope, receive, send)
25 |
26 |
27 | async def handle_forbidden(
28 | scope: Dict[str, Any], receive: Callable, send: Callable
29 | ) -> None:
30 | forbidden_response = format_forbidden_exception()
31 | await forbidden_response(scope, receive, send)
32 |
33 |
34 | async def handle_unauthorized(
35 | scope: Dict[str, Any], receive: Callable, send: Callable
36 | ) -> None:
37 | unauthorized_response = format_unauthorized_exception()
38 | await unauthorized_response(scope, receive, send)
39 |
40 |
41 | async def handle_internal_server_error(
42 | scope: Dict[str, Any], receive: Callable, send: Callable
43 | ) -> None:
44 | internal_server_error = format_internal_server_error()
45 | await internal_server_error(scope, receive, send)
46 |
47 |
48 | async def default_error_handler(exc):
49 | logging.exception(exc)
50 | return format_internal_server_error()
51 |
--------------------------------------------------------------------------------
/inspira/helpers/error_templates.py:
--------------------------------------------------------------------------------
1 | from inspira.constants import TEXT_HTML
2 | from inspira.responses import HttpResponse
3 |
4 | template = """
5 |
6 |
7 |
{title}
8 |
42 |
43 |
44 |
45 |
{status_code}
46 |
{message}
47 |
48 |
49 |
50 |
51 | """
52 |
53 |
54 | def format_internal_server_error() -> HttpResponse:
55 | msg = template.format(
56 | title="Internal Server Error",
57 | message="Internal Server Error"
58 | "
We are currently trying to fix the problem.",
59 | status_code=500,
60 | )
61 | return HttpResponse(content=msg, status_code=500, content_type=TEXT_HTML)
62 |
63 |
64 | def format_not_found_exception() -> HttpResponse:
65 | msg = template.format(
66 | title="Not Found",
67 | message="Ooops!!! The page you are looking for is not found",
68 | status_code=404,
69 | )
70 | return HttpResponse(content=msg, status_code=404, content_type=TEXT_HTML)
71 |
72 |
73 | def format_forbidden_exception() -> HttpResponse:
74 | msg = template.format(
75 | title="Forbidden",
76 | message="Forbidden",
77 | status_code=403,
78 | )
79 | return HttpResponse(content=msg, status_code=403, content_type=TEXT_HTML)
80 |
81 |
82 | def format_unauthorized_exception() -> HttpResponse:
83 | msg = template.format(
84 | title="Unauthorized",
85 | message="Unauthorized",
86 | status_code=401,
87 | )
88 | return HttpResponse(content=msg, status_code=401, content_type=TEXT_HTML)
89 |
90 |
91 | def format_method_not_allowed_exception() -> HttpResponse:
92 | msg = template.format(
93 | title="Method Not Allowed",
94 | message="Method Not Allowed"
95 | "
The method is not allowed for the requested URL.",
96 | status_code=405,
97 | )
98 | return HttpResponse(content=msg, status_code=405, content_type=TEXT_HTML)
99 |
--------------------------------------------------------------------------------
/inspira/helpers/static_file_handler.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Dict
2 |
3 | from inspira.requests import Request
4 | from inspira.responses import TemplateResponse
5 |
6 |
7 | async def handle_static_files(
8 | scope: Dict[str, Any], receive: Callable, send: Callable, request: Request
9 | ) -> None:
10 | template_response = TemplateResponse(request, scope["path"])
11 | await template_response(scope, receive, send)
12 |
--------------------------------------------------------------------------------
/inspira/inspira.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import inspect
3 | import os
4 | import re
5 | import sys
6 | from typing import Any, Callable, Dict, List
7 |
8 | from inspira.config import Config
9 | from inspira.constants import SRC_DIRECTORY
10 | from inspira.enums import HttpMethod
11 | from inspira.globals import set_global_app
12 | from inspira.helpers.error_handlers import (
13 | default_error_handler,
14 | handle_method_not_allowed,
15 | handle_not_found,
16 | )
17 | from inspira.helpers.static_file_handler import handle_static_files
18 | from inspira.logging import log
19 | from inspira.requests import Request, RequestContext
20 | from inspira.utils.controller_parser import parse_controller_decorators
21 | from inspira.utils.dependency_resolver import resolve_dependencies_automatic
22 | from inspira.utils.handler_invoker import invoke_handler
23 | from inspira.utils.session_utils import get_or_create_session
24 | from inspira.websockets import handle_websocket
25 |
26 |
27 | class Inspira:
28 | def __init__(self, secret_key=None, config=None):
29 | self.config = config if config is not None else Config()
30 | self.secret_key = (
31 | secret_key if secret_key is not None else self.config["SECRET_KEY"]
32 | )
33 | set_global_app(self, self.secret_key)
34 |
35 | self.routes: Dict[str, Dict[str, Callable]] = {
36 | method.value: {} for method in HttpMethod
37 | }
38 | self.error_handler = default_error_handler
39 | self.middleware: List[Callable] = []
40 | self.discover_controllers()
41 |
42 | def add_middleware(self, middleware: Callable) -> Callable:
43 | self.middleware.append(middleware)
44 | return middleware
45 |
46 | def add_route(self, path: str, method: HttpMethod, handler: Callable) -> None:
47 | if path in self.routes[method.value]:
48 | raise AssertionError(
49 | f"Route with method '{method}' and path '{path}' already exists"
50 | )
51 |
52 | self.routes[method.value][path] = handler
53 |
54 | def discover_controllers(self) -> None:
55 | current_dir = os.getcwd()
56 | src_dir = os.path.join(current_dir, SRC_DIRECTORY)
57 |
58 | for root, _, files in os.walk(src_dir):
59 | for file_name in files:
60 | file_path = os.path.join(root, file_name)
61 | if self._is_controller_file(file_path):
62 | rel_path = os.path.relpath(file_path, src_dir)[:-3].replace(
63 | os.sep, "."
64 | )
65 | module_path = f"src.{rel_path}"
66 | self._add_routes(module_path)
67 |
68 | def _add_routes(self, file_path: str) -> None:
69 | try:
70 | module_name = self._file_path_to_module(file_path)
71 | src_directory = os.path.abspath(
72 | os.path.join(file_path, os.pardir, os.pardir)
73 | )
74 | sys.path.insert(0, src_directory)
75 | module = importlib.import_module(module_name)
76 | for name, obj in inspect.getmembers(module):
77 | if inspect.isclass(obj) and hasattr(obj, "__path__"):
78 | self._add_class_routes(obj)
79 | except ImportError as e:
80 | log.error(f"Error importing module {file_path}: {e}")
81 | finally:
82 | # Remove the 'src' directory from the Python path after importing
83 | sys.path.pop(0)
84 |
85 | def _add_class_routes(self, cls) -> None:
86 | if not hasattr(cls, "__path__"):
87 | return
88 |
89 | dependencies = resolve_dependencies_automatic(cls)
90 | instance = cls(*dependencies) if dependencies is not None else cls()
91 |
92 | path_prefix = getattr(cls, "__path__", "")
93 |
94 | for name, method in inspect.getmembers(instance, inspect.ismethod):
95 | if (
96 | hasattr(method, "__is_handler__")
97 | and hasattr(method, "__method__")
98 | and hasattr(method, "__path__")
99 | ):
100 | http_method = getattr(method, "__method__")
101 | route = getattr(method, "__path__")
102 | full_route = path_prefix + route
103 | self.add_route(full_route, http_method, method)
104 |
105 | def _file_path_to_module(self, file_path: str) -> str:
106 | rel_path = os.path.relpath(file_path, os.getcwd())
107 | return rel_path.replace(os.sep, ".")
108 |
109 | def _is_controller_file(self, file_path: str) -> bool:
110 | return file_path.endswith("_controller.py") and parse_controller_decorators(
111 | file_path
112 | )
113 |
114 | async def __call__(
115 | self, scope: Dict[str, Any], receive: Callable, send: Callable
116 | ) -> None:
117 | if scope["type"] == "websocket":
118 | await handle_websocket(scope, receive, send)
119 | elif scope["type"] == "http":
120 | await self.process_middlewares(scope, receive, send, self.handle_http)
121 |
122 | async def handle_http(
123 | self, scope: Dict[str, Any], receive: Callable, send: Callable
124 | ) -> None:
125 | request = RequestContext.get_request()
126 | await self.set_request_session(request)
127 |
128 | method = scope["method"]
129 | path = scope["path"]
130 |
131 | if path.startswith("/static"):
132 | await handle_static_files(scope, receive, send, request)
133 | elif path in self.routes[method]:
134 | await self.handle_route(method, path, receive, request, scope, send)
135 | else:
136 | # Check if the route is present but with a different method
137 | if any(path in methods for methods in self.routes.values()):
138 | await handle_method_not_allowed(scope, receive, send)
139 | else:
140 | await self.handle_dynamic_route(
141 | method, path, request, scope, receive, send
142 | )
143 |
144 | async def handle_dynamic_route(
145 | self,
146 | method: str,
147 | path: str,
148 | request: Request,
149 | scope: Dict[str, Any],
150 | receive: Callable,
151 | send: Callable,
152 | ):
153 | for route_path, handler in self.routes[method].items():
154 | if "{" in route_path and "}" in route_path:
155 | route_pattern = route_path.replace("{", "(?P<").replace("}", ">[^/]+)")
156 | match = re.fullmatch(route_pattern, path)
157 | if match:
158 | try:
159 | params = match.groupdict()
160 | response = await invoke_handler(handler, request, scope, params)
161 | await response(scope, receive, send)
162 | return
163 | except Exception as exc:
164 | error_response = await self.error_handler(exc)
165 | await error_response(scope, receive, send)
166 | # If no matching route is found, return a 404 response
167 | await handle_not_found(scope, receive, send)
168 |
169 | async def handle_route(
170 | self,
171 | method: str,
172 | path: str,
173 | receive: Callable,
174 | request: Request,
175 | scope: Dict[str, Any],
176 | send: Callable,
177 | ):
178 | try:
179 | handler = self.get_handler(method, path)
180 | response = await self.invoke_handler(handler, request, scope)
181 | await response(scope, receive, send)
182 | except Exception as exc:
183 | await self.handle_error(exc, scope, receive, send)
184 |
185 | def get_handler(self, method: str, path: str):
186 | return self.routes[method][path]
187 |
188 | async def invoke_handler(self, handler, request: Request, scope: Dict[str, Any]):
189 | return await invoke_handler(handler, request, scope)
190 |
191 | async def handle_error(self, exc, scope, receive, send):
192 | error_response = await self.error_handler(exc)
193 | await error_response(scope, receive, send)
194 |
195 | async def process_middlewares(
196 | self, scope: Dict[str, Any], receive: Callable, send: Callable, handler
197 | ):
198 | request = await self.create_request(receive, scope, send)
199 | RequestContext.set_request(request)
200 |
201 | for middleware in reversed(self.middleware):
202 | handler = await middleware(handler)
203 | response = await handler(scope, receive, send)
204 | return response
205 |
206 | async def create_request(
207 | self, receive: Callable, scope: Dict[str, Any], send: Callable
208 | ) -> Request:
209 | return Request(scope, receive, send)
210 |
211 | async def set_request_session(self, request: Request) -> None:
212 | session = get_or_create_session(request)
213 | request.session = session
214 |
--------------------------------------------------------------------------------
/inspira/logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | log = logging.getLogger("Inspira")
4 |
5 | handler = logging.StreamHandler()
6 | formatter = logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s")
7 | handler.setFormatter(formatter)
8 | log.addHandler(handler)
9 | log.setLevel(logging.INFO)
10 |
--------------------------------------------------------------------------------
/inspira/middlewares/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/middlewares/__init__.py
--------------------------------------------------------------------------------
/inspira/middlewares/cors.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Dict, List
2 |
3 | from inspira.helpers.error_handlers import handle_forbidden
4 | from inspira.requests import Request
5 |
6 |
7 | class CORSMiddleware:
8 | def __init__(
9 | self,
10 | allow_origins: List[str] = None,
11 | allow_credentials: bool = False,
12 | allow_methods: List[str] = None,
13 | allow_headers: List[str] = None,
14 | ):
15 | self.allow_origins = allow_origins or []
16 | self.allow_credentials = allow_credentials
17 | self.allow_methods = allow_methods or []
18 | self.allow_headers = allow_headers or []
19 |
20 | async def __call__(self, handler):
21 | async def middleware(scope: Dict[str, Any], receive: Callable, send: Callable):
22 | request = Request(scope, receive, send)
23 | origin = request.get_headers().get("origin")
24 |
25 | if scope["method"] == "OPTIONS":
26 | return await self.handle_options(scope, receive, send, origin)
27 |
28 | if origin is not None and not self.is_origin_allowed(origin):
29 | return await handle_forbidden(scope, receive, send)
30 |
31 | async def send_wrapper(message):
32 | if message["type"] == "http.response.start":
33 | headers = message.get("headers", [])
34 |
35 | if origin in self.allow_origins:
36 | headers.extend(self.get_cors_headers(origin))
37 |
38 | message["headers"] = headers
39 |
40 | await send(message)
41 |
42 | await handler(scope, receive, send_wrapper)
43 |
44 | return middleware
45 |
46 | def is_origin_allowed(self, origin):
47 | return origin is not None and (
48 | origin in self.allow_origins or "*" in self.allow_origins
49 | )
50 |
51 | def get_cors_headers(self, origin):
52 | return [
53 | (b"Access-Control-Allow-Origin", origin.encode()),
54 | (
55 | b"Access-Control-Allow-Credentials",
56 | str(self.allow_credentials).lower().encode(),
57 | ),
58 | (b"Access-Control-Allow-Methods", ",".join(self.allow_methods).encode()),
59 | (b"Access-Control-Allow-Headers", ",".join(self.allow_headers).encode()),
60 | ]
61 |
62 | async def handle_options(self, scope, receive, send, origin):
63 | headers = self.get_cors_headers(origin)
64 | headers.append(
65 | (
66 | b"Access-Control-Allow-Headers",
67 | ",".join(self.allow_headers + ["Content-Type"]).encode(),
68 | )
69 | )
70 | response_message = {
71 | "type": "http.response.start",
72 | "status": 200,
73 | "headers": headers,
74 | }
75 |
76 | await send(response_message)
77 | await send({"type": "http.response.body", "body": b""})
78 |
--------------------------------------------------------------------------------
/inspira/middlewares/sessions.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from typing import Any, Callable, Dict
4 |
5 | from inspira.globals import get_global_app
6 | from inspira.helpers.error_handlers import handle_forbidden
7 | from inspira.inspira import RequestContext
8 | from inspira.logging import log
9 | from inspira.utils.session_utils import (
10 | decode_session_data,
11 | encode_session_data,
12 | get_session_token_from_request,
13 | )
14 |
15 |
16 | class SessionMiddleware:
17 | def __init__(self):
18 | self.app = get_global_app()
19 |
20 | def build_set_cookie_header(self, session_data):
21 | encoded_payload = encode_session_data(session_data, self.app.secret_key)
22 | expires_date = datetime.datetime.utcnow() + datetime.timedelta(
23 | seconds=self.app.config["SESSION_MAX_AGE"]
24 | )
25 | formatted_expires = expires_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
26 |
27 | cookie_value = (
28 | f"{self.app.config['SESSION_COOKIE_NAME']}={encoded_payload}; "
29 | f"Expires={formatted_expires}; Path={self.app.config['SESSION_COOKIE_PATH'] or '/'}; HttpOnly"
30 | )
31 |
32 | if self.app.config["SESSION_COOKIE_DOMAIN"]:
33 | cookie_value += f"; Domain={self.app.config['SESSION_COOKIE_DOMAIN']}"
34 |
35 | if self.app.config["SESSION_COOKIE_SECURE"]:
36 | cookie_value += "; Secure"
37 |
38 | if self.app.config["SESSION_COOKIE_SAMESITE"]:
39 | cookie_value += f"; SameSite={self.app.config['SESSION_COOKIE_SAMESITE']}"
40 |
41 | return cookie_value
42 |
43 | async def __call__(self, handler):
44 | async def middleware(scope: Dict[str, Any], receive: Callable, send: Callable):
45 | async def send_wrapper(message):
46 | if message["type"] == "http.response.start":
47 | headers = message.get("headers", [])
48 | request = RequestContext().get_request()
49 | session_cookie = get_session_token_from_request(
50 | request, self.app.config["SESSION_COOKIE_NAME"]
51 | )
52 |
53 | decoded_session = {}
54 |
55 | if request.session and not session_cookie:
56 | request.set_session("session_id", str(uuid.uuid4()))
57 |
58 | if session_cookie:
59 | decoded_session = decode_session_data(
60 | session_cookie, self.app.secret_key
61 | )
62 |
63 | if decoded_session is None:
64 | log.error("Invalid session format.")
65 | return await handle_forbidden(scope, receive, send)
66 |
67 | if not request.session or decoded_session != request.session:
68 | if request.session:
69 | cookie_value = self.build_set_cookie_header(request.session)
70 |
71 | headers.append((b"Set-Cookie", cookie_value.encode()))
72 | else:
73 | cookie_value = f"{self.app.config['SESSION_COOKIE_NAME']}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path={self.app.config['SESSION_COOKIE_PATH'] or '/'}; HttpOnly"
74 |
75 | headers.append((b"Set-Cookie", cookie_value.encode()))
76 |
77 | message["headers"] = headers
78 |
79 | await send(message)
80 |
81 | await handler(scope, receive, send_wrapper)
82 |
83 | return middleware
84 |
--------------------------------------------------------------------------------
/inspira/middlewares/user_loader.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Dict
2 |
3 | from inspira.auth.auth_utils import decode_auth_token
4 | from inspira.auth.mixins.user_mixin import AnonymousUserMixin
5 | from inspira.globals import get_global_app
6 | from inspira.helpers.error_handlers import handle_forbidden
7 | from inspira.logging import log
8 | from inspira.requests import RequestContext
9 | from inspira.utils.session_utils import (
10 | decode_session_data,
11 | get_session_token_from_request,
12 | )
13 |
14 |
15 | class UserLoaderMiddleware:
16 | def __init__(self, user_model):
17 | self.user_model = user_model
18 | self.app = get_global_app()
19 |
20 | async def __call__(self, handler):
21 | async def middleware(scope: Dict[str, Any], receive: Callable, send: Callable):
22 | request = RequestContext().get_request()
23 | session_cookie = get_session_token_from_request(
24 | request, self.app.config["SESSION_COOKIE_NAME"]
25 | )
26 |
27 | user = None
28 |
29 | if session_cookie:
30 | decoded_session = decode_session_data(
31 | session_cookie, self.app.secret_key
32 | )
33 | if decoded_session is None:
34 | log.error("Invalid session format.")
35 | return await handle_forbidden(scope, receive, send)
36 | else:
37 | user_id = decode_auth_token(decoded_session.get("token", None))
38 |
39 | if user_id:
40 | user = self.user_model.query.get(user_id)
41 | RequestContext.set_current_user(user or AnonymousUserMixin())
42 | request.user = RequestContext.get_current_user()
43 |
44 | await handler(scope, receive, send)
45 |
46 | return middleware
47 |
--------------------------------------------------------------------------------
/inspira/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/inspira/migrations/__init__.py
--------------------------------------------------------------------------------
/inspira/migrations/migrations.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | import click
5 | from sqlalchemy import (
6 | Column,
7 | Integer,
8 | MetaData,
9 | String,
10 | create_engine,
11 | inspect,
12 | select,
13 | text,
14 | )
15 | from sqlalchemy.exc import SQLAlchemyError
16 | from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
17 | from sqlalchemy.sql.expression import func
18 |
19 | from inspira.constants import MIGRATION_DIRECTORY
20 | from inspira.logging import log
21 | from inspira.migrations.utils import (
22 | generate_migration_file,
23 | get_migration_files,
24 | get_or_create_migration_directory,
25 | migration_file_exist,
26 | )
27 |
28 | PROJECT_ROOT = os.path.abspath(".")
29 | sys.path.append(PROJECT_ROOT)
30 |
31 | try:
32 | from database import Base, db_session, engine
33 | except ImportError:
34 | Base = declarative_base()
35 | engine = create_engine("sqlite:///:memory:")
36 | db_session = scoped_session(
37 | sessionmaker(autocommit=False, autoflush=False, bind=engine)
38 | )
39 |
40 |
41 | class Migration(Base):
42 | __tablename__ = MIGRATION_DIRECTORY
43 | id = Column(Integer, primary_key=True)
44 | migration_name = Column(String(255))
45 | version = Column(Integer)
46 |
47 |
48 | def initialize_database(engine):
49 | Base.metadata.create_all(engine)
50 |
51 |
52 | def create_migrations(migration_name):
53 | if migration_file_exist(migration_name):
54 | return
55 |
56 | generate_migration_file(migration_name)
57 |
58 |
59 | def run_migrations(down=False):
60 | with engine.connect() as connection:
61 | if not engine.dialect.has_table(connection, MIGRATION_DIRECTORY):
62 | initialize_database(engine)
63 |
64 | migration_dir = get_or_create_migration_directory()
65 | migration_files = get_migration_files(migration_dir)
66 |
67 | for file in migration_files:
68 | migration_name = os.path.basename(file).replace(".sql", "")
69 |
70 | current_version = (
71 | connection.execute(
72 | select(func.max(Migration.version)).where(
73 | Migration.migration_name == migration_name
74 | )
75 | ).scalar()
76 | or 0
77 | )
78 |
79 | if not current_version and not down:
80 | execute_up_migration(connection, file, migration_name)
81 |
82 | insert_migration(current_version, migration_name)
83 | continue
84 |
85 | if down:
86 | execute_down_migration(connection, file, migration_name)
87 | remove_migration(migration_name)
88 |
89 |
90 | def execute_up_migration(connection, file, migration_name):
91 | with open(file, "r") as migration_file:
92 | sql = migration_file.read()
93 |
94 | up_sql_start = sql.find("-- Up")
95 | if up_sql_start != -1:
96 | up_sql_start += len("-- Up")
97 | up_sql_end = sql.find("-- Down") if "-- Down" in sql else None
98 | up_sql = sql[up_sql_start:up_sql_end].strip()
99 |
100 | execute_sql_file_contents(connection, up_sql)
101 |
102 | click.echo(f"Applying 'Up' migration for {migration_name}")
103 | else:
104 | click.echo(f"No 'Up' migration found in {migration_name}")
105 |
106 |
107 | def execute_down_migration(connection, file, migration_name):
108 | with open(file, "r") as migration_file:
109 | sql = migration_file.read()
110 |
111 | down_sql_start = sql.find("-- Down")
112 | if down_sql_start != -1:
113 | down_sql_start += len("-- Down")
114 | down_sql_end = sql.find("-- End Down") if "-- End Down" in sql else None
115 | down_sql = sql[down_sql_start:down_sql_end].strip()
116 |
117 | execute_sql_file_contents(connection, down_sql)
118 |
119 | click.echo(f"Applying 'Down' migration for {migration_name}")
120 | else:
121 | click.echo(f"No 'Down' migration found in {migration_name}")
122 |
123 |
124 | def execute_sql_file_contents(connection, sql_content):
125 | sql_statements = [
126 | statement.strip() for statement in sql_content.split(";") if statement.strip()
127 | ]
128 |
129 | try:
130 | for statement in sql_statements:
131 | connection.execute(text(statement))
132 | connection.commit()
133 | log.info("Migration run successfully.")
134 | except SQLAlchemyError as e:
135 | log.error("Error:", e)
136 | connection.rollback()
137 | log.info("Transaction rolled back.")
138 |
139 |
140 | def insert_migration(current_version, migration_name):
141 | migration = Migration()
142 | migration.version = current_version + 1
143 | migration.migration_name = migration_name
144 | db_session.add(migration)
145 | db_session.commit()
146 |
147 |
148 | def remove_migration(migration_name):
149 | migration = (
150 | db_session.query(Migration).filter_by(migration_name=migration_name).first()
151 | )
152 |
153 | if migration:
154 | try:
155 | db_session.delete(migration)
156 | db_session.commit()
157 | except Exception as e:
158 | db_session.rollback()
159 | log.error(f"Error deleting migration {migration_name}: {e}")
160 | else:
161 | log.error(f"Migration {migration_name} not found.")
162 |
--------------------------------------------------------------------------------
/inspira/migrations/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from inspira.cli.create_controller import create_init_file
4 | from inspira.constants import MIGRATION_DIRECTORY
5 | from inspira.logging import log
6 |
7 |
8 | def get_or_create_migration_directory():
9 | migration_directory = os.path.join(MIGRATION_DIRECTORY)
10 |
11 | os.makedirs(migration_directory, exist_ok=True)
12 |
13 | create_init_file(migration_directory)
14 |
15 | return migration_directory
16 |
17 |
18 | def migration_file_exist(migration_file_name: str) -> bool:
19 | migration_dir = get_or_create_migration_directory()
20 | migration_files = get_migration_files(migration_dir)
21 |
22 | migration_exists = any(
23 | migration_file_name in migration for migration in migration_files
24 | )
25 | if migration_exists:
26 | log.info(f"Migration {migration_file_name} already exists. Skipping...")
27 |
28 | return migration_exists
29 |
30 |
31 | def get_migration_files(migration_dir):
32 | migration_files = [
33 | os.path.join(migration_dir, f)
34 | for f in os.listdir(migration_dir)
35 | if f.endswith(".sql")
36 | and f.split("_")[0].isdigit()
37 | and os.path.isfile(os.path.join(migration_dir, f))
38 | ]
39 | migration_files.sort()
40 | return migration_files
41 |
42 |
43 | def get_latest_migration_number(migration_dir):
44 | migration_files = [f for f in os.listdir(migration_dir) if f.endswith(".sql")]
45 | if not migration_files:
46 | return 0
47 |
48 | latest_migration = max(int(f.split("_")[0]) for f in migration_files)
49 | return latest_migration
50 |
51 |
52 | def generate_migration_file(migration_name):
53 | migration_dir = get_or_create_migration_directory()
54 | latest_migration_number = get_latest_migration_number(migration_dir)
55 | new_migration_number = latest_migration_number + 1
56 | migration_file_path = os.path.join(
57 | migration_dir, f"{str(new_migration_number).zfill(4)}_{migration_name}.sql"
58 | )
59 |
60 | with open(migration_file_path, "w") as migration_file:
61 | migration_file.write(f"-- Up\n")
62 | migration_file.write("\n")
63 | migration_file.write("\n-- Down\n")
64 |
65 | log.info(
66 | f"Migration file '{str(new_migration_number).zfill(4)}_{migration_name}.sql' created."
67 | )
68 | return migration_file
69 |
--------------------------------------------------------------------------------
/inspira/requests.py:
--------------------------------------------------------------------------------
1 | import json
2 | import urllib.parse
3 | from typing import Any, Callable, Dict
4 |
5 | from inspira.constants import UTF8
6 |
7 |
8 | class RequestContext:
9 | _current_request = None
10 | _current_user = None
11 |
12 | @classmethod
13 | def set_request(cls, request):
14 | cls._current_request = request
15 |
16 | @classmethod
17 | def get_request(cls):
18 | return cls._current_request
19 |
20 | @classmethod
21 | def get_current_user(cls):
22 | return cls._current_user
23 |
24 | @classmethod
25 | def set_current_user(cls, user):
26 | cls._current_user = user
27 |
28 |
29 | class Request:
30 | def __init__(self, scope: Dict[str, Any], receive: Callable, send: Callable):
31 | self.scope = scope
32 | self.receive = receive
33 | self.send = send
34 | self._session = {}
35 | self._headers = {}
36 | self._forbidden = False
37 | self.user = None
38 |
39 | def is_forbidden(self):
40 | return self._forbidden
41 |
42 | def set_forbidden(self):
43 | self._forbidden = True
44 |
45 | @property
46 | def session(self):
47 | return self._session
48 |
49 | @session.setter
50 | def session(self, value):
51 | self._session = value
52 |
53 | def set_session(self, key, value):
54 | if not isinstance(self._session, dict):
55 | self._session = {}
56 |
57 | self._session[key] = value
58 |
59 | def get_session(self, key, default=None):
60 | if not isinstance(self._session, dict):
61 | return default
62 |
63 | return self._session.get(key, default)
64 |
65 | def remove_session(self, key, default=None):
66 | if not isinstance(self._session, dict):
67 | return default
68 |
69 | return self._session.pop(key, default)
70 |
71 | def get_headers(self):
72 | return dict(
73 | (key.decode(UTF8), value.decode(UTF8))
74 | for key, value in self.scope.get("headers", [])
75 | )
76 |
77 | def set_header(self, key, value):
78 | self._headers[key] = value
79 |
80 | def get_request_headers(self):
81 | return list(
82 | (key.encode(UTF8), value.encode(UTF8))
83 | for key, value in self._headers.items()
84 | )
85 |
86 | def cookies(self):
87 | cookie_header = self.get_headers().get("cookie", "")
88 | if cookie_header:
89 | try:
90 | return {
91 | key.strip(): value.strip()
92 | for key, value in [
93 | cookie.split("=", 1) for cookie in cookie_header.split(";")
94 | ]
95 | }
96 | except ValueError:
97 | return {}
98 | return {}
99 |
100 | async def json(self):
101 | body = await self._get_body()
102 | if body:
103 | return json.loads(body.decode(UTF8))
104 | return {}
105 |
106 | async def _get_boundary(self):
107 | content_type_header = self.get_headers().get("content-type", "")
108 | if "multipart/form-data" in content_type_header:
109 | parts = content_type_header.split(";")
110 | for part in parts:
111 | if "boundary" in part:
112 | _, boundary = part.strip().split("=")
113 | return boundary.strip('"')
114 | return None
115 |
116 | async def form(self):
117 | content_type_header = self.get_headers().get("content-type", "")
118 | if "application/x-www-form-urlencoded" in content_type_header:
119 | body = await self._get_body()
120 | form_data = urllib.parse.parse_qsl(body.decode(UTF8))
121 | return {key: value for key, value in form_data}
122 | elif "multipart/form-data" in content_type_header:
123 | # Handle multipart form data
124 | return await self._parse_multipart_form_data()
125 |
126 | return {}
127 |
128 | async def _get_body(self):
129 | body = b""
130 | more_body = True
131 | while more_body:
132 | message = await self.receive()
133 | body += message.get("body", b"")
134 | more_body = message.get("more_body", False)
135 | return body
136 |
137 | async def _parse_multipart_form_data(self):
138 | form_data = await self._get_body()
139 | boundary = await self._get_boundary()
140 |
141 | if not boundary or not form_data:
142 | return {}
143 |
144 | parts = form_data.split(b"--" + boundary.encode())
145 |
146 | form_data_dict = {}
147 | for part in parts:
148 | if not part.strip():
149 | continue
150 |
151 | headers_content = part.split(b"\r\n\r\n", 1)
152 | if len(headers_content) == 2:
153 | headers, content = headers_content
154 | content_lines = content.split(b"\r\n")
155 | # Filter out empty lines and join the rest
156 | content = b"\r\n".join(line for line in content_lines if line.strip())
157 | else:
158 | headers, content = headers_content[0], b""
159 |
160 | headers_lines = headers.decode(UTF8).split("\r\n")
161 |
162 | content_disposition = next(
163 | (
164 | line
165 | for line in headers_lines
166 | if line.startswith("Content-Disposition")
167 | ),
168 | None,
169 | )
170 |
171 | if not content_disposition:
172 | continue
173 |
174 | _, name = content_disposition.split(";")
175 | name = name.split("=")[1].strip('"')
176 |
177 | content_str = content.decode(UTF8)
178 |
179 | form_data_dict[name] = content_str.strip()
180 |
181 | return form_data_dict
182 |
--------------------------------------------------------------------------------
/inspira/responses.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | import mimetypes
4 | import os
5 | from http import HTTPStatus
6 |
7 | from jinja2 import Environment, FileSystemLoader
8 |
9 | from inspira.constants import APPLICATION_JSON, NOT_FOUND, TEXT_HTML, TEXT_PLAIN, UTF8
10 | from inspira.logging import log
11 | from inspira.requests import RequestContext
12 |
13 |
14 | class HttpResponse:
15 | def __init__(
16 | self,
17 | content=None,
18 | status_code=HTTPStatus.OK,
19 | content_type=TEXT_PLAIN,
20 | headers=None,
21 | ):
22 | self.content = content
23 | self.status_code = status_code
24 | self.content_type = content_type
25 | self.headers = dict(headers or {})
26 |
27 | def set_cookie(
28 | self,
29 | key,
30 | value,
31 | max_age=None,
32 | expires=None,
33 | path="/",
34 | domain=None,
35 | secure=False,
36 | httponly=False,
37 | samesite=None,
38 | ):
39 | cookie_str = f"{key}={value}"
40 |
41 | if max_age is not None:
42 | cookie_str += f"; Max-Age={max_age}"
43 | elif expires is not None and isinstance(expires, (int, float)):
44 | expires_dt = datetime.datetime.utcfromtimestamp(expires)
45 | expires_str = expires_dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
46 | cookie_str += f"; Expires={expires_str}"
47 |
48 | if path:
49 | cookie_str += f"; Path={path}"
50 | if domain:
51 | cookie_str += f"; Domain={domain}"
52 | if secure:
53 | cookie_str += "; Secure"
54 | if httponly:
55 | cookie_str += "; HttpOnly"
56 | if samesite:
57 | cookie_str += f"; SameSite={samesite}"
58 |
59 | # Append the cookie to the headers dictionary
60 | self.headers.setdefault("set-cookie", []).append(cookie_str)
61 |
62 | async def __call__(self, scope, receive, send):
63 | headers = await self.encoded_headers()
64 |
65 | await send(
66 | {
67 | "type": "http.response.start",
68 | "status": self.status_code,
69 | "headers": headers,
70 | }
71 | )
72 |
73 | body = await self.serialize_content()
74 |
75 | await send(
76 | {
77 | "type": "http.response.body",
78 | "body": body,
79 | "more_body": False,
80 | }
81 | )
82 |
83 | async def encoded_headers(self):
84 | request_headers = RequestContext().get_request().get_request_headers()
85 |
86 | headers = [(b"content-type", self.content_type.encode(UTF8))]
87 |
88 | headers.extend(request_headers)
89 |
90 | for key, value_list in self.headers.items():
91 | headers.extend(self.encode_header(key, value_list))
92 |
93 | return headers
94 |
95 | def encode_header(self, key, value_list):
96 | if not isinstance(value_list, list):
97 | value_list = [value_list]
98 |
99 | return [(key.encode(UTF8), self.encode_value(value)) for value in value_list]
100 |
101 | def encode_value(self, value):
102 | if isinstance(value, str):
103 | return value.encode(UTF8)
104 | return value
105 |
106 | async def serialize_content(self):
107 | if self.content is not None:
108 | if isinstance(self.content, bytes):
109 | body = self.content
110 | elif self.content_type == APPLICATION_JSON:
111 | body = json.dumps(self.content).encode(UTF8)
112 | else:
113 | body = str(self.content).encode(UTF8)
114 | else:
115 | body = b""
116 | return body
117 |
118 |
119 | class JsonResponse(HttpResponse):
120 | def __init__(self, content=None, status_code=HTTPStatus.OK, headers=None):
121 | super().__init__(content, status_code, APPLICATION_JSON, headers)
122 |
123 |
124 | class TemplateResponse(HttpResponse):
125 | def __init__(
126 | self,
127 | template_name=None,
128 | context=None,
129 | template_dir="templates",
130 | static_dir="static",
131 | ):
132 | super().__init__(None, HTTPStatus.OK, TEXT_HTML)
133 | self.template_name = template_name
134 | self.context = context or {}
135 | self.static_dir = static_dir
136 | self.template_dir = template_dir
137 |
138 | async def __call__(self, scope, receive, send):
139 | path_info = scope.get("path", "").lstrip("/") or scope.get(
140 | "raw_path", b""
141 | ).decode(UTF8).lstrip("/")
142 |
143 | if path_info.startswith("static/"):
144 | await self.handle_static_file(scope, receive, send)
145 | else:
146 | await self.render_template(scope, receive, send)
147 |
148 | async def render_template(self, scope, receive, send):
149 | if self.template_name is None:
150 | log.error("Template name is not provided.")
151 | not_found_response = JsonResponse(
152 | {"error": NOT_FOUND}, status_code=HTTPStatus.NOT_FOUND
153 | )
154 | await not_found_response(scope, receive, send)
155 | return
156 |
157 | template_path = os.path.join(self.template_dir, self.template_name)
158 |
159 | if not os.path.exists(template_path):
160 | log.error("Template not found:", template_path)
161 | not_found_response = JsonResponse(
162 | {"error": NOT_FOUND}, status_code=HTTPStatus.NOT_FOUND
163 | )
164 | await not_found_response(scope, receive, send)
165 |
166 | template_env = Environment(loader=FileSystemLoader(self.template_dir))
167 | template = template_env.get_template(self.template_name)
168 | content = template.render(**self.context)
169 |
170 | self.content = content.encode(UTF8)
171 | await super().__call__(scope, receive, send)
172 |
173 | async def handle_static_file(self, scope, receive, send):
174 | path_info = scope.get("path", "").lstrip("/") or scope.get(
175 | "raw_path", b""
176 | ).decode(UTF8).lstrip("/")
177 | static_prefix = "static/"
178 |
179 | if path_info.startswith(static_prefix):
180 | relative_path = path_info[len(static_prefix) :]
181 | file_path = os.path.join(self.static_dir, relative_path)
182 |
183 | if os.path.isfile(file_path):
184 | content_type, _ = mimetypes.guess_type(file_path)
185 | headers = [(b"content-type", content_type.encode(UTF8))]
186 |
187 | with open(file_path, "rb") as file:
188 | body = file.read()
189 |
190 | await send(
191 | {
192 | "type": "http.response.start",
193 | "status": HTTPStatus.OK,
194 | "headers": headers,
195 | }
196 | )
197 |
198 | await send(
199 | {
200 | "type": "http.response.body",
201 | "body": body,
202 | "more_body": False,
203 | }
204 | )
205 | else:
206 | log.error("File not found:", file_path)
207 |
208 | # Return a 404 response for non-existing static files
209 | not_found_response = JsonResponse(
210 | {"error": "Not Found"}, status_code=HTTPStatus.NOT_FOUND
211 | )
212 | await not_found_response(scope, receive, send)
213 | else:
214 | log.error("Unexpected path:", path_info)
215 |
216 | # Return a 404 response for unexpected paths
217 | not_found_response = JsonResponse(
218 | {"error": "Not Found"}, status_code=HTTPStatus.NOT_FOUND
219 | )
220 | await not_found_response(scope, receive, send)
221 |
222 |
223 | class HttpResponseRedirect(HttpResponse):
224 | def __init__(self, url: str, status_code=HTTPStatus.FOUND, headers=None):
225 | super().__init__(content=None, status_code=status_code, headers=headers or {})
226 | self.headers["Location"] = url
227 |
228 |
229 | class ForbiddenResponse(HttpResponse):
230 | def __init__(
231 | self,
232 | content=None,
233 | content_type=TEXT_PLAIN,
234 | status_code=HTTPStatus.FORBIDDEN,
235 | headers=None,
236 | ):
237 | super().__init__(content, status_code, content_type, headers)
238 |
--------------------------------------------------------------------------------
/inspira/testclient.py:
--------------------------------------------------------------------------------
1 | from httpx import AsyncClient
2 |
3 | from inspira.enums import HttpMethod
4 |
5 |
6 | class TestClient:
7 | __test__ = False
8 |
9 | def __init__(self, app):
10 | self.app = app
11 |
12 | async def request(self, method, path, **kwargs):
13 | async with AsyncClient(app=self.app, base_url="http://testserver") as client:
14 | return await getattr(client, method.lower())(path, **kwargs)
15 |
16 | async def get(self, path, **kwargs):
17 | return await self.request(HttpMethod.GET.value, path, **kwargs)
18 |
19 | async def post(self, path, **kwargs):
20 | return await self.request(HttpMethod.POST.value, path, **kwargs)
21 |
22 | async def put(self, path, **kwargs):
23 | return await self.request(HttpMethod.PUT.value, path, **kwargs)
24 |
25 | async def delete(self, path, **kwargs):
26 | return await self.request(HttpMethod.DELETE.value, path, **kwargs)
27 |
28 | async def patch(self, path, **kwargs):
29 | return await self.request(HttpMethod.PATCH.value, path, **kwargs)
30 |
31 | async def options(self, path, **kwargs):
32 | return await self.request(HttpMethod.OPTIONS.value, path, **kwargs)
33 |
--------------------------------------------------------------------------------
/inspira/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .dependency_resolver import resolve_dependencies_automatic, resolve_dependency
2 | from .inflection import pluralize_word, singularize
3 | from .naming import convert_to_camel_case, convert_to_snake_case
4 | from .secret_key import get_random_secret_key
5 |
--------------------------------------------------------------------------------
/inspira/utils/controller_parser.py:
--------------------------------------------------------------------------------
1 | import ast
2 |
3 |
4 | def parse_controller_decorators(file_path: str) -> bool:
5 | with open(file_path, "r") as file:
6 | tree = ast.parse(file.read(), filename=file_path)
7 |
8 | for node in ast.walk(tree):
9 | if isinstance(node, ast.ClassDef):
10 | for decorator in node.decorator_list:
11 | if (
12 | isinstance(decorator, ast.Call)
13 | and isinstance(decorator.func, ast.Name)
14 | and (
15 | decorator.func.id == "path" or decorator.func.id == "websocket"
16 | )
17 | ):
18 | return True
19 | return False
20 |
--------------------------------------------------------------------------------
/inspira/utils/dependency_resolver.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from typing import Any, Callable, List, Optional, Type
3 |
4 |
5 | def resolve_dependencies_automatic(cls: Type) -> Optional[List[Any]]:
6 | dependencies: List[Any] = []
7 |
8 | # Get the constructor parameters, if available
9 | init_method = getattr(cls, "__init__", None)
10 |
11 | if init_method and init_method != object.__init__:
12 | constructor_params = inspect.signature(init_method).parameters.values()
13 | for param in constructor_params:
14 | if param.name != "self": # Exclude 'self' parameter
15 | param_type = param.annotation
16 | dependency = resolve_dependency(param_type)
17 | dependencies.append(dependency)
18 |
19 | return dependencies if dependencies else None
20 |
21 |
22 | def resolve_dependency(dependency_type: Type[Callable]) -> Optional[Callable]:
23 | if dependency_type:
24 | init_method = getattr(dependency_type, "__init__", None)
25 | if init_method and init_method != object.__init__:
26 | constructor_params = inspect.signature(init_method).parameters.values()
27 | resolved_params = [
28 | resolve_dependency(param.annotation)
29 | for param in constructor_params
30 | if param.name != "self"
31 | ]
32 | return dependency_type(*resolved_params)
33 | else:
34 | return dependency_type()
35 | else:
36 | return None
37 |
--------------------------------------------------------------------------------
/inspira/utils/handler_invoker.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from typing import Any, Dict
3 |
4 | from inspira.requests import Request
5 | from inspira.utils.param_converter import convert_param_type
6 |
7 |
8 | async def invoke_handler(handler, request: Request, scope: Dict[str, Any], params=None):
9 | handler_signature = inspect.signature(handler)
10 | handler_params = {}
11 | for param_name, param in handler_signature.parameters.items():
12 | if param_name == "request":
13 | handler_params["request"] = request
14 | elif param_name == "scope":
15 | handler_params["scope"] = scope
16 | elif param_name in params:
17 | handler_params[param_name] = convert_param_type(
18 | params[param_name], param.annotation
19 | )
20 | elif param.default != inspect.Parameter.empty:
21 | handler_params[param_name] = param.default
22 | else:
23 | handler_params[param_name] = None
24 |
25 | return await handler(**handler_params)
26 |
--------------------------------------------------------------------------------
/inspira/utils/inflection.py:
--------------------------------------------------------------------------------
1 | from typing import Literal
2 |
3 | import inflect
4 |
5 | p = inflect.engine()
6 |
7 |
8 | def singularize(word: str) -> str | Literal[False]:
9 | return p.singular_noun(word) or word
10 |
11 |
12 | def pluralize_word(word: str) -> str:
13 | if word.endswith("s"):
14 | return word
15 | return p.plural(word) or word
16 |
--------------------------------------------------------------------------------
/inspira/utils/naming.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 |
4 | def convert_to_snake_case(input_string: str) -> str:
5 | snake_case_string = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", input_string)
6 | snake_case_string = snake_case_string.replace("-", "_")
7 | snake_case_string = snake_case_string.lower()
8 |
9 | return snake_case_string
10 |
11 |
12 | def convert_to_camel_case(input_string: str) -> str:
13 | space_separated = input_string.replace("_", " ").replace("-", " ")
14 | capitalized_words = "".join(word.capitalize() for word in space_separated.split())
15 | camel_case_string = capitalized_words[0] + capitalized_words[1:]
16 |
17 | return camel_case_string
18 |
--------------------------------------------------------------------------------
/inspira/utils/param_converter.py:
--------------------------------------------------------------------------------
1 | import inspect
2 |
3 |
4 | def convert_param_type(value, param_type):
5 | try:
6 | if param_type is None or param_type == inspect.Parameter.empty:
7 | return str(value)
8 | return param_type(value)
9 | except ValueError:
10 | return value
11 |
--------------------------------------------------------------------------------
/inspira/utils/secret_key.py:
--------------------------------------------------------------------------------
1 | import secrets
2 |
3 |
4 | def get_random_secret_key(length: int = 50) -> str:
5 | return secrets.token_urlsafe(length)[:length]
6 |
--------------------------------------------------------------------------------
/inspira/utils/session_utils.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | from http.cookies import SimpleCookie
4 |
5 | from itsdangerous import URLSafeTimedSerializer
6 |
7 | from inspira.globals import get_global_app
8 | from inspira.logging import log
9 |
10 |
11 | class DateTimeEncoder(json.JSONEncoder):
12 | def default(self, obj):
13 | if isinstance(obj, datetime.datetime):
14 | return obj.isoformat()
15 | return super().default(obj)
16 |
17 |
18 | def encode_session_data(session_data, secret_key):
19 | expiration_time = datetime.datetime.utcnow() + datetime.timedelta(
20 | seconds=get_global_app().config["SESSION_MAX_AGE"]
21 | )
22 |
23 | serializer = URLSafeTimedSerializer(secret_key)
24 |
25 | payload = {**session_data, "expiration_time": expiration_time}
26 |
27 | json_session_data = json.dumps(payload, cls=DateTimeEncoder)
28 |
29 | session_token = serializer.dumps(json_session_data)
30 |
31 | return session_token
32 |
33 |
34 | def decode_session_data(session_token, secret_key):
35 | try:
36 | serializer = URLSafeTimedSerializer(secret_key)
37 | json_session_data = serializer.loads(session_token)
38 | decoded_payload = json.loads(json_session_data)
39 | return decoded_payload
40 | except Exception as e:
41 | log.error(f"Error decoding session: {e}")
42 | return None
43 |
44 |
45 | def get_or_create_session(request):
46 | session_cookie = get_session_token_from_request(
47 | request, get_global_app().config["SESSION_COOKIE_NAME"]
48 | )
49 | secret_key = get_global_app().secret_key
50 |
51 | if session_cookie:
52 | try:
53 | session_data = decode_session_data(session_cookie, secret_key)
54 | return session_data
55 | except ValueError:
56 | log.error("Invalid signature when decoding session")
57 | raise ValueError("Invalid signature")
58 | else:
59 | log.error("No session in cookies")
60 |
61 |
62 | def get_session_token_from_request(request, session_cookie_name):
63 | cookies = SimpleCookie(request.get_headers().get("cookie", ""))
64 | token_cookie = cookies.get(session_cookie_name)
65 | return token_cookie.value if token_cookie else None
66 |
--------------------------------------------------------------------------------
/inspira/websockets/__init__.py:
--------------------------------------------------------------------------------
1 | from .websocket import WebSocket
2 | from .websocket_controller_registry import WebSocketControllerRegistry
3 | from .handle_websocket import handle_websocket
4 |
--------------------------------------------------------------------------------
/inspira/websockets/handle_websocket.py:
--------------------------------------------------------------------------------
1 | from inspira.constants import WEBSOCKET_DISCONNECT_TYPE, WEBSOCKET_RECEIVE_TYPE
2 | from inspira.logging import log
3 | from inspira.utils.dependency_resolver import resolve_dependencies_automatic
4 | from inspira.websockets import WebSocket, WebSocketControllerRegistry
5 |
6 |
7 | async def handle_websocket(scope, receive, send):
8 | path = scope["path"]
9 | controller_cls = WebSocketControllerRegistry.get_controller(path)
10 |
11 | if not controller_cls:
12 | log.info(f"No WebSocket controller registered for path: {path}")
13 | return
14 |
15 | websocket_cls = WebSocket(scope, receive, send)
16 |
17 | dependencies = resolve_dependencies_automatic(controller_cls)
18 | instance = (
19 | controller_cls(*dependencies) if dependencies is not None else controller_cls()
20 | )
21 |
22 | try:
23 | await instance.on_open(websocket_cls)
24 |
25 | while True:
26 | message = await websocket_cls.receive()
27 |
28 | if message["type"] == WEBSOCKET_DISCONNECT_TYPE:
29 | break
30 |
31 | if message["type"] == WEBSOCKET_RECEIVE_TYPE:
32 | await instance.on_message(websocket_cls, message)
33 |
34 | except Exception as e:
35 | log.info(f"WebSocket connection error: {e}")
36 | finally:
37 | await instance.on_close(websocket_cls)
38 |
--------------------------------------------------------------------------------
/inspira/websockets/websocket.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from inspira.constants import (
4 | WEBSOCKET_ACCEPT_TYPE,
5 | WEBSOCKET_CLOSE_TYPE,
6 | WEBSOCKET_SEND_TYPE,
7 | WEBSOCKET_TYPE,
8 | )
9 | from inspira.logging import log
10 |
11 |
12 | class WebSocket:
13 | def __init__(self, scope, receive, send):
14 | assert scope["type"] == WEBSOCKET_TYPE
15 | self.receive = receive
16 | self._send = send
17 |
18 | async def send_text(self, data: str) -> None:
19 | await self._send({"type": WEBSOCKET_SEND_TYPE, "text": data})
20 |
21 | async def send_json(self, data: dict) -> None:
22 | text_message = json.dumps(data, separators=(",", ":"))
23 | await self._send({"type": WEBSOCKET_SEND_TYPE, "text": text_message})
24 |
25 | async def send_binary(self, data: bytes) -> None:
26 | await self._send({"type": WEBSOCKET_SEND_TYPE, "bytes": data})
27 |
28 | async def on_open(self):
29 | await self._send({"type": WEBSOCKET_ACCEPT_TYPE})
30 |
31 | async def on_close(self):
32 | try:
33 | await self._send({"type": WEBSOCKET_CLOSE_TYPE})
34 | except Exception as e:
35 | log.error(f"Error sending close message: {e}")
36 |
--------------------------------------------------------------------------------
/inspira/websockets/websocket_controller_registry.py:
--------------------------------------------------------------------------------
1 | from typing import Type
2 |
3 |
4 | class WebSocketControllerRegistry:
5 | _controllers = {}
6 |
7 | @classmethod
8 | def register_controller(cls, path: str, controller_cls: Type):
9 | cls._controllers[path] = controller_cls
10 | return controller_cls
11 |
12 | @classmethod
13 | def get_controller(cls, path: str):
14 | return cls._controllers.get(path)
15 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "inspira"
3 | version = "0.17.0"
4 | description = "Inspira is a lightweight framework for building asynchronous web applications."
5 | readme = "README.md"
6 | authors = [{name = "Hayri Cicek", email = "hayri@inspiraframework.com"}]
7 | license = {file = "LICENSE"}
8 | classifiers = [
9 | "Development Status :: 2 - Pre-Alpha",
10 | "Environment :: Web Environment",
11 | "Intended Audience :: Developers",
12 | "Operating System :: OS Independent",
13 | "Programming Language :: Python :: 3 :: Only",
14 | "Programming Language :: Python :: 3.10",
15 | "Programming Language :: Python :: 3.11",
16 | "Programming Language :: Python :: 3.12",
17 | "Topic :: Internet :: WWW/HTTP",
18 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
19 | "License :: OSI Approved :: MIT License",
20 | "Topic :: Internet",
21 | "Topic :: Software Development :: Libraries :: Application Frameworks",
22 | "Topic :: Software Development :: Libraries :: Python Modules",
23 | "Topic :: Software Development :: Libraries",
24 | "Topic :: Software Development",
25 | ]
26 |
27 | requires-python = ">=3.8"
28 |
29 | dependencies = [
30 | "Jinja2>=3.1.2",
31 | "click>=8.1.7",
32 | "uvicorn",
33 | "httpx",
34 | "sqlalchemy",
35 | "bcrypt",
36 | "inflect",
37 | "sqlalchemy-utils",
38 | "websockets",
39 | "PyJWT",
40 | "itsdangerous",
41 | ]
42 |
43 | [project.scripts]
44 | inspira = "inspira.cli.cli:cli"
45 |
46 | [project.urls]
47 | Homepage = "https://inspiraframework.com"
48 | Repository = "https://github.com/cicekhayri/inspira"
49 |
50 | [build-system]
51 | requires = ["flit_core >=3.2,<4"]
52 | build-backend = "flit_core.buildapi"
53 |
54 |
55 | [tool.isort]
56 | profile = "black"
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | annotated-types==0.6.0
2 | anyio==4.1.0
3 | bcrypt==4.1.1
4 | certifi==2023.11.17
5 | click==8.1.7
6 | h11==0.14.0
7 | httpcore==1.0.2
8 | httpx==0.25.2
9 | idna==3.6
10 | inflect==7.0.0
11 | iniconfig==2.0.0
12 | itsdangerous==2.1.2
13 | Jinja2==3.1.3
14 | MarkupSafe==2.1.3
15 | packaging==23.2
16 | pluggy==1.3.0
17 | pydantic==2.5.2
18 | pydantic_core==2.14.5
19 | PyJWT==2.8.0
20 | pytest==7.4.3
21 | pytest-asyncio==0.23.2
22 | pytest-mock==3.12.0
23 | sniffio==1.3.0
24 | SQLAlchemy==2.0.25
25 | SQLAlchemy-Utils==0.41.1
26 | typing_extensions==4.9.0
27 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | from unittest.mock import AsyncMock, MagicMock
4 |
5 | import pytest
6 | from click.testing import CliRunner
7 |
8 | from inspira import Inspira
9 | from inspira.config import Config
10 | from inspira.constants import MIGRATION_DIRECTORY, SRC_DIRECTORY
11 | from inspira.requests import Request
12 | from inspira.testclient import TestClient
13 |
14 |
15 | @pytest.fixture
16 | def secret_key():
17 | return "your_secret_key"
18 |
19 |
20 | @pytest.fixture
21 | def app(secret_key):
22 | return Inspira(secret_key)
23 |
24 |
25 | @pytest.fixture
26 | def client(app):
27 | return TestClient(app)
28 |
29 |
30 | @pytest.fixture
31 | def client_session(app):
32 | return TestClient(app)
33 |
34 |
35 | @pytest.fixture
36 | def mock_connection():
37 | return MagicMock()
38 |
39 |
40 | @pytest.fixture
41 | def request_with_session(mock_scope):
42 | receive = AsyncMock()
43 | send = AsyncMock()
44 | request = Request(mock_scope, receive, send)
45 | request.session = {"user_id": 123, "username": "john_doe"}
46 | return request
47 |
48 |
49 | @pytest.fixture
50 | def teardown_src_directory():
51 | yield
52 | shutil.rmtree(SRC_DIRECTORY)
53 |
54 |
55 | @pytest.fixture
56 | def teardown_migration_directory():
57 | yield
58 | shutil.rmtree(MIGRATION_DIRECTORY)
59 |
60 |
61 | @pytest.fixture
62 | def teardown_main_file():
63 | yield
64 | os.remove("main.py")
65 |
66 |
67 | @pytest.fixture
68 | def teardown_database_file():
69 | yield
70 | os.remove("database.py")
71 |
72 |
73 | @pytest.fixture
74 | def create_migration_files(tmpdir):
75 | migration_files = [
76 | "0001_test.sql",
77 | "0002_another.sql",
78 | "not_a_migration.txt",
79 | "003_missing.sql",
80 | ]
81 |
82 | path = "tests/migrations"
83 | os.makedirs(path, exist_ok=True)
84 |
85 | for file_name in migration_files:
86 | file_path = os.path.join(path, file_name)
87 | with open(file_path, "w") as file:
88 | file.write("Sample content")
89 |
90 | return migration_files, path
91 |
92 |
93 | @pytest.fixture
94 | def mock_scope():
95 | return {
96 | "headers": [(b"content-type", b"application/json")],
97 | }
98 |
99 |
100 | @pytest.fixture
101 | def sample_config():
102 | return Config()
103 |
104 |
105 | @pytest.fixture
106 | def user_mock():
107 | class User:
108 | def __init__(self, id):
109 | self.id = id
110 |
111 | return User
112 |
113 |
114 | @pytest.fixture
115 | def setup_teardown_db_session():
116 | from inspira.migrations.migrations import db_session, engine, initialize_database
117 |
118 | initialize_database(engine)
119 | yield db_session
120 | db_session.rollback()
121 |
122 |
123 | @pytest.fixture
124 | def runner():
125 | return CliRunner()
126 |
--------------------------------------------------------------------------------
/tests/migrations/0001_test.sql:
--------------------------------------------------------------------------------
1 | Sample content
--------------------------------------------------------------------------------
/tests/migrations/0002_another.sql:
--------------------------------------------------------------------------------
1 | Sample content
--------------------------------------------------------------------------------
/tests/migrations/003_missing.sql:
--------------------------------------------------------------------------------
1 | Sample content
--------------------------------------------------------------------------------
/tests/migrations/not_a_migration.txt:
--------------------------------------------------------------------------------
1 | Sample content
--------------------------------------------------------------------------------
/tests/static/example.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cicekhayri/inspira/0935f14a97eda6ca0c6f01a10306835f17c36735/tests/static/example.css
--------------------------------------------------------------------------------
/tests/templates/example.html:
--------------------------------------------------------------------------------
1 | {{ name }}
--------------------------------------------------------------------------------
/tests/test_auth.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import AsyncMock
2 |
3 | import pytest
4 |
5 | from inspira.auth.auth_utils import (
6 | decode_auth_token,
7 | encode_auth_token,
8 | login_user,
9 | logout_user,
10 | )
11 | from inspira.auth.decorators import login_required
12 | from inspira.requests import Request, RequestContext
13 | from inspira.responses import HttpResponse
14 |
15 |
16 | def test_generate_and_decode_token(mock_scope):
17 | receive = AsyncMock()
18 | send = AsyncMock()
19 | request = Request(mock_scope, receive, send)
20 | RequestContext.set_request(request)
21 |
22 | user_id = 123
23 | token = encode_auth_token(user_id)
24 | assert token is not None
25 |
26 | decoded_user_id = decode_auth_token(token)
27 | assert decoded_user_id == user_id
28 |
29 |
30 | @pytest.mark.asyncio
31 | async def test_login_user(mock_scope):
32 | receive = AsyncMock()
33 | send = AsyncMock()
34 | request = Request(mock_scope, receive, send)
35 | RequestContext.set_request(request)
36 |
37 | user_id = 123
38 | login_user(user_id)
39 |
40 | assert "token" in request.session
41 |
42 | token = request.session.get("token")
43 | decoded_user_id = decode_auth_token(token)
44 |
45 | assert decoded_user_id == user_id
46 |
47 |
48 | @pytest.mark.asyncio
49 | async def test_logout_user(mock_scope):
50 | receive = AsyncMock()
51 | send = AsyncMock()
52 | request = Request(mock_scope, receive, send)
53 | RequestContext.set_request(request)
54 |
55 | request.set_session("token", "dummy_token")
56 |
57 | logout_user()
58 |
59 | assert "token" not in request.session
60 |
61 |
62 | @pytest.mark.asyncio
63 | async def test_invalid_token(mock_scope):
64 | receive = AsyncMock()
65 | send = AsyncMock()
66 | request = Request(mock_scope, receive, send)
67 | RequestContext.set_request(request)
68 |
69 | invalid_token = "invalid_token"
70 |
71 | decoded_user_id = decode_auth_token(invalid_token)
72 | assert decoded_user_id is None
73 |
74 |
75 | @pytest.mark.asyncio
76 | async def test_login_logout_integration(mock_scope):
77 | receive = AsyncMock()
78 | send = AsyncMock()
79 | request = Request(mock_scope, receive, send)
80 | RequestContext.set_request(request)
81 |
82 | user_id = 123
83 |
84 | login_user(user_id)
85 | assert "token" in request.session
86 |
87 | logout_user()
88 | assert "token" not in request.session
89 |
90 |
91 | @pytest.mark.asyncio
92 | async def test_login_required_decorator(mock_scope):
93 | receive = AsyncMock()
94 | send = AsyncMock()
95 | request = Request(mock_scope, receive, send)
96 | RequestContext.set_request(request)
97 |
98 | @login_required
99 | async def protected_route():
100 | return HttpResponse("OK")
101 |
102 | response = await protected_route()
103 | assert response.status_code == 401
104 |
105 | request.set_session("token", "invalid_token")
106 | response = await protected_route()
107 | assert response.status_code == 401
108 |
109 | user_id = 123
110 | login_user(user_id)
111 | response = await protected_route()
112 | assert response.status_code == 200
113 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | import os
2 | from unittest.mock import patch
3 |
4 | from click.testing import CliRunner
5 |
6 | from inspira.cli import cli
7 | from inspira.cli.cli import controller
8 | from inspira.cli.generate_model_file import database_file_exists, generate_model_file
9 |
10 |
11 | def test_database_command(teardown_database_file):
12 | runner = CliRunner()
13 | result = runner.invoke(
14 | cli, ["new", "database", "--name", "hello_db", "--type", "sqlite"]
15 | )
16 | assert result.exit_code == 0
17 | assert "Database file created successfully." in result.output
18 |
19 |
20 | def test_run_migrations_down(teardown_migration_directory):
21 | runner = CliRunner()
22 | result = runner.invoke(cli, ["migrate", "--down"])
23 | assert result.exit_code == 0
24 |
25 |
26 | def test_controller_command(runner, teardown_main_file, teardown_src_directory):
27 | runner.invoke(cli, ["init"])
28 | result = runner.invoke(cli, ["new", "controller", "order"])
29 |
30 | controller_file_path = os.path.join("src", "controller", "order_controller.py")
31 |
32 | assert os.path.isfile(controller_file_path)
33 | assert result.exit_code == 0
34 |
35 |
36 | def test_controller_creation_avoided_if_exists(
37 | runner, teardown_main_file, teardown_src_directory
38 | ):
39 | runner.invoke(cli, ["init"])
40 | result = runner.invoke(controller, ["order"])
41 |
42 | controller_file_path = os.path.join("src", "controller", "order_controller.py")
43 |
44 | assert os.path.isfile(controller_file_path)
45 | assert result.exit_code == 0
46 |
47 | result = runner.invoke(controller, ["order"])
48 | assert "Controller 'order_controller.py' already exists." in result.output
49 | assert result.exit_code == 0
50 |
51 |
52 | def test_controller_command_without_name(
53 | runner, teardown_main_file, teardown_src_directory
54 | ):
55 | runner.invoke(cli, ["init"])
56 | result = runner.invoke(cli, ["new", "controller"])
57 |
58 | assert "Error: Missing argument 'NAME'." in result.output
59 | assert result.exit_code == 2
60 |
61 |
62 | def test_repository_command(runner, teardown_main_file, teardown_src_directory):
63 | runner.invoke(cli, ["init"])
64 | result = runner.invoke(cli, ["new", "repository", "order"])
65 | repository_file_path = os.path.join("src", "repository", "order_repository.py")
66 |
67 | assert os.path.isfile(repository_file_path)
68 | assert result.exit_code == 0
69 |
70 |
71 | def test_repository_command_without_name(
72 | runner, teardown_main_file, teardown_src_directory
73 | ):
74 | runner.invoke(cli, ["init"])
75 | result = runner.invoke(cli, ["new", "repository"])
76 |
77 | assert "Error: Missing argument 'NAME'." in result.output
78 | assert result.exit_code == 2
79 |
80 |
81 | def test_service_command(runner, teardown_main_file, teardown_src_directory):
82 | runner.invoke(cli, ["init"])
83 | result = runner.invoke(cli, ["new", "service", "order"])
84 |
85 | service_file_path = os.path.join("src", "service", "order_service.py")
86 |
87 | assert os.path.isfile(service_file_path)
88 | assert result.exit_code == 0
89 |
90 |
91 | def test_service_command_without_name(
92 | runner, teardown_main_file, teardown_src_directory
93 | ):
94 | runner.invoke(cli, ["init"])
95 | result = runner.invoke(cli, ["new", "service"])
96 | assert "Error: Missing argument 'NAME'." in result.output
97 | assert result.exit_code == 2
98 |
99 |
100 | def test_database_file_exists_when_file_exists():
101 | with patch("os.path.isfile", return_value=True):
102 | assert database_file_exists() is True
103 |
104 |
105 | def test_database_file_exists_prints_error_message():
106 | with patch("click.echo") as mock_echo, patch(
107 | "os.path.isfile", return_value=False
108 | ):
109 | database_file_exists()
110 |
111 | mock_echo.assert_called_once_with("Main script (database.py) not found.")
112 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | from inspira.config import default_max_age
2 |
3 |
4 | def test_get_item(sample_config):
5 | assert sample_config["SESSION_COOKIE_NAME"] == "session"
6 | assert sample_config["INVALID_KEY"] is None
7 |
8 |
9 | def test_set_item(sample_config):
10 | sample_config["SESSION_COOKIE_NAME"] = "new_session"
11 | sample_config["NEW_KEY"] = "this_new_key"
12 |
13 | assert sample_config["SESSION_COOKIE_NAME"] == "new_session"
14 | assert sample_config["NEW_KEY"] == "this_new_key"
15 |
16 |
17 | def test_default_values(sample_config):
18 | assert sample_config["SESSION_MAX_AGE"] == 2678400
19 | assert sample_config["SESSION_COOKIE_DOMAIN"] is None
20 | assert sample_config["SESSION_COOKIE_PATH"] is None
21 | assert sample_config["SESSION_COOKIE_HTTPONLY"] is True
22 | assert sample_config["SESSION_COOKIE_SECURE"] is True
23 | assert sample_config["SESSION_COOKIE_SAMESITE"] is None
24 |
25 |
26 | def test_unknown_key_returns_none(sample_config):
27 | assert sample_config["UNKNOWN_KEY"] is None
28 |
29 |
30 | def test_default_max_age():
31 | expected_result = 24 * 60 * 60 * 31
32 | assert default_max_age() == expected_result
33 |
--------------------------------------------------------------------------------
/tests/test_dependency_resolver.py:
--------------------------------------------------------------------------------
1 | from inspira.utils import resolve_dependencies_automatic, resolve_dependency
2 |
3 |
4 | def test_resolve_dependencies_automatic_with_dependencies():
5 | class TestClass:
6 | def __init__(self, dep1, dep2):
7 | pass
8 |
9 | dependencies = resolve_dependencies_automatic(TestClass)
10 | assert len(dependencies) == 2
11 |
12 |
13 | def test_resolve_dependencies_automatic_without_dependencies():
14 | class TestClass:
15 | def __init__(self):
16 | pass
17 |
18 | dependencies = resolve_dependencies_automatic(TestClass)
19 | assert dependencies is None
20 |
21 |
22 | def test_resolve_dependencies():
23 | class TestClass:
24 | pass
25 |
26 | dependency_instance = resolve_dependency(TestClass)
27 | assert isinstance(dependency_instance, TestClass)
28 |
29 |
30 | def test_resolve_dependency_with_invalid_dependency():
31 | invalid_dependency_instance = resolve_dependency(None)
32 | assert invalid_dependency_instance is None
33 |
--------------------------------------------------------------------------------
/tests/test_globals.py:
--------------------------------------------------------------------------------
1 | from inspira import Inspira
2 |
3 |
4 | def test_set_global_app():
5 | from inspira.globals import get_global_app, set_global_app
6 |
7 | app_instance = Inspira()
8 | set_global_app(app_instance, secret_key="dummy")
9 |
10 | assert get_global_app() == app_instance
11 |
--------------------------------------------------------------------------------
/tests/test_helpers.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import mock_open, patch
2 |
3 | from inspira.constants import TEXT_HTML
4 | from inspira.helpers.error_templates import (
5 | format_internal_server_error,
6 | format_method_not_allowed_exception,
7 | format_not_found_exception,
8 | )
9 | from inspira.utils.controller_parser import parse_controller_decorators
10 |
11 |
12 | def test_format_server_exception():
13 | response = format_internal_server_error()
14 | assert response.content_type == TEXT_HTML
15 | assert response.status_code == 500
16 | assert "Internal Server Error" in response.content
17 |
18 |
19 | def test_format_not_found_exception():
20 | response = format_not_found_exception()
21 | assert response.content_type == TEXT_HTML
22 | assert response.status_code == 404
23 | assert "Ooops!!! The page you are looking for is not found" in response.content
24 |
25 |
26 | def test_format_method_not_allowed_exception():
27 | response = format_method_not_allowed_exception()
28 | assert response.content_type == TEXT_HTML
29 | assert response.status_code == 405
30 | assert "Method Not Allowed" in response.content
31 | assert "The method is not allowed for the requested URL." in response.content
32 |
33 |
34 | def test_parse_controller_decorators_with_path_decorator():
35 | code = """
36 | @path("/example")
37 | class MyController:
38 | pass
39 | """
40 | with patch("builtins.open", mock_open(read_data=code)):
41 | result = parse_controller_decorators("fake_file_path")
42 | assert result is True
43 |
44 |
45 | def test_parse_controller_decorators_with_websocket_decorator():
46 | code = """
47 | @websocket("/example")
48 | class MyController:
49 | pass
50 | """
51 | with patch("builtins.open", mock_open(read_data=code)):
52 | result = parse_controller_decorators("fake_file_path")
53 | assert result is True
54 |
55 |
56 | def test_parse_controller_decorators_without_matching_decorators():
57 | code = """
58 | class MyController:
59 | pass
60 | """
61 | with patch("builtins.open", mock_open(read_data=code)):
62 | result = parse_controller_decorators("fake_file_path")
63 | assert result is False
64 |
65 |
66 | def test_parse_controller_decorators_with_invalid_code():
67 | code = "invalid_python_code"
68 | with patch("builtins.open", mock_open(read_data=code)):
69 | result = parse_controller_decorators("fake_file_path")
70 | assert result is False
71 |
--------------------------------------------------------------------------------
/tests/test_inspira.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import os
3 | from http import HTTPStatus
4 | from unittest.mock import Mock
5 |
6 | import pytest
7 |
8 | from inspira.decorators.http_methods import get
9 | from inspira.enums import HttpMethod
10 | from inspira.responses import JsonResponse
11 | from inspira.utils.param_converter import convert_param_type
12 |
13 |
14 | @pytest.mark.asyncio
15 | async def test_add_route(app, client):
16 | @get("/example")
17 | async def example_handler(request):
18 | return JsonResponse({"message": "Example response"})
19 |
20 | app.add_route("/example", HttpMethod.GET, example_handler)
21 |
22 | response = await client.get("/example")
23 |
24 | assert response.status_code == HTTPStatus.OK
25 | assert response.json() == {"message": "Example response"}
26 |
27 |
28 | def test_discover_controllers(app, monkeypatch):
29 | monkeypatch.setattr(os, "getcwd", Mock(return_value="/path/to"))
30 | monkeypatch.setattr(os.path, "join", lambda *args: "/".join(args))
31 | monkeypatch.setattr(
32 | os, "walk", lambda path: [("/path/to/src", [], ["controller_file.py"])]
33 | )
34 | monkeypatch.setattr(os.path, "relpath", lambda *args: "controller_file.py")
35 |
36 | app._is_controller_file = Mock(return_value=True)
37 | app._add_routes = Mock()
38 |
39 | app.discover_controllers()
40 |
41 | app._is_controller_file.assert_called_once_with("/path/to/src/controller_file.py")
42 | app._add_routes.assert_called_once_with("src.controller_file")
43 |
44 |
45 | def test_convert_param_type_with_valid_type(app):
46 | result = convert_param_type("10", int)
47 | assert result == 10, "Expected the value to be converted to int"
48 |
49 |
50 | def test_convert_param_type_with_none_type(app):
51 | result = convert_param_type("10", None)
52 | assert result == "10", "Expected the value to be converted to str"
53 |
54 |
55 | def test_convert_param_type_with_empty_type(app):
56 | result = convert_param_type("10", inspect.Parameter.empty)
57 | assert result == "10", "Expected the value to be converted to str"
58 |
--------------------------------------------------------------------------------
/tests/test_logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from inspira.logging import handler, log
4 |
5 |
6 | def test_logging():
7 | assert log.name == "Inspira"
8 | assert log.level == logging.INFO
9 | assert log.handlers == [handler]
10 |
11 |
12 | def test_logger_error():
13 | log.level = logging.ERROR
14 | assert log.level == logging.ERROR
15 | assert log.handlers == [handler]
16 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | from http import HTTPStatus
2 | from http.cookies import SimpleCookie
3 |
4 | import pytest
5 |
6 | from inspira.auth.auth_utils import decode_auth_token, login_user
7 | from inspira.auth.decorators import login_required
8 | from inspira.auth.mixins.user_mixin import AnonymousUserMixin
9 | from inspira.decorators.http_methods import get
10 | from inspira.enums import HttpMethod
11 | from inspira.logging import log
12 | from inspira.middlewares.cors import CORSMiddleware
13 | from inspira.middlewares.sessions import SessionMiddleware
14 | from inspira.middlewares.user_loader import UserLoaderMiddleware
15 | from inspira.requests import Request, RequestContext
16 | from inspira.responses import HttpResponse, JsonResponse
17 | from inspira.utils.session_utils import decode_session_data
18 |
19 |
20 | @pytest.mark.asyncio
21 | async def test_cors_middleware(app, client):
22 | origins = ["http://localhost:8000"]
23 |
24 | cors_middleware = CORSMiddleware(
25 | allow_origins=origins,
26 | allow_credentials=True,
27 | allow_methods=["*"],
28 | allow_headers=["*"],
29 | )
30 |
31 | app.add_middleware(cors_middleware)
32 |
33 | @get("/test")
34 | async def test_route(request: Request):
35 | return JsonResponse({"message": "hello"})
36 |
37 | app.add_route("/test", HttpMethod.GET, test_route)
38 |
39 | response_allowed = await client.get(
40 | "/test", headers={"Origin": "http://localhost:8000"}
41 | )
42 |
43 | assert (
44 | response_allowed.headers["Access-Control-Allow-Origin"]
45 | == "http://localhost:8000"
46 | )
47 | assert response_allowed.status_code == HTTPStatus.OK
48 |
49 |
50 | @pytest.mark.asyncio
51 | async def test_cors_middleware_origin(app, client):
52 | origins = ["http://localhost:8000"]
53 |
54 | cors_middleware = CORSMiddleware(
55 | allow_origins=origins,
56 | allow_credentials=True,
57 | allow_methods=["*"],
58 | allow_headers=["*"],
59 | )
60 |
61 | app.add_middleware(cors_middleware)
62 |
63 | @get("/test")
64 | async def test_route(request: Request):
65 | return HttpResponse("hello")
66 |
67 | app.add_route("/test", HttpMethod.GET, test_route)
68 |
69 | not_response_allowed = await client.get(
70 | "/test", headers={"Origin": "http://localhost:3000"}
71 | )
72 |
73 | assert not_response_allowed.status_code == HTTPStatus.FORBIDDEN
74 |
75 |
76 | @pytest.mark.asyncio
77 | async def test_cors_middleware_without_origin_header(app, client):
78 | origins = ["http://localhost:8000"]
79 |
80 | cors_middleware = CORSMiddleware(
81 | allow_origins=origins,
82 | allow_credentials=True,
83 | allow_methods=["*"],
84 | allow_headers=["*"],
85 | )
86 |
87 | app.add_middleware(cors_middleware)
88 |
89 | @get("/test")
90 | async def test_route(request: Request):
91 | return HttpResponse("hello")
92 |
93 | app.add_route("/test", HttpMethod.GET, test_route)
94 |
95 | not_response_allowed = await client.get("/test")
96 |
97 | assert not_response_allowed.status_code == HTTPStatus.OK
98 |
99 |
100 | @pytest.mark.asyncio
101 | async def test_set_session_success(app, secret_key, client):
102 | session_middleware = SessionMiddleware()
103 |
104 | app.add_middleware(session_middleware)
105 |
106 | @get("/test")
107 | async def test_route(request: Request):
108 | request.set_session("message", "Hej")
109 | return HttpResponse("hello")
110 |
111 | app.add_route("/test", HttpMethod.GET, test_route)
112 |
113 | response = await client.get("/test")
114 | set_cookie_header = response.headers.get("set-cookie", "")
115 |
116 | cookies = SimpleCookie(set_cookie_header)
117 | session_cookie = cookies.get("session")
118 |
119 | assert session_cookie is not None
120 |
121 | decoded_session = decode_session_data(session_cookie.value, secret_key)
122 | expected_session = {"message": "Hej"}
123 |
124 | expiration_time = decoded_session.pop("expiration_time", None)
125 | session_id = decoded_session.pop("session_id", None)
126 |
127 | assert decoded_session == expected_session
128 | assert response.status_code == HTTPStatus.OK
129 | assert expiration_time is not None
130 | assert session_id is not None
131 |
132 |
133 | @pytest.mark.asyncio
134 | async def test_invalid_signature_exception(app, client):
135 | session_middleware = SessionMiddleware()
136 |
137 | app.add_middleware(session_middleware)
138 |
139 | @get("/test")
140 | async def test_route(request: Request):
141 | request.set_session("message", "Hej")
142 | return HttpResponse("hello")
143 |
144 | app.add_route("/test", HttpMethod.GET, test_route)
145 |
146 | response = await client.get("/test")
147 | set_cookie_header = response.headers.get("set-cookie", "")
148 |
149 | cookies = SimpleCookie(set_cookie_header)
150 | session_cookie = cookies.get("session")
151 |
152 | assert session_cookie is not None
153 |
154 | decoded_session = decode_session_data(session_cookie.value, "wrong_token")
155 |
156 | assert decoded_session is None
157 | assert response.status_code == HTTPStatus.OK
158 |
159 |
160 | @pytest.mark.asyncio
161 | async def test_remove_session_success(app, client):
162 | session_middleware = SessionMiddleware()
163 |
164 | app.add_middleware(session_middleware)
165 |
166 | @get("/test")
167 | async def test_route(request: Request):
168 | request.set_session("message", "Hej")
169 | return HttpResponse("hello")
170 |
171 | @get("/remove")
172 | async def remove_route(request: Request):
173 | request.remove_session("message")
174 | return HttpResponse("hello")
175 |
176 | app.add_route("/test", HttpMethod.GET, test_route)
177 |
178 | response1 = await client.get("/test")
179 |
180 | assert "set-cookie" in response1.headers
181 |
182 | app.add_route("/remove", HttpMethod.GET, remove_route)
183 | response2 = await client.get("/remove")
184 |
185 | assert "set-cookie" in response2.headers
186 | set_cookie_header = response2.headers.get("set-cookie", "")
187 |
188 | cookies = SimpleCookie(set_cookie_header)
189 | session_cookie = cookies.get("session")
190 |
191 | assert session_cookie.value == ""
192 | assert response2.status_code == HTTPStatus.OK
193 |
194 |
195 | @pytest.mark.asyncio
196 | async def test_get_session_success(app, client):
197 | session_middleware = SessionMiddleware()
198 |
199 | app.add_middleware(session_middleware)
200 |
201 | @get("/get")
202 | async def get_route(request: Request):
203 | request.set_session("user_id", 123)
204 | user_id = request.get_session("user_id")
205 | return HttpResponse(f"User ID: {user_id}")
206 |
207 | app.add_route("/get", HttpMethod.GET, get_route)
208 |
209 | response = await client.get("/get")
210 |
211 | assert response.content == b"User ID: 123"
212 |
213 |
214 | @pytest.mark.asyncio
215 | async def test_remove_nonexistent_key(app, client):
216 | session_middleware = SessionMiddleware()
217 |
218 | app.add_middleware(session_middleware)
219 |
220 | @get("/remove")
221 | async def remove_route(request: Request):
222 | log.info(f"Session before removal: {request.session}")
223 |
224 | request.remove_session("nonexistent_key")
225 | return HttpResponse("Session key removed successfully")
226 |
227 | app.add_route("/remove", HttpMethod.GET, remove_route)
228 |
229 | response = await client.get("/remove")
230 | set_cookie_header = response.headers.get("set-cookie", "")
231 |
232 | cookies = SimpleCookie(set_cookie_header)
233 | session_cookie = cookies.get("session")
234 |
235 | assert session_cookie.value == ""
236 | assert response.status_code == HTTPStatus.OK
237 |
238 |
239 | @pytest.mark.asyncio
240 | async def test_user_loader_middleware(app, client, user_mock, secret_key):
241 | session_middleware = SessionMiddleware()
242 |
243 | user_loader_middleware = UserLoaderMiddleware(user_mock)
244 |
245 | app.add_middleware(session_middleware)
246 | app.add_middleware(user_loader_middleware)
247 |
248 | @get("/get")
249 | async def get_route(request: Request):
250 | user = user_mock(id=1)
251 | login_user(user.id)
252 | return HttpResponse(f"User ID: {user.id}")
253 |
254 | app.add_route("/get", HttpMethod.GET, get_route)
255 |
256 | response = await client.get("/get")
257 | set_cookie_header = response.headers.get("set-cookie", "")
258 |
259 | assert set_cookie_header is not None
260 | assert "session=" in set_cookie_header
261 |
262 | cookies = SimpleCookie(set_cookie_header)
263 | session_cookie = cookies.get("session")
264 |
265 | assert session_cookie is not None
266 |
267 | decoded_session = decode_session_data(session_cookie.value, secret_key)
268 | get_user_id = decode_auth_token(decoded_session["token"])
269 |
270 | assert get_user_id == 1
271 |
272 |
273 | @pytest.mark.asyncio
274 | async def test_user_not_logged_in(app, client, secret_key, user_mock):
275 | session_middleware = SessionMiddleware()
276 | user_loader_middleware = UserLoaderMiddleware(user_mock)
277 | app.add_middleware(session_middleware)
278 | app.add_middleware(user_loader_middleware)
279 |
280 | @get("/protected")
281 | @login_required
282 | async def protected(request: Request):
283 | return HttpResponse("Protected Route")
284 |
285 | app.add_route("/protected", HttpMethod.GET, protected)
286 |
287 | response = await client.get("/protected")
288 |
289 | assert response.status_code == HTTPStatus.UNAUTHORIZED.value
290 | assert "Unauthorized" in response.text
291 |
292 |
293 | @pytest.mark.asyncio
294 | async def test_user_loader_middleware_anonymous_user(
295 | app, client, secret_key, user_mock
296 | ):
297 | user_loader_middleware = UserLoaderMiddleware(user_mock)
298 | app.add_middleware(user_loader_middleware)
299 |
300 | @get("/protected")
301 | async def protected(request: Request):
302 | user_authenticated = request.user.is_authenticated
303 | return JsonResponse({"user_authenticated": user_authenticated})
304 |
305 | app.add_route("/protected", HttpMethod.GET, protected)
306 |
307 | response = await client.get("/protected")
308 |
309 | assert response.status_code == 200
310 | assert response.json() == {"user_authenticated": False}
311 |
312 | user_in_method = RequestContext.get_current_user()
313 | assert isinstance(user_in_method, AnonymousUserMixin)
314 |
--------------------------------------------------------------------------------
/tests/test_migrations.py:
--------------------------------------------------------------------------------
1 | import os
2 | from unittest.mock import patch, mock_open
3 |
4 | from inspira.cli.cli import migrate
5 | from inspira.constants import MIGRATION_DIRECTORY
6 | from inspira.migrations.migrations import (
7 | Migration,
8 | db_session,
9 | execute_down_migration,
10 | execute_up_migration,
11 | insert_migration,
12 | run_migrations,
13 | )
14 | from inspira.migrations.utils import (
15 | get_latest_migration_number,
16 | get_migration_files,
17 | get_or_create_migration_directory,
18 | migration_file_exist,
19 | generate_migration_file,
20 | )
21 |
22 |
23 | def test_execute_up_migration(capsys, mock_connection, tmp_path):
24 | migration_name = "test_migration"
25 | migration_content = "-- Up\nCREATE TABLE test_table (id SERIAL PRIMARY KEY);\n"
26 |
27 | migration_file_path = tmp_path / f"{migration_name}.sql"
28 | with open(migration_file_path, "w") as migration_file:
29 | migration_file.write(migration_content)
30 |
31 | execute_up_migration(mock_connection, migration_file_path, migration_name)
32 |
33 | expected_sql = "CREATE TABLE test_table (id SERIAL PRIMARY KEY)"
34 |
35 | # Extract SQL string from the call and compare as a string
36 | actual_calls = [
37 | call_args[0][0].text for call_args in mock_connection.execute.call_args_list
38 | ]
39 |
40 | assert expected_sql in actual_calls
41 | assert "Applying 'Up' migration for test_migration" in capsys.readouterr().out
42 |
43 |
44 | def test_execute_down_migration(capsys, mock_connection, tmp_path):
45 | migration_name = "test_migration"
46 | migration_content = "-- Down\nDROP TABLE test_table;\n"
47 |
48 | migration_file_path = tmp_path / f"{migration_name}.sql"
49 | with open(migration_file_path, "w") as migration_file:
50 | migration_file.write(migration_content)
51 |
52 | execute_down_migration(mock_connection, migration_file_path, migration_name)
53 |
54 | expected_sql = "DROP TABLE test_table"
55 |
56 | # Extract SQL string from the call and compare as a string
57 | actual_calls = [
58 | call_args[0][0].text for call_args in mock_connection.execute.call_args_list
59 | ]
60 |
61 | assert expected_sql in actual_calls
62 | assert "Applying 'Down' migration for test_migration" in capsys.readouterr().out
63 |
64 |
65 | def test_run_migrations_up(capsys, mock_connection, tmp_path):
66 | migration_name = "test_migration"
67 | migration_content = "-- Up\nCREATE TABLE test_table (id SERIAL PRIMARY KEY);\n"
68 |
69 | migration_file_path = tmp_path / f"{migration_name}.sql"
70 | with open(migration_file_path, "w") as migration_file:
71 | migration_file.write(migration_content)
72 |
73 | run_migrations()
74 |
75 | mock_connection.execute.assert_called_once_with(
76 | "CREATE TABLE test_table (id SERIAL PRIMARY KEY)"
77 | )
78 | assert "Applying 'Up' migration for test_migration" in capsys.readouterr().out
79 |
80 |
81 | def test_get_or_create_migration_directory():
82 | migration_directory = os.path.join(MIGRATION_DIRECTORY)
83 | with patch("inspira.logging.log.error") as log_error_mock:
84 | result = get_or_create_migration_directory()
85 |
86 | assert result == migration_directory
87 | assert os.path.exists(result)
88 | assert os.path.isdir(result)
89 | assert os.path.exists(os.path.join(result, "__init__.py"))
90 | log_error_mock.assert_not_called()
91 |
92 |
93 | def test_get_migration_files(create_migration_files, teardown_migration_directory):
94 | migration_files, migration_dir = create_migration_files
95 |
96 | with patch("inspira.migrations.utils.os.listdir", return_value=migration_files):
97 | result = get_migration_files(migration_dir)
98 |
99 | assert result == [
100 | os.path.join(migration_dir, "0001_test.sql"),
101 | os.path.join(migration_dir, "0002_another.sql"),
102 | os.path.join(migration_dir, "003_missing.sql"),
103 | ]
104 |
105 |
106 | @patch("inspira.migrations.utils.os.listdir")
107 | def test_get_latest_migration_number(mock_listdir):
108 | migration_dir = "tests/migrations"
109 | migration_files = ["0001_test.sql", "0002_another.sql", "003_missing.sql"]
110 |
111 | mock_listdir.return_value = migration_files
112 |
113 | result = get_latest_migration_number(migration_dir)
114 |
115 | assert result == 3
116 |
117 |
118 | def test_insert_migration(setup_teardown_db_session):
119 | current_version = 0
120 | migration_name = "example_migration"
121 | insert_migration(current_version, migration_name)
122 |
123 | result = (
124 | db_session.query(Migration)
125 | .filter_by(version=current_version + 1, migration_name=migration_name)
126 | .first()
127 | )
128 |
129 | assert result.version == 1
130 | assert result.migration_name == migration_name
131 |
132 |
133 | def test_run_migrations_up(runner, monkeypatch, tmpdir, teardown_migration_directory):
134 | migration_file_path = tmpdir / "0001_create_table_customers.sql"
135 | migration_file_path.write_text(
136 | """
137 | -- Up
138 | CREATE TABLE customers (
139 | id INTEGER NOT NULL,
140 | name VARCHAR(50) NOT NULL,
141 | PRIMARY KEY (id)
142 | );
143 | -- Down
144 | DROP TABLE customers;
145 | """,
146 | encoding="utf-8",
147 | )
148 |
149 | monkeypatch.setattr(
150 | "inspira.migrations.migrations.initialize_database", lambda engine: None
151 | )
152 | monkeypatch.setattr(
153 | "inspira.migrations.utils.get_or_create_migration_directory",
154 | lambda: str(tmpdir),
155 | )
156 |
157 | result = runner.invoke(migrate)
158 |
159 | assert result.exit_code == 0
160 |
161 |
162 | def test_migration_file_exist_when_file_exists(caplog):
163 | with patch("inspira.migrations.utils.get_or_create_migration_directory"), patch(
164 | "inspira.migrations.utils.get_migration_files",
165 | return_value=["existing_migration_file.sql"],
166 | ):
167 | result = migration_file_exist("existing_migration_file.sql")
168 |
169 | assert result is True
170 |
171 |
172 | def test_migration_file_exist_when_file_does_not_exist():
173 | with patch("inspira.migrations.utils.get_or_create_migration_directory"), patch(
174 | "inspira.migrations.utils.get_migration_files",
175 | return_value=["other_migration_file.sql"],
176 | ):
177 | result = migration_file_exist("non_existing_migration_file.sql")
178 | assert result is False
179 |
180 |
181 | def test_migration_file_exist_handles_empty_migration_list():
182 | with patch("inspira.migrations.utils.get_or_create_migration_directory"), patch(
183 | "inspira.migrations.utils.get_migration_files", return_value=[]
184 | ):
185 | result = migration_file_exist("non_existing_migration_file.sql")
186 | assert result is False
187 |
188 |
189 | def test_generate_migration_file_creates_file():
190 | with patch("inspira.migrations.utils.get_or_create_migration_directory"), patch(
191 | "inspira.migrations.utils.get_latest_migration_number", return_value=42
192 | ):
193 | migration_name = "example_migration"
194 | with patch("builtins.open", mock_open()):
195 | result = generate_migration_file(migration_name)
196 |
197 | assert os.path.exists(result.name)
198 |
--------------------------------------------------------------------------------
/tests/test_path_decorator.py:
--------------------------------------------------------------------------------
1 | from inspira.decorators.path import path
2 |
3 |
4 | @path("/example/path")
5 | class ExampleController:
6 | pass
7 |
8 |
9 | def test_path_decorator():
10 | assert hasattr(ExampleController, "__path__")
11 | assert ExampleController.__path__ == "/example/path"
12 | assert hasattr(ExampleController, "__is_controller__")
13 | assert ExampleController.__is_controller__ is True
14 |
--------------------------------------------------------------------------------
/tests/test_requests.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import AsyncMock
2 |
3 | import pytest
4 |
5 | from inspira.requests import Request, RequestContext
6 |
7 |
8 | def test_set_request(mock_scope):
9 | receive = AsyncMock()
10 | send = AsyncMock()
11 | request = Request(mock_scope, receive, send)
12 | RequestContext.set_request(request)
13 |
14 | assert RequestContext.get_request() == request
15 |
16 |
17 | def test_get_session(request_with_session):
18 | assert request_with_session.get_session("user_id") == 123
19 | assert request_with_session.get_session("username") == "john_doe"
20 |
21 |
22 | def test_set_session(mock_scope):
23 | receive = AsyncMock()
24 | send = AsyncMock()
25 | request = Request(mock_scope, receive, send)
26 | request.set_session("new_key", "new_value")
27 | assert request.session == {"new_key": "new_value"}
28 |
29 |
30 | def test_remove_session(request_with_session):
31 | removed_value = request_with_session.remove_session("user_id")
32 | assert removed_value == 123
33 | assert "user_id" not in request_with_session.session
34 |
35 |
36 | def test_remove_session_nonexistent_key(request_with_session):
37 | removed_value = request_with_session.remove_session("nonexistent_key")
38 |
39 | assert removed_value is None
40 | assert request_with_session.session == {"user_id": 123, "username": "john_doe"}
41 |
42 |
43 | @pytest.mark.asyncio
44 | async def test_json_with_valid_json(mock_scope):
45 | receive = AsyncMock()
46 | send = AsyncMock()
47 | request = Request(mock_scope, receive, send)
48 |
49 | receive.side_effect = [
50 | {"body": b'{"key": "value"}', "more_body": False},
51 | ]
52 |
53 | json_data = await request.json()
54 |
55 | assert json_data == {"key": "value"}
56 |
57 |
58 | @pytest.mark.asyncio
59 | async def test_json_with_empty_body(mock_scope):
60 | receive = AsyncMock()
61 | send = AsyncMock()
62 | request = Request(mock_scope, receive, send)
63 |
64 | receive.side_effect = [
65 | {"body": b"", "more_body": False},
66 | ]
67 |
68 | json_data = await request.json()
69 |
70 | assert json_data == {}
71 |
72 |
73 | @pytest.mark.asyncio
74 | async def test_form_with_urlencoded_data():
75 | scope = {"headers": [(b"content-type", b"application/x-www-form-urlencoded")]}
76 | receive = AsyncMock()
77 | send = AsyncMock()
78 | request = Request(scope, receive, send)
79 |
80 | receive.side_effect = [
81 | {"body": b"key1=value1&key2=value2", "more_body": False},
82 | ]
83 |
84 | form_data = await request.form()
85 |
86 | assert form_data == {"key1": "value1", "key2": "value2"}
87 |
88 |
89 | @pytest.mark.asyncio
90 | async def test_form_with_multipart_form_data():
91 | scope = {
92 | "headers": [(b"content-type", b"multipart/form-data; boundary=boundary123")]
93 | }
94 | receive = AsyncMock()
95 | send = AsyncMock()
96 | request = Request(scope, receive, send)
97 |
98 | receive.side_effect = [
99 | {
100 | "body": b'--boundary123\r\nContent-Disposition: form-data; name="key1"\r\n\r\nvalue1\r\n--boundary123\r\nContent-Disposition: form-data; name="key2"\r\n\r\nvalue2\r\n--boundary123--',
101 | "more_body": False,
102 | },
103 | ]
104 |
105 | form_data = await request.form()
106 |
107 | assert form_data == {"key1": "value1", "key2": "value2"}
108 |
109 |
110 | @pytest.mark.asyncio
111 | async def test_cookies_with_valid_cookie_header():
112 | scope = {"headers": [(b"cookie", b"key1=value1; key2=value2")]}
113 | receive = AsyncMock()
114 | send = AsyncMock()
115 | request = Request(scope, receive, send)
116 |
117 | cookies = request.cookies()
118 |
119 | assert cookies == {"key1": "value1", "key2": "value2"}
120 |
121 |
122 | @pytest.mark.asyncio
123 | async def test_cookies_with_empty_cookie_header():
124 | scope = {"headers": [(b"cookie", b"")]}
125 | receive = AsyncMock()
126 | send = AsyncMock()
127 | request = Request(scope, receive, send)
128 |
129 | cookies = request.cookies()
130 |
131 | assert cookies == {}
132 |
133 |
134 | @pytest.mark.asyncio
135 | async def test_cookies_without_cookie_header():
136 | scope = {"headers": []}
137 | receive = AsyncMock()
138 | send = AsyncMock()
139 | request = Request(scope, receive, send)
140 |
141 | cookies = request.cookies()
142 |
143 | assert cookies == {}
144 |
145 |
146 | @pytest.mark.asyncio
147 | async def test_cookies_with_malformed_cookie_header():
148 | scope = {"headers": [(b"cookie", b"invalid_cookie_header")]}
149 | receive = AsyncMock()
150 | send = AsyncMock()
151 | request = Request(scope, receive, send)
152 |
153 | cookies = request.cookies()
154 |
155 | assert cookies == {}
156 |
--------------------------------------------------------------------------------
/tests/test_responses.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from http import HTTPStatus
4 |
5 | import pytest
6 |
7 | from inspira.constants import APPLICATION_JSON, TEXT_PLAIN, UTF8
8 | from inspira.decorators.http_methods import delete, get, patch, post, put
9 | from inspira.enums import HttpMethod
10 | from inspira.requests import Request
11 | from inspira.responses import (
12 | ForbiddenResponse,
13 | HttpResponse,
14 | HttpResponseRedirect,
15 | JsonResponse,
16 | TemplateResponse,
17 | )
18 |
19 |
20 | def test_should_throw_error_when_same_endpoint_specified_twice(app):
21 | @get("/home")
22 | def home(request):
23 | return HttpResponse("This is a test endpoint")
24 |
25 | app.add_route("/home", HttpMethod.GET, home)
26 |
27 | with pytest.raises(AssertionError):
28 |
29 | @get("/home")
30 | def home2(request):
31 | return HttpResponse("This is a test endpoint")
32 |
33 | app.add_route("/home", HttpMethod.GET, home2)
34 |
35 |
36 | @pytest.mark.asyncio
37 | async def test_basic_route(client, app):
38 | @get("/home")
39 | async def home(request):
40 | return HttpResponse("This is a test endpoint")
41 |
42 | app.add_route("/home", HttpMethod.GET, home)
43 |
44 | response = await client.get("/home")
45 |
46 | assert response.status_code == 200
47 | assert response.text == "This is a test endpoint"
48 |
49 |
50 | @pytest.mark.asyncio
51 | async def test_set_cookie_with_route(app, client):
52 | @get("/home")
53 | async def home(request):
54 | http_response = HttpResponse("This is a test endpoint")
55 | http_response.set_cookie("my_cookie", "my_cookie_value")
56 | return http_response
57 |
58 | app.add_route("/home", HttpMethod.GET, home)
59 |
60 | response = await client.get("/home")
61 | headers_dict = dict(response.headers)
62 |
63 | expected_headers = {
64 | "content-type": TEXT_PLAIN,
65 | "set-cookie": "my_cookie=my_cookie_value; Path=/",
66 | }
67 |
68 | assert headers_dict == expected_headers
69 |
70 |
71 | @pytest.mark.asyncio
72 | async def test_set_multiple_cookie(app, client_session):
73 | @get("/home")
74 | async def home(request):
75 | http_response = HttpResponse("This is a test endpoint")
76 | http_response.set_cookie("my_cookie", "my_cookie_value")
77 | http_response.set_cookie("my_second_cookie", "my_second_cookie_value")
78 |
79 | return http_response
80 |
81 | app.add_route("/home", HttpMethod.GET, home)
82 |
83 | response = await client_session.get("/home")
84 | headers_dict = dict(response.headers)
85 |
86 | expected_header = {
87 | "content-type": TEXT_PLAIN,
88 | "set-cookie": "my_cookie=my_cookie_value; Path=/, my_second_cookie=my_second_cookie_value; Path=/",
89 | }
90 |
91 | assert headers_dict == expected_header
92 |
93 |
94 | @pytest.mark.asyncio
95 | async def test_response_json(app, client):
96 | @get("/home/something")
97 | async def home(request):
98 | return JsonResponse({"message": "Hej something"})
99 |
100 | app.add_route("/home/something", HttpMethod.GET, home)
101 |
102 | response = await client.get("/home/something")
103 |
104 | assert response.status_code == HTTPStatus.OK
105 | assert response.headers["content-type"] == APPLICATION_JSON
106 |
107 | content = response.json()
108 | expected_content = {"message": "Hej something"}
109 |
110 | assert content == expected_content
111 |
112 |
113 | @pytest.mark.asyncio
114 | async def test_response_text(app, client):
115 | @get("/home/something")
116 | async def home(request):
117 | return HttpResponse("Hej something")
118 |
119 | app.add_route("/home/something", HttpMethod.GET, home)
120 |
121 | response = await client.get("/home/something")
122 |
123 | assert response.status_code == HTTPStatus.OK
124 |
125 | content = response.text
126 | expected_content = "Hej something"
127 | assert content == expected_content
128 |
129 |
130 | @pytest.mark.asyncio
131 | async def test_number_parameter(app, client):
132 | @get("/home/1")
133 | async def home(request):
134 | return JsonResponse({"id": 1})
135 |
136 | app.add_route("/home/1", HttpMethod.GET, home)
137 |
138 | response = await client.get("/home/1")
139 |
140 | assert response.status_code == HTTPStatus.OK
141 |
142 | content = response.json()
143 | expected_content = {"id": 1}
144 | assert content == expected_content
145 |
146 |
147 | @pytest.mark.asyncio
148 | async def test_endpoint_without_type_defaults_to_string(app, client):
149 | @get("/home/1")
150 | async def home(request):
151 | return JsonResponse({"id": "1"})
152 |
153 | app.add_route("/home/1", HttpMethod.GET, home)
154 |
155 | response = await client.get("/home/1")
156 |
157 | assert response.status_code == HTTPStatus.OK
158 |
159 | content = response.json()
160 | expected_content = {"id": "1"}
161 | assert content == expected_content
162 |
163 |
164 | @pytest.mark.asyncio
165 | async def test_posting_json(app, client):
166 | payload = {"name": "Hayri", "email": "hayri@inspira.com"}
167 |
168 | @post("/posting-json")
169 | async def posting_json(request):
170 | json_data = await request.json()
171 | return JsonResponse(json_data)
172 |
173 | app.add_route("/posting-json", HttpMethod.POST, posting_json)
174 |
175 | response = await client.post("/posting-json", json=payload)
176 |
177 | assert response.status_code == HTTPStatus.OK
178 | assert response.json() == payload
179 |
180 |
181 | @pytest.mark.asyncio
182 | async def test_posting_form_data(client, app):
183 | payload = {"name": "Hayri", "email": "hayri@inspira.com"}
184 |
185 | @post("/posting-form")
186 | async def posting_form(request):
187 | json_data = await request.form()
188 | return JsonResponse(json_data)
189 |
190 | app.add_route("/posting-form", HttpMethod.POST, posting_form)
191 |
192 | response = await client.post("/posting-form", data=payload)
193 |
194 | assert response.status_code == HTTPStatus.OK
195 | assert response.json() == payload
196 |
197 |
198 | @pytest.mark.asyncio
199 | async def test_delete_method(client, app):
200 | @delete("/delete/1")
201 | async def deleting(request):
202 | return HttpResponse(status_code=HTTPStatus.NO_CONTENT)
203 |
204 | app.add_route("/delete/1", HttpMethod.DELETE, deleting)
205 |
206 | response = await client.delete("/delete/1")
207 |
208 | assert response.status_code == HTTPStatus.NO_CONTENT
209 |
210 |
211 | @pytest.mark.asyncio
212 | async def test_put_method(app, client):
213 | @put("/update/1")
214 | async def updating(request):
215 | return HttpResponse(status_code=HTTPStatus.NO_CONTENT)
216 |
217 | app.add_route("/update/1", HttpMethod.PUT, updating)
218 |
219 | response = await client.put("/update/1")
220 |
221 | assert response.status_code == HTTPStatus.NO_CONTENT
222 |
223 |
224 | @pytest.mark.asyncio
225 | async def test_patch_method(app, client):
226 | @patch("/test/1")
227 | async def test_patch(request: Request):
228 | return HttpResponse(status_code=HTTPStatus.NO_CONTENT)
229 |
230 | app.add_route("/test/1", HttpMethod.PATCH, test_patch)
231 |
232 | response = await client.patch("/test/1")
233 |
234 | assert response.status_code == HTTPStatus.NO_CONTENT
235 |
236 |
237 | @pytest.mark.asyncio
238 | async def test_redirect(app, client):
239 | @get("/")
240 | async def redirect(request):
241 | return HttpResponseRedirect("/hello")
242 |
243 | app.add_route("/", HttpMethod.GET, redirect)
244 |
245 | response = await client.get("/")
246 |
247 | assert response.status_code == HTTPStatus.FOUND
248 | assert response.headers["location"] == "/hello"
249 |
250 |
251 | @pytest.mark.asyncio
252 | async def test_template(client, app):
253 | template_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "templates"))
254 |
255 | @get("/example")
256 | async def render_template(request):
257 | return TemplateResponse(
258 | "example.html", {"name": "test"}, template_dir=template_dir
259 | )
260 |
261 | app.add_route("/example", HttpMethod.GET, render_template)
262 |
263 | response = await client.get("/example")
264 |
265 | assert response.status_code == HTTPStatus.OK
266 | assert response.text == "test
"
267 |
268 |
269 | @pytest.mark.asyncio
270 | async def test_serialize_content_byte():
271 | response = HttpResponse(content=b"example")
272 | result = await response.serialize_content()
273 | assert result == b"example"
274 |
275 |
276 | @pytest.mark.asyncio
277 | async def test_serialize_content_json():
278 | response = JsonResponse(content={"key": "value"})
279 | result = await response.serialize_content()
280 | expected_result = json.dumps({"key": "value"}).encode(UTF8)
281 | assert result == expected_result
282 |
283 |
284 | @pytest.mark.asyncio
285 | async def test_serialize_content_default():
286 | response = HttpResponse(content="example")
287 | result = await response.serialize_content()
288 | expected_result = b"example"
289 | assert result == expected_result
290 |
291 |
292 | @pytest.mark.asyncio
293 | async def test_encoded_headers():
294 | response = HttpResponse(headers={"Key1": "Value1", "Key2": ["Value2a", "Value2b"]})
295 | result = await response.encoded_headers()
296 |
297 | expected_result = [
298 | (b"content-type", TEXT_PLAIN.encode(UTF8)),
299 | (b"Key1", b"Value1"),
300 | (b"Key2", b"Value2a"),
301 | (b"Key2", b"Value2b"),
302 | ]
303 |
304 | assert result == expected_result
305 |
306 |
307 | @pytest.mark.asyncio
308 | async def test_set_cookie():
309 | response = HttpResponse()
310 | response.set_cookie("user", "john_doe", max_age=3600, path="/")
311 | headers = response.headers.get("set-cookie", [])
312 | assert len(headers) == 1
313 | assert "user=john_doe" in headers[0]
314 | assert "Max-Age=3600" in headers[0]
315 | assert "Path=/" in headers[0]
316 |
317 |
318 | def test_forbidden_response_default_value():
319 | response = ForbiddenResponse()
320 |
321 | assert response.content is None
322 | assert response.status_code == HTTPStatus.FORBIDDEN
323 | assert response.content_type == TEXT_PLAIN
324 |
325 |
326 | def test_forbidden_response_custom_values():
327 | content = "This is forbidden"
328 | custom_status_code = HTTPStatus.UNAUTHORIZED
329 | custom_headers = {"Custom-Header": "Value"}
330 |
331 | response = ForbiddenResponse(
332 | content=content, status_code=custom_status_code, headers=custom_headers
333 | )
334 |
335 | assert response.content == content
336 | assert response.status_code == custom_status_code
337 | assert response.content_type == TEXT_PLAIN
338 | assert response.headers == custom_headers
339 |
340 |
341 | def test_forbidden_response_content_type_json():
342 | content = {"message": "You don't have access"}
343 | response = ForbiddenResponse(content=content, content_type=APPLICATION_JSON)
344 |
345 | assert response.content == content
346 | assert response.content_type == APPLICATION_JSON
347 |
--------------------------------------------------------------------------------
/tests/test_session_utils.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import pytest
4 |
5 | from inspira.utils.session_utils import (
6 | DateTimeEncoder,
7 | decode_session_data,
8 | encode_session_data,
9 | get_or_create_session,
10 | get_session_token_from_request,
11 | )
12 |
13 |
14 | def test_datatime_encoder():
15 | encoder = DateTimeEncoder()
16 |
17 | dt_object = datetime.datetime(2023, 1, 15, 12, 30, 0)
18 | encoded_dt = encoder.encode(dt_object)
19 | assert encoded_dt == '"2023-01-15T12:30:00"'
20 |
21 |
22 | def test_datetime_encoder_list_with_datetime_object():
23 | encoder = DateTimeEncoder()
24 | dt_object = datetime.datetime(2023, 1, 15, 12, 30, 0)
25 | data = {"timestamp": dt_object, "other_data": "example"}
26 | encoded_data = encoder.encode(data)
27 | assert (
28 | encoded_data == '{"timestamp": "2023-01-15T12:30:00", "other_data": "example"}'
29 | )
30 |
31 |
32 | def test_datatime_encoder_non_datetime_object():
33 | encoder = DateTimeEncoder()
34 | non_dt_object = {"key": "value"}
35 | encoded_non_dt = encoder.encode(non_dt_object)
36 | assert encoded_non_dt == '{"key": "value"}'
37 |
38 |
39 | def test_encode_decode_session_data(secret_key):
40 | session_data = {"email": "hayri@inspiraframework.com"}
41 | session_token = encode_session_data(session_data, secret_key)
42 |
43 | decoded_session_data = decode_session_data(session_token, secret_key)
44 |
45 | expiration_time = decoded_session_data.pop("expiration_time", None)
46 |
47 | assert expiration_time is not None
48 | assert decoded_session_data == session_data
49 |
50 |
51 | def test_get_or_create_session_with_valid_cookie(secret_key):
52 | session_data = {"email": "hayri@inspiraframework.com"}
53 | encoded_session_token = encode_session_data(session_data, secret_key)
54 |
55 | class MockRequest:
56 | def __init__(self, cookie_value):
57 | self.headers = {"cookie": cookie_value}
58 |
59 | def get_headers(self):
60 | return self.headers
61 |
62 | mock_request = MockRequest(f"session={encoded_session_token}")
63 | result = get_or_create_session(mock_request)
64 |
65 | expected_result = {"email": "hayri@inspiraframework.com"}
66 |
67 | expiration_time = result.pop("expiration_time", None)
68 |
69 | assert result == expected_result
70 | assert expiration_time is not None
71 |
72 |
73 | def test_get_or_create_session_with_invalid_session(secret_key):
74 | session_id = "invalid_session"
75 |
76 | class MockRequest:
77 | def __init__(self, cookie_value):
78 | self.session = None
79 | self.headers = {"cookie": cookie_value}
80 |
81 | def get_headers(self):
82 | return self.headers
83 |
84 | mock_request = MockRequest(f"session={session_id}")
85 |
86 | session = get_or_create_session(mock_request)
87 |
88 | assert session is None
89 |
90 |
91 | @pytest.mark.parametrize(
92 | "cookie_value, expected_token",
93 | [
94 | ("session=your_token_value", "your_token_value"),
95 | ("other_cookie=other_value; session=your_token_value", "your_token_value"),
96 | ("other_cookie=other_value", None),
97 | ("", None),
98 | ],
99 | )
100 | def test_get_session_token_from_request(cookie_value, expected_token):
101 | class MockRequest:
102 | def __init__(self, cookie_value):
103 | self.headers = {"cookie": cookie_value}
104 |
105 | def get_headers(self):
106 | return self.headers
107 |
108 | session_cookie_name = "session"
109 | mock_request = MockRequest(cookie_value)
110 | result = get_session_token_from_request(mock_request, session_cookie_name)
111 |
112 | assert result == expected_token
113 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from inspira.utils import (
2 | convert_to_camel_case,
3 | convert_to_snake_case,
4 | get_random_secret_key,
5 | pluralize_word,
6 | singularize,
7 | )
8 |
9 |
10 | def test_singularize_word():
11 | word = "orders"
12 | expected = "order"
13 | assert singularize(word) == expected
14 |
15 |
16 | def test_pluralize_singular_word():
17 | word = "order"
18 | expected = "orders"
19 | assert pluralize_word(word) == expected
20 |
21 |
22 | def test_pluralize_word():
23 | word = "users"
24 | expected = "users"
25 | assert pluralize_word(word) == expected
26 |
27 |
28 | def test_get_random_secret_key_length():
29 | length = 10
30 | key = get_random_secret_key(length)
31 | assert len(key) == length
32 |
33 |
34 | def test_get_random_secret_key_uniqueness():
35 | key1 = get_random_secret_key()
36 | key2 = get_random_secret_key()
37 |
38 | assert key1 != key2
39 |
40 |
41 | def test_convert_to_snake_case():
42 | assert convert_to_snake_case("get-started") == "get_started"
43 | assert convert_to_snake_case("getstarted") == "getstarted"
44 | assert convert_to_snake_case("Get-Started") == "get_started"
45 |
46 |
47 | def test_convert_to_camel_case():
48 | assert convert_to_camel_case("get-started") == "GetStarted"
49 | assert convert_to_camel_case("get_started") == "GetStarted"
50 | assert convert_to_camel_case("GetStarted") == "Getstarted"
51 |
--------------------------------------------------------------------------------
/tests/test_websockets.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import AsyncMock
2 |
3 | import pytest
4 |
5 | from inspira.constants import (
6 | WEBSOCKET_ACCEPT_TYPE,
7 | WEBSOCKET_CLOSE_TYPE,
8 | WEBSOCKET_DISCONNECT_TYPE,
9 | WEBSOCKET_RECEIVE_TYPE,
10 | WEBSOCKET_SEND_TYPE,
11 | WEBSOCKET_TYPE,
12 | )
13 | from inspira.decorators.websocket import websocket
14 | from inspira.websockets import WebSocket, WebSocketControllerRegistry
15 |
16 |
17 | @websocket("/test")
18 | class TestWebSocketController:
19 | async def on_open(self, websocket: WebSocket):
20 | await websocket.on_open()
21 |
22 | async def on_message(self, websocket: WebSocket, message):
23 | modified_message = f"Server response to: {message.get('text', '')}"
24 | await websocket.send_text(modified_message)
25 |
26 | async def on_close(self, websocket: WebSocket):
27 | await websocket.on_close()
28 |
29 |
30 | @pytest.mark.asyncio
31 | async def test_handle_websocket(app):
32 | receive_queue = [{"type": WEBSOCKET_RECEIVE_TYPE, "text": "Test message"}]
33 | send_queue = []
34 |
35 | async def receive():
36 | return receive_queue.pop(0) if receive_queue else None
37 |
38 | async def send(message):
39 | send_queue.append(message)
40 |
41 | WebSocketControllerRegistry.register_controller("/test", TestWebSocketController)
42 |
43 | await app({"type": WEBSOCKET_TYPE, "path": "/test"}, receive, send)
44 |
45 | assert send_queue == [
46 | {"type": WEBSOCKET_ACCEPT_TYPE},
47 | {"type": WEBSOCKET_SEND_TYPE, "text": "Server response to: Test message"},
48 | {"type": WEBSOCKET_CLOSE_TYPE},
49 | ]
50 |
51 |
52 | @pytest.mark.asyncio
53 | async def test_handle_websocket_multiple_messages(app):
54 | receive_queue = [
55 | {"type": WEBSOCKET_RECEIVE_TYPE, "text": "Message 1"},
56 | {"type": WEBSOCKET_RECEIVE_TYPE, "text": "Message 2"},
57 | {"type": WEBSOCKET_RECEIVE_TYPE, "text": "Message 3"},
58 | ]
59 | send_queue = []
60 |
61 | async def receive():
62 | return receive_queue.pop(0) if receive_queue else None
63 |
64 | async def send(message):
65 | send_queue.append(message)
66 |
67 | WebSocketControllerRegistry.register_controller("/test", TestWebSocketController)
68 |
69 | await app({"type": WEBSOCKET_TYPE, "path": "/test"}, receive, send)
70 |
71 | assert send_queue == [
72 | {"type": WEBSOCKET_ACCEPT_TYPE},
73 | {"type": WEBSOCKET_SEND_TYPE, "text": "Server response to: Message 1"},
74 | {"type": WEBSOCKET_SEND_TYPE, "text": "Server response to: Message 2"},
75 | {"type": WEBSOCKET_SEND_TYPE, "text": "Server response to: Message 3"},
76 | {"type": WEBSOCKET_CLOSE_TYPE},
77 | ]
78 |
79 |
80 | @pytest.mark.asyncio
81 | async def test_handle_websocket_connection_closure(app):
82 | receive_queue = [{"type": WEBSOCKET_DISCONNECT_TYPE}]
83 | send_queue = []
84 |
85 | async def receive():
86 | return receive_queue.pop(0) if receive_queue else None
87 |
88 | async def send(message):
89 | send_queue.append(message)
90 |
91 | WebSocketControllerRegistry.register_controller("/test", TestWebSocketController)
92 |
93 | await app({"type": WEBSOCKET_TYPE, "path": "/test"}, receive, send)
94 |
95 | assert send_queue == [
96 | {"type": WEBSOCKET_ACCEPT_TYPE},
97 | {"type": WEBSOCKET_CLOSE_TYPE},
98 | ]
99 |
100 |
101 | @pytest.mark.asyncio
102 | async def test_handle_websocket_invalid_path(app):
103 | receive_queue = [{"type": WEBSOCKET_RECEIVE_TYPE, "text": "Test message"}]
104 | send_queue = []
105 |
106 | async def receive():
107 | return receive_queue.pop(0) if receive_queue else None
108 |
109 | async def send(message):
110 | send_queue.append(message)
111 |
112 | WebSocketControllerRegistry.register_controller("/test", TestWebSocketController)
113 |
114 | await app({"type": WEBSOCKET_TYPE, "path": "/invalid_path"}, receive, send)
115 |
116 | assert send_queue == []
117 |
118 |
119 | @pytest.mark.asyncio
120 | async def test_send_text():
121 | websocket = WebSocket(
122 | scope={"type": WEBSOCKET_TYPE}, receive=AsyncMock(), send=AsyncMock()
123 | )
124 |
125 | data = "dddd"
126 | await websocket.send_text(data)
127 |
128 | expected_message = {"type": WEBSOCKET_SEND_TYPE, "text": "dddd"}
129 | websocket._send.assert_called_once_with(expected_message)
130 |
131 |
132 | @pytest.mark.asyncio
133 | async def test_send_json_text():
134 | websocket = WebSocket(
135 | scope={"type": WEBSOCKET_TYPE}, receive=AsyncMock(), send=AsyncMock()
136 | )
137 |
138 | data = {"key": "value"}
139 | await websocket.send_json(data)
140 |
141 | expected_message = {"type": WEBSOCKET_SEND_TYPE, "text": '{"key":"value"}'}
142 | websocket._send.assert_called_once_with(expected_message)
143 |
144 |
145 | @pytest.mark.asyncio
146 | async def test_send_bytes():
147 | websocket = WebSocket(
148 | scope={"type": WEBSOCKET_TYPE}, receive=AsyncMock(), send=AsyncMock()
149 | )
150 |
151 | data = b"value"
152 | await websocket.send_binary(data)
153 |
154 | expected_message = {"type": WEBSOCKET_SEND_TYPE, "bytes": b"value"}
155 | websocket._send.assert_called_once_with(expected_message)
156 |
--------------------------------------------------------------------------------