├── .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](https://img.shields.io/badge/license-MIT-blue.svg)](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 | --------------------------------------------------------------------------------