├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── fastapi_app ├── __init__.py ├── background_tasks │ ├── __init__.py │ └── calculate_event_documentation.py ├── celery.py ├── config.py ├── crud │ ├── __init__.py │ ├── account.py │ ├── base.py │ ├── domain.py │ ├── event.py │ ├── event_documentation.py │ ├── event_documentation_example.py │ ├── event_schema.py │ ├── screenshot.py │ └── user.py ├── database.py ├── db │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 2023_04_03_1551-184d20e99b62_create_initial_tables.py │ │ ├── 2023_04_04_1551-e8fe31da6e29_create_domains_table.py │ │ ├── 2023_04_05_1551-b4a56b178155_create_users_table.py │ │ ├── 2023_04_29_1630-801047f6478c_create_event_documentations_table.py │ │ ├── 2023_04_30_1909-411e9f5bac7a_create_screenshots_table.py │ │ └── 2023_05_01_0500-69df1b9d2b1a_create_event_documentation_examples_.py ├── dependencies.py ├── main.py ├── models │ ├── __init__.py │ ├── account.py │ ├── base.py │ ├── base_file.py │ ├── domain.py │ ├── event.py │ ├── event_documentation.py │ ├── event_documentation_example.py │ ├── event_schema.py │ ├── screenshot.py │ └── user.py ├── schemas │ ├── __init__.py │ └── api │ │ ├── __init__.py │ │ ├── domain.py │ │ ├── event.py │ │ ├── event_documentation.py │ │ ├── screenshot.py │ │ └── user.py ├── services │ ├── __init__.py │ ├── account.py │ ├── base.py │ ├── domain.py │ ├── event.py │ ├── event_documentation.py │ └── user.py ├── subapps │ ├── __init__.py │ ├── client.py │ ├── internal.py │ └── web_app.py └── utils │ ├── __init__.py │ ├── init_supertokens.py │ ├── logger.py │ ├── route_handler.py │ ├── s3_client.py │ └── singleton.py ├── mypy.ini ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # next.js 5 | .next/ 6 | out/ 7 | 8 | # production 9 | build 10 | 11 | # misc 12 | .DS_Store 13 | *.pem 14 | 15 | # debug 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | .pnpm-debug.log* 20 | 21 | # env files 22 | .env*.local 23 | .env 24 | .envrc 25 | 26 | # vercel 27 | .vercel 28 | 29 | # typescript 30 | *.tsbuildinfo 31 | next-env.d.ts 32 | 33 | # python 34 | __pycache__ 35 | .mypy_cache 36 | 37 | # vertual environment 38 | .venv -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/psf/black 8 | rev: 23.3.0 9 | hooks: 10 | - id: black 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Petr Gazarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | poetry install 3 | 4 | server: 5 | uvicorn fastapi_app.main:app --reload 6 | 7 | worker: 8 | celery -A fastapi_app.celery worker --loglevel=info --concurrency=5 9 | 10 | lint: 11 | mypy fastapi_app 12 | 13 | migration_generate: 14 | alembic revision -m "$(name)" 15 | 16 | migration_autogenerate: 17 | alembic revision --autogenerate -m "$(name)" 18 | 19 | migrate: 20 | alembic upgrade head 21 | 22 | rollback: 23 | alembic downgrade -1 24 | 25 | rollback_base: 26 | alembic downgrade base 27 | 28 | reset: 29 | make rollback migrate seed 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI App 2 | 3 | This is a small app that I built while learning Python and FastApi. It allows to create documentations (shared schemas + screenshots) from tracking events. It also serves as a backend for an interface that lets a user register, sign in, set some settings and view documentations. 4 | 5 | While building this app, I spent the most time learning the best practices of accomplishing common tasks in the FastAPI ecosystem. I tried out various libraries and arrived at the following list that I think work well together: 6 | 7 | - FastAPI + Asyncio 8 | - SQLModel for data models and validation 9 | - Celery for background tasks 10 | - Alembic for migrations 11 | - Supertokens for user authentication 12 | - Mypy for type checking 13 | - Make for running commands 14 | 15 | Additionally, the codebase showcases the following things: 16 | 17 | - Multiple FastAPI apps in one project 18 | - Alembic auto-generating migrations from model files 19 | - Celery tasks communicating through an SQS FIFO queue 20 | - AWS S3 integration for storing images 21 | - Loggers that print SQL queries and other useful information 22 | - Error handler middlewares 23 | - CORS middleware 24 | - Two separate types of authentication (static auth token for an internal endpoint + Supertokens for user authentication) 25 | - Dependencies management using Poetry 26 | - And more. 27 | 28 | ## Inspiration 29 | 30 | I took a ton of inspiration from the following articles and projects: 31 | 32 | [Abstracting FastAPI Services](https://camillovisini.com/article/abstracting-fastapi-services/) 33 | 34 | [grillazz/fastapi-sqlalchemy-asyncpg](https://github.com/grillazz/fastapi-sqlalchemy-asyncpg) 35 | 36 | [The ultimate async setup: FastAPI, SQLModel, Alembic, Pytest](https://medium.com/@estretyakov/the-ultimate-async-setup-fastapi-sqlmodel-alembic-pytest-ae5cdcfed3d4) 37 | 38 | ## Project structure 39 | 40 | The project is organized into the following directories and files: 41 | 42 | | Directory/File Name | Description | 43 | | ------------------- | ------------------------------------------------------------------------------------ | 44 | | background_tasks/ | Celery tasks | 45 | | crud/ | CRUD operations | 46 | | db/ | Alembic migrations | 47 | | models/ | model files that combine data models and Pydantic schemas | 48 | | schemas/ | Pydantic schemas for things other than data models (e.g. api requests and responses) | 49 | | services/ | business logic | 50 | | subapps/ | FastAPI apps with each file containing a separate app | 51 | | utils/ | utility functions | 52 | | celery.py | Celery app | 53 | | config.py | Pydantic settings | 54 | | database.py | SQLAlchemy database engine and session | 55 | | dependencies.py | FastAPI dependencies | 56 | | main.py | main project file | 57 | 58 | ## Running the project 59 | 60 | ### Prerequisites 61 | 62 | - Python 3.10 63 | - Supertokens account 64 | - GitHub OAuth app (SuperTokens uses GitHub OAuth but that can easily be changed for another OAuth provider) 65 | - AWS Account, an S3 bucket and an SQS queue 66 | - PostgreSQL database 67 | 68 | ### Steps 69 | 70 | 1. Clone the repo 71 | 2. Change `sqlalchemy.url` in `alembic.ini` to point to your database 72 | 3. Use commands in Makefile to install dependencies and run migrations 73 | 4. Rename `.env.example` to `.env` and fill in the values 74 | 5. Run `make server` and `make worker` to start the web server and the Celery worker 75 | 76 | 77 | ## Pre-Commit Setup 78 | 79 | If you are using this project then pre-commit setup would be very helpful for checking your codebase. In short, pre-commit is a tool that allows developers to define and apply automated checks to their code before they commit it to a version control system. You can find more info [here](https://pre-commit.com). 80 | 81 | 82 | ```commandline 83 | pre-commit install 84 | 85 | # for the first time, run on all files 86 | pre-commit run --all-files 87 | ``` 88 | 89 | ## License 90 | 91 | MIT License 92 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = fastapi_app/db 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to db/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:db/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = postgresql://localhost:5432/fastapi_app_development 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # Logging configuration 78 | [loggers] 79 | keys = root,sqlalchemy,alembic 80 | 81 | [handlers] 82 | keys = console, console_rich, error_file, access_file 83 | 84 | [formatters] 85 | keys = generic, generic_rich, access 86 | 87 | [logger_root] 88 | ; Logging level for all loggers 89 | level = NOTSET 90 | handlers = console_rich, error_file 91 | 92 | [logger_sqlalchemy] 93 | level = INFO 94 | handlers = 95 | qualname = sqlalchemy.engine 96 | 97 | [logger_alembic] 98 | level = INFO 99 | handlers = 100 | qualname = alembic 101 | 102 | [handler_console] 103 | class = logging.StreamHandler 104 | args = (sys.stderr,) 105 | level = NOTSET 106 | formatter = generic 107 | 108 | [handler_error_file] 109 | class = logging.FileHandler 110 | formatter = generic 111 | level = WARNING 112 | args = ('/tmp/error.log','w') 113 | 114 | [handler_access_file] 115 | class = logging.FileHandler 116 | formatter = access 117 | args = ('/tmp/access.log',) 118 | 119 | [formatter_generic] 120 | format = [%(process)d|%(name)-12s|%(filename)s:%(lineno)d] %(levelname)-7s %(message)s 121 | datefmt = %H:%M:%S 122 | class = logging.Formatter 123 | 124 | [formatter_access] 125 | format = %(message)s 126 | class = logging.Formatter 127 | 128 | [formatter_generic_rich] 129 | format = [%(process)d %(name)s] %(message)s 130 | datefmt = %H:%M:%S 131 | class = logging.Formatter 132 | 133 | [handler_console_rich] 134 | class = fastapi_app.utils.RichConsoleHandler 135 | args = (100, "blue") 136 | kwargs = {"omit_repeated_times":False, "show_time": False, "enable_link_path": True, "tracebacks_show_locals": True} 137 | level = NOTSET 138 | -------------------------------------------------------------------------------- /fastapi_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrgazarov/FastAPI-app/664feaaca5fc2eee5966a1f2e4a6a9be32b3b6e6/fastapi_app/__init__.py -------------------------------------------------------------------------------- /fastapi_app/background_tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .calculate_event_documentation import * 2 | -------------------------------------------------------------------------------- /fastapi_app/background_tasks/calculate_event_documentation.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from uuid import UUID 3 | from fastapi_app import database, services, crud, celery 4 | 5 | 6 | @celery.celery_app.task( # type: ignore 7 | name="calculate_event_documentation", 8 | queue="event-documentation-updates.fifo", 9 | ) 10 | def calculate_event_documentation(account_id: UUID, event_id: UUID) -> None: 11 | loop = asyncio.get_event_loop() 12 | loop.run_until_complete(calculate_event_documentation_async(account_id, event_id)) 13 | return None 14 | 15 | 16 | async def calculate_event_documentation_async(account_id: UUID, event_id: UUID) -> None: 17 | async for db in database.get_db(): 18 | event = await crud.Event(db=db).find(event_id) 19 | 20 | if event: 21 | await services.EventDocumentationsService( 22 | account_id=account_id, db=db 23 | ).calculate_event_documentation(event=event) 24 | else: 25 | raise ValueError(f"Could not find event with id {event_id}") 26 | -------------------------------------------------------------------------------- /fastapi_app/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery # type: ignore 2 | from kombu.utils.url import safequote # type: ignore 3 | from fastapi_app import config 4 | 5 | settings = config.get_settings() 6 | 7 | broker_url = "sqs://{aws_access_key}:{aws_secret_key}@".format( 8 | aws_access_key=safequote(settings.aws_access_key_id), 9 | aws_secret_key=safequote(settings.aws_secret_access_key), 10 | ) 11 | 12 | celery_app = Celery( 13 | "fastapi_app_workers", 14 | broker_url=broker_url, 15 | result_backend=None, 16 | task_default_queue="[queue-name].fifo", 17 | include=["fastapi_app.background_tasks"], 18 | broker_transport_options={ 19 | "predefined_queues": { 20 | "[queue-name].fifo": { 21 | "url": "https://sqs.us-east-1.amazonaws.com/[account_id]/[queue-name].fifo", 22 | "access_key_id": settings.aws_access_key_id, 23 | "secret_access_key": settings.aws_secret_access_key, 24 | } 25 | } 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /fastapi_app/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | from functools import lru_cache 3 | 4 | 5 | class Settings(BaseSettings): 6 | app_url: str 7 | aws_access_key_id: str 8 | aws_secret_access_key: str 9 | database_url: str 10 | github_client_id: str 11 | github_client_secret: str 12 | internal_auth_token: str 13 | supertokens_api_key: str 14 | supertokens_connection_uri: str 15 | 16 | class Config: 17 | env_file = ".env" 18 | 19 | 20 | @lru_cache() 21 | def get_settings() -> Settings: 22 | # Workaround because Pyright does not work well with BaseSettings 23 | return Settings.parse_obj({}) 24 | -------------------------------------------------------------------------------- /fastapi_app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import * 2 | from .domain import * 3 | from .event import * 4 | from .event_schema import * 5 | from .event_documentation import * 6 | from .event_documentation_example import * 7 | from .screenshot import * 8 | from .user import * 9 | -------------------------------------------------------------------------------- /fastapi_app/crud/account.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from fastapi_app import models 3 | from pydantic import BaseModel 4 | from .base import CRUDBase 5 | 6 | 7 | class Account(CRUDBase[models.Account, models.AccountCreate, BaseModel]): 8 | def __init__(self, db: AsyncSession): 9 | super().__init__(db) 10 | self.model = models.Account 11 | -------------------------------------------------------------------------------- /fastapi_app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Sequence, Optional, TypeVar, Type, Dict, Any 2 | from sqlmodel import select, delete 3 | from pydantic import UUID4 4 | from pydantic import BaseModel 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from fastapi_app import models 7 | 8 | ModelType = TypeVar("ModelType", bound=models.SQLModelBase) 9 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 10 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 11 | 12 | 13 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 14 | db: AsyncSession 15 | model: Type[ModelType] 16 | 17 | def __init__(self, db: AsyncSession): 18 | self.db = db 19 | 20 | async def find(self, id: UUID4) -> Optional[ModelType]: 21 | stmt = select(self.model).where(self.model.id == id) 22 | result = await self.db.execute(stmt) 23 | return result.scalar_one_or_none() 24 | 25 | async def find_by(self, kwargs: Dict[str, Any]) -> Optional[ModelType]: 26 | filters = [getattr(self.model, key) == value for key, value in kwargs.items()] 27 | stmt = select(self.model).where(*filters) 28 | result = await self.db.execute(stmt) 29 | return result.scalar_one_or_none() 30 | 31 | async def find_all( 32 | self, where: Dict[str, Any] = {}, order_by: Optional[Any] = None 33 | ) -> Sequence[ModelType]: 34 | filters = [getattr(self.model, key) == value for key, value in where.items()] 35 | stmt = select(self.model).where(*filters) 36 | if order_by is not None: 37 | stmt = stmt.order_by(order_by) 38 | result = await self.db.execute(stmt) 39 | return result.scalars().all() 40 | 41 | async def create(self, data: CreateSchemaType) -> ModelType: 42 | instance = self.model.from_orm(data) 43 | self.db.add(instance) 44 | try: 45 | await self.db.commit() 46 | except Exception as e: 47 | await self.db.rollback() 48 | raise e 49 | await self.db.refresh(instance) 50 | return instance 51 | 52 | async def update( 53 | self, 54 | instance: ModelType, 55 | data: UpdateSchemaType, 56 | ) -> ModelType: 57 | data_dictionary = data.dict(exclude_unset=True) 58 | for key, value in data_dictionary.items(): 59 | setattr(instance, key, value) 60 | self.db.add(instance) 61 | try: 62 | await self.db.commit() 63 | except Exception as e: 64 | await self.db.rollback() 65 | raise e 66 | await self.db.refresh(instance) 67 | return instance 68 | 69 | async def delete(self, id: UUID4) -> ModelType: 70 | instance = await self.find(id) 71 | if instance is None: 72 | raise Exception(f"{self.model.__name__} not found") 73 | try: 74 | await self.db.delete(instance) 75 | await self.db.commit() 76 | except Exception as e: 77 | await self.db.rollback() 78 | raise e 79 | return instance 80 | 81 | async def delete_all(self, where: Dict[str, Any]) -> bool: 82 | filters = [getattr(self.model, key) == value for key, value in where.items()] 83 | stmt = delete(self.model).where(*filters) 84 | await self.db.execute(stmt) 85 | try: 86 | await self.db.commit() 87 | except Exception as e: 88 | await self.db.rollback() 89 | raise e 90 | return True 91 | -------------------------------------------------------------------------------- /fastapi_app/crud/domain.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from pydantic import BaseModel 3 | from .base import CRUDBase 4 | from fastapi_app import models 5 | 6 | 7 | class Domain(CRUDBase[models.Domain, models.DomainCreate, BaseModel]): 8 | def __init__(self, db: AsyncSession): 9 | super().__init__(db) 10 | self.model = models.Domain 11 | -------------------------------------------------------------------------------- /fastapi_app/crud/event.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import delete 2 | from pydantic import UUID4 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from fastapi_app import models 5 | from pydantic import BaseModel 6 | from .base import CRUDBase 7 | 8 | 9 | class Event(CRUDBase[models.Event, models.EventCreate, BaseModel]): 10 | def __init__(self, db: AsyncSession): 11 | super().__init__(db) 12 | self.model = models.Event 13 | 14 | async def delete_by_name(self, name: str, account_id: UUID4) -> bool: 15 | stmt = ( 16 | delete(self.model) 17 | .where(self.model.name == name) 18 | .where(self.model.account_id == account_id) 19 | ) 20 | await self.db.execute(stmt) 21 | try: 22 | await self.db.commit() 23 | except Exception as e: 24 | await self.db.rollback() 25 | raise e 26 | return True 27 | -------------------------------------------------------------------------------- /fastapi_app/crud/event_documentation.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Sequence 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | from sqlalchemy.sql.expression import select 4 | from sqlalchemy.orm import joinedload, selectinload 5 | from pydantic import BaseModel 6 | from fastapi_app import models 7 | from .base import CRUDBase 8 | 9 | 10 | class EventDocumentation( 11 | CRUDBase[models.EventDocumentation, models.EventDocumentationCreate, BaseModel] 12 | ): 13 | def __init__(self, db: AsyncSession): 14 | super().__init__(db) 15 | self.model = models.EventDocumentation 16 | 17 | async def find_all_with_schemas( 18 | self, where: Dict[str, Any] = {}, order_by: Optional[Any] = None 19 | ) -> Sequence[models.EventDocumentation]: 20 | filters = [getattr(self.model, key) == value for key, value in where.items()] 21 | stmt = ( 22 | select(self.model) 23 | .where(*filters) 24 | .options(joinedload("event_schema")) 25 | .options( 26 | selectinload("event_documentation_examples") 27 | .selectinload("event") 28 | .selectinload("screenshot") 29 | ) 30 | ) 31 | 32 | if order_by is not None: 33 | stmt = stmt.order_by(order_by) 34 | result = await self.db.execute(stmt) 35 | return result.scalars().all() 36 | 37 | async def find_by_with_schema( 38 | self, where: Dict[str, Any] 39 | ) -> Optional[models.EventDocumentation]: 40 | filters = [getattr(self.model, key) == value for key, value in where.items()] 41 | stmt = ( 42 | select(self.model) 43 | .where(*filters) 44 | .options(joinedload(self.model.event_schema)) 45 | ) 46 | result = await self.db.execute(stmt) 47 | return result.scalars().first() 48 | -------------------------------------------------------------------------------- /fastapi_app/crud/event_documentation_example.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from pydantic import BaseModel 3 | from fastapi_app import models 4 | from .base import CRUDBase 5 | 6 | 7 | class EventDocumentationExample( 8 | CRUDBase[ 9 | models.EventDocumentationExample, 10 | models.EventDocumentationExampleCreate, 11 | BaseModel, 12 | ] 13 | ): 14 | def __init__(self, db: AsyncSession): 15 | super().__init__(db) 16 | self.model = models.EventDocumentationExample 17 | -------------------------------------------------------------------------------- /fastapi_app/crud/event_schema.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from fastapi_app import models 3 | from pydantic import BaseModel 4 | from .base import CRUDBase 5 | 6 | 7 | class EventSchema(CRUDBase[models.EventSchema, models.EventSchemaCreate, BaseModel]): 8 | def __init__(self, db: AsyncSession): 9 | super().__init__(db) 10 | self.model = models.EventSchema 11 | -------------------------------------------------------------------------------- /fastapi_app/crud/screenshot.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | from io import BytesIO 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from pydantic import BaseModel 5 | from fastapi_app import models 6 | from fastapi_app.utils.s3_client import s3_client 7 | from fastapi_app.schemas import api as schemas_api 8 | from .base import CRUDBase 9 | 10 | 11 | class Screenshot( 12 | CRUDBase[ 13 | models.Screenshot, 14 | schemas_api.ScreenshotCreate, 15 | BaseModel, 16 | ] 17 | ): 18 | def __init__(self, db: AsyncSession): 19 | super().__init__(db) 20 | self.model = models.Screenshot 21 | 22 | async def create(self, data: schemas_api.ScreenshotCreate) -> models.Screenshot: 23 | file_key = f"{data.account_id}/{str(uuid4())}.jpeg" 24 | s3_client.upload_fileobj( 25 | BytesIO(data.image_data), 26 | self.model.bucket_name(), 27 | file_key, 28 | ExtraArgs={"ContentType": data.content_type}, 29 | ) 30 | 31 | db_instance = models.Screenshot.from_orm( 32 | models.ScreenshotCreate( 33 | file_key=file_key, 34 | content_type=data.content_type, 35 | account_id=data.account_id, 36 | event_id=data.event_id, 37 | ) 38 | ) 39 | self.db.add(db_instance) 40 | try: 41 | await self.db.commit() 42 | except Exception as e: 43 | await self.db.rollback() 44 | raise e 45 | await self.db.refresh(db_instance) 46 | return db_instance 47 | -------------------------------------------------------------------------------- /fastapi_app/crud/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from sqlalchemy.exc import IntegrityError 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from .base import CRUDBase 5 | from fastapi_app import models 6 | 7 | 8 | class EmailAlreadyExistsError(Exception): 9 | pass 10 | 11 | 12 | class User(CRUDBase[models.User, models.UserCreate, models.UserUpdate]): 13 | def __init__(self, db: AsyncSession): 14 | super().__init__(db) 15 | self.model = models.User 16 | 17 | async def create(self, data: models.UserCreate) -> models.User: 18 | try: 19 | return await super().create(data) 20 | except IntegrityError as e: 21 | if "UniqueViolationError" in str(object=e) and "email" in str(object=e): 22 | raise EmailAlreadyExistsError( 23 | f"User with this email already exists. Try signing in with your original SSO provider." 24 | ) from e 25 | raise e 26 | -------------------------------------------------------------------------------- /fastapi_app/database.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 3 | from sqlalchemy.orm import sessionmaker 4 | from fastapi_app import config, utils 5 | 6 | logger = utils.AppLogger().get_logger() 7 | 8 | engine = create_async_engine( 9 | config.get_settings().database_url, 10 | future=True, 11 | echo=True, 12 | ) 13 | 14 | # expire_on_commit=False will prevent attributes from being expired after commit. 15 | AsyncSessionFactory = sessionmaker( 16 | engine, autoflush=False, expire_on_commit=False, class_=AsyncSession 17 | ) 18 | 19 | 20 | # Dependency 21 | async def get_db() -> AsyncGenerator[AsyncSession, None]: 22 | async with AsyncSessionFactory() as session: 23 | logger.debug(f"ASYNC Pool: {engine.pool.status()}") 24 | yield session 25 | -------------------------------------------------------------------------------- /fastapi_app/db/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /fastapi_app/db/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | from sqlalchemy import engine_from_config 3 | from sqlalchemy import pool 4 | from fastapi_app.models.base import SQLModelBase 5 | from alembic import context 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | if config.config_file_name is not None: 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | target_metadata = SQLModelBase.metadata 20 | 21 | target_metadata.naming_convention = { 22 | "ix": "ix_%(column_0_label)s", 23 | "uq": "uq_%(table_name)s_%(column_0_name)s", 24 | "ck": "ck_%(table_name)s_%(constraint_name)s", 25 | "fk": "fk_%(table_name)s_%(column_0_name)" "s_%(referred_table_name)s", 26 | "pk": "pk_%(table_name)s", 27 | } 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def run_migrations_online() -> None: 60 | """Run migrations in 'online' mode. 61 | 62 | In this scenario we need to create an Engine 63 | and associate a connection with the context. 64 | 65 | """ 66 | connectable = engine_from_config( 67 | config.get_section(config.config_ini_section, {}), 68 | prefix="sqlalchemy.", 69 | poolclass=pool.NullPool, 70 | ) 71 | 72 | with connectable.connect() as connection: 73 | context.configure(connection=connection, target_metadata=target_metadata) 74 | 75 | with context.begin_transaction(): 76 | context.run_migrations() 77 | 78 | 79 | if context.is_offline_mode(): 80 | run_migrations_offline() 81 | else: 82 | run_migrations_online() 83 | -------------------------------------------------------------------------------- /fastapi_app/db/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_04_03_1551-184d20e99b62_create_initial_tables.py: -------------------------------------------------------------------------------- 1 | """create initial tables 2 | 3 | Revision ID: 184d20e99b62 4 | Revises: 5 | Create Date: 2023-03-31 19:31:13.839153 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "184d20e99b62" 16 | down_revision = None 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table( 23 | "accounts", 24 | sa.Column( 25 | "id", 26 | postgresql.UUID(as_uuid=True), 27 | primary_key=True, 28 | default=uuid4, 29 | ), 30 | sa.Column( 31 | "write_key", 32 | postgresql.UUID(as_uuid=True), 33 | nullable=False, 34 | index=True, 35 | unique=True, 36 | default=uuid4, 37 | ), 38 | sa.Column( 39 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 40 | ), 41 | sa.Column( 42 | "updated_at", 43 | sa.DateTime, 44 | server_default=sa.func.now(), 45 | onupdate=sa.func.now(), 46 | nullable=False, 47 | ), 48 | ) 49 | op.create_table( 50 | "events", 51 | sa.Column( 52 | "id", 53 | postgresql.UUID(as_uuid=True), 54 | primary_key=True, 55 | default=uuid4, 56 | ), 57 | sa.Column("name", sa.String, nullable=False), 58 | sa.Column("path", sa.String, nullable=False), 59 | sa.Column("domain", sa.String, nullable=False), 60 | sa.Column("provider", sa.String, nullable=False), 61 | sa.Column( 62 | # nullable=False does not work correctly with SQLModel 63 | "properties", 64 | postgresql.JSONB, 65 | ), 66 | sa.Column( 67 | "account_id", 68 | postgresql.UUID(as_uuid=True), 69 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 70 | nullable=False, 71 | index=True, 72 | ), 73 | sa.Column( 74 | "created_at", 75 | sa.DateTime, 76 | server_default=sa.func.now(), 77 | nullable=False, 78 | index=True, 79 | ), 80 | sa.Column( 81 | "updated_at", 82 | sa.DateTime, 83 | server_default=sa.func.now(), 84 | onupdate=sa.func.now(), 85 | nullable=False, 86 | ), 87 | ) 88 | op.create_index( 89 | index_name="index_events_on_name_and_account_id", 90 | table_name="events", 91 | columns=["name", "account_id"], 92 | ) 93 | 94 | 95 | def downgrade() -> None: 96 | op.drop_table("events") 97 | op.drop_table("accounts") 98 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_04_04_1551-e8fe31da6e29_create_domains_table.py: -------------------------------------------------------------------------------- 1 | """create domains table 2 | 3 | Revision ID: e8fe31da6e29 4 | Revises: 184d20e99b62 5 | Create Date: 2023-04-02 00:57:21.624935 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "e8fe31da6e29" 15 | down_revision = "184d20e99b62" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | op.create_table( 22 | "domains", 23 | sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4), 24 | sa.Column("name", sa.String, nullable=False), 25 | sa.Column( 26 | "account_id", 27 | postgresql.UUID(as_uuid=True), 28 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 29 | nullable=False, 30 | index=True, 31 | ), 32 | sa.Column( 33 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 34 | ), 35 | sa.Column( 36 | "updated_at", 37 | sa.DateTime, 38 | server_default=sa.func.now(), 39 | onupdate=sa.func.now(), 40 | nullable=False, 41 | ), 42 | ) 43 | 44 | 45 | def downgrade() -> None: 46 | op.drop_table("domains") 47 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_04_05_1551-b4a56b178155_create_users_table.py: -------------------------------------------------------------------------------- 1 | """create users table 2 | 3 | Revision ID: b4a56b178155 4 | Revises: e8fe31da6e29 5 | Create Date: 2023-04-05 15:51:21.095825 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "b4a56b178155" 16 | down_revision = "e8fe31da6e29" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table( 23 | "users", 24 | sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4), 25 | sa.Column("email", sa.String, nullable=False, unique=True, index=True), 26 | # first_name and last_name are optional because name is optional in GitHub API 27 | sa.Column("first_name", sa.String), 28 | sa.Column("last_name", sa.String), 29 | sa.Column("image_url", sa.String), 30 | sa.Column("supertokens_id", sa.String, nullable=False, unique=True, index=True), 31 | sa.Column( 32 | "account_id", 33 | postgresql.UUID(as_uuid=True), 34 | sa.ForeignKey("accounts.id"), 35 | nullable=True, 36 | index=True, 37 | ), 38 | sa.Column( 39 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 40 | ), 41 | sa.Column( 42 | "updated_at", 43 | sa.DateTime, 44 | server_default=sa.func.now(), 45 | onupdate=sa.func.now(), 46 | nullable=False, 47 | ), 48 | ) 49 | 50 | 51 | def downgrade() -> None: 52 | op.drop_table("users") 53 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_04_29_1630-801047f6478c_create_event_documentations_table.py: -------------------------------------------------------------------------------- 1 | """create event_documentations table 2 | 3 | Revision ID: 801047f6478c 4 | Revises: b4a56b178155 5 | Create Date: 2023-04-29 16:30:03.783890 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "801047f6478c" 16 | down_revision = "b4a56b178155" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table( 23 | "event_documentations", 24 | sa.Column( 25 | "id", 26 | postgresql.UUID(as_uuid=True), 27 | primary_key=True, 28 | default=uuid4, 29 | ), 30 | sa.Column("name", sa.String, nullable=False), 31 | sa.Column("last_seen", sa.DateTime, nullable=False), 32 | sa.Column("domains", sa.ARRAY(sa.String()), nullable=False, default=list), 33 | sa.Column("paths", sa.ARRAY(sa.String()), nullable=False, default=list), 34 | sa.Column( 35 | "account_id", 36 | postgresql.UUID(as_uuid=True), 37 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 38 | nullable=False, 39 | index=True, 40 | ), 41 | sa.Column( 42 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 43 | ), 44 | sa.Column( 45 | "updated_at", 46 | sa.DateTime, 47 | server_default=sa.func.now(), 48 | onupdate=sa.func.now(), 49 | nullable=False, 50 | ), 51 | ) 52 | op.create_index( 53 | "index_event_documentations_on_name_and_account_id", 54 | "event_documentations", 55 | ["name", "account_id"], 56 | unique=False, 57 | ) 58 | op.create_table( 59 | "event_schemas", 60 | sa.Column( 61 | "id", 62 | postgresql.UUID(as_uuid=True), 63 | primary_key=True, 64 | default=uuid4, 65 | ), 66 | sa.Column( 67 | # nullable=False does not work correctly with SQLModel 68 | "json_schema", 69 | postgresql.JSONB, 70 | ), 71 | sa.Column( 72 | "account_id", 73 | postgresql.UUID(as_uuid=True), 74 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 75 | nullable=False, 76 | index=True, 77 | ), 78 | sa.Column( 79 | "event_documentation_id", 80 | postgresql.UUID(as_uuid=True), 81 | sa.ForeignKey("event_documentations.id", ondelete="CASCADE"), 82 | index=True, 83 | nullable=False, 84 | ), 85 | sa.Column( 86 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 87 | ), 88 | sa.Column( 89 | "updated_at", 90 | sa.DateTime, 91 | server_default=sa.func.now(), 92 | onupdate=sa.func.now(), 93 | nullable=False, 94 | ), 95 | ) 96 | 97 | 98 | def downgrade() -> None: 99 | op.drop_table("event_schemas") 100 | op.drop_table("event_documentations") 101 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_04_30_1909-411e9f5bac7a_create_screenshots_table.py: -------------------------------------------------------------------------------- 1 | """create screenshots table 2 | 3 | Revision ID: 411e9f5bac7a 4 | Revises: 801047f6478c 5 | Create Date: 2023-04-30 19:09:38.968733 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "411e9f5bac7a" 16 | down_revision = "801047f6478c" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table( 23 | "screenshots", 24 | sa.Column( 25 | "id", 26 | postgresql.UUID(as_uuid=True), 27 | primary_key=True, 28 | default=uuid4, 29 | ), 30 | sa.Column("file_key", sa.String(), nullable=False, index=True), 31 | sa.Column("content_type", sa.String(), nullable=False), 32 | sa.Column( 33 | "event_id", 34 | postgresql.UUID(as_uuid=True), 35 | sa.ForeignKey("events.id", ondelete="CASCADE"), 36 | nullable=False, 37 | index=True, 38 | unique=True, 39 | ), 40 | sa.Column( 41 | "account_id", 42 | postgresql.UUID(as_uuid=True), 43 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 44 | nullable=False, 45 | index=True, 46 | ), 47 | sa.Column( 48 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 49 | ), 50 | sa.Column( 51 | "updated_at", 52 | sa.DateTime, 53 | server_default=sa.func.now(), 54 | onupdate=sa.func.now(), 55 | nullable=False, 56 | ), 57 | ) 58 | 59 | 60 | def downgrade() -> None: 61 | op.drop_table("screenshots") 62 | -------------------------------------------------------------------------------- /fastapi_app/db/versions/2023_05_01_0500-69df1b9d2b1a_create_event_documentation_examples_.py: -------------------------------------------------------------------------------- 1 | """create event_documentation_examples table 2 | 3 | Revision ID: 69df1b9d2b1a 4 | Revises: 411e9f5bac7a 5 | Create Date: 2023-05-01 05:00:08.598507 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | from uuid import uuid4 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "69df1b9d2b1a" 16 | down_revision = "411e9f5bac7a" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table( 23 | "event_documentation_examples", 24 | sa.Column( 25 | "id", 26 | postgresql.UUID(as_uuid=True), 27 | primary_key=True, 28 | default=uuid4, 29 | ), 30 | sa.Column( 31 | "event_documentation_id", 32 | postgresql.UUID(as_uuid=True), 33 | sa.ForeignKey("event_documentations.id", ondelete="CASCADE"), 34 | nullable=False, 35 | index=True, 36 | ), 37 | sa.Column( 38 | "event_id", 39 | postgresql.UUID(as_uuid=True), 40 | sa.ForeignKey("events.id", ondelete="CASCADE"), 41 | nullable=False, 42 | index=True, 43 | unique=True, 44 | ), 45 | sa.Column( 46 | "account_id", 47 | postgresql.UUID(as_uuid=True), 48 | sa.ForeignKey("accounts.id", ondelete="CASCADE"), 49 | nullable=False, 50 | index=True, 51 | ), 52 | sa.Column( 53 | "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False 54 | ), 55 | sa.Column( 56 | "updated_at", 57 | sa.DateTime, 58 | server_default=sa.func.now(), 59 | onupdate=sa.func.now(), 60 | nullable=False, 61 | ), 62 | ) 63 | 64 | 65 | def downgrade() -> None: 66 | op.drop_table("event_documentation_examples") 67 | -------------------------------------------------------------------------------- /fastapi_app/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Request, Depends 2 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 3 | from supertokens_python.recipe.session.framework.fastapi import ( 4 | verify_session as supertokens_verify_session, 5 | ) 6 | from supertokens_python.recipe.session import SessionContainer 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from fastapi_app import database, services, config 9 | 10 | 11 | async def get_db(request: Request, db: AsyncSession = Depends(database.get_db)) -> None: 12 | request.state.db = db 13 | 14 | 15 | token_auth_scheme = HTTPBearer() 16 | 17 | 18 | async def get_current_user(request: Request) -> None: 19 | user_id = request.state.session.get_user_id() 20 | user = await services.UsersService(db=request.state.db).get_user_with_account( 21 | supertokens_id=user_id 22 | ) 23 | if user is None: 24 | raise HTTPException(status_code=401, detail="Unauthenticated") 25 | request.state.current_user = user 26 | 27 | 28 | async def require_origin_header(request: Request) -> None: 29 | request_origin = request.headers.get("origin") 30 | if request_origin is None: 31 | raise HTTPException(status_code=400, detail="Missing origin header") 32 | 33 | 34 | async def verify_internal_auth_token( 35 | settings: config.Settings = Depends(config.get_settings), 36 | token: HTTPAuthorizationCredentials = Depends(token_auth_scheme), 37 | ) -> None: 38 | if token.credentials != settings.internal_auth_token: 39 | raise HTTPException(status_code=401, detail="Unauthenticated") 40 | 41 | 42 | async def verify_session( 43 | request: Request, 44 | session: SessionContainer = Depends(supertokens_verify_session()), 45 | ) -> None: 46 | request.state.session = session 47 | -------------------------------------------------------------------------------- /fastapi_app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi_app import subapps 3 | 4 | app = FastAPI(title="FastAPI App") 5 | 6 | 7 | app.mount("/client", subapps.client_app) 8 | app.mount("/api", subapps.web_app) 9 | app.mount("/internal", subapps.internal_app) 10 | -------------------------------------------------------------------------------- /fastapi_app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import * 2 | from .domain import * 3 | from .event import * 4 | from .event_documentation import * 5 | from .event_documentation_example import * 6 | from .event_schema import * 7 | from .screenshot import * 8 | from .user import * 9 | from .base import * 10 | -------------------------------------------------------------------------------- /fastapi_app/models/account.py: -------------------------------------------------------------------------------- 1 | from typing import List, TYPE_CHECKING, Union, Callable, ClassVar 2 | from sqlalchemy import text 3 | from uuid import uuid4, UUID 4 | from sqlmodel import Field, Relationship 5 | from .base import SQLModelBase, PydanticModelBase 6 | 7 | if TYPE_CHECKING: 8 | from .event import Event 9 | from .event_schema import EventSchema 10 | from .event_documentation import EventDocumentation 11 | from .screenshot import Screenshot 12 | from .domain import Domain 13 | from .user import User 14 | 15 | 16 | class Account(SQLModelBase, table=True): 17 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "accounts" 18 | 19 | write_key: UUID = Field( 20 | default_factory=uuid4, 21 | index=True, 22 | sa_column_kwargs={"server_default": text("gen_random_uuid()"), "unique": True}, 23 | ) 24 | 25 | events: List["Event"] = Relationship(back_populates="account") 26 | event_schemas: List["EventSchema"] = Relationship(back_populates="account") 27 | event_documentations: List["EventDocumentation"] = Relationship( 28 | back_populates="account" 29 | ) 30 | screenshots: List["Screenshot"] = Relationship(back_populates="account") 31 | domains: List["Domain"] = Relationship(back_populates="account") 32 | users: List["User"] = Relationship(back_populates="account") 33 | 34 | 35 | class AccountCreate(PydanticModelBase): 36 | pass 37 | -------------------------------------------------------------------------------- /fastapi_app/models/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import text 3 | from uuid import uuid4, UUID 4 | from sqlmodel import Field, SQLModel 5 | from pydantic import BaseModel 6 | 7 | 8 | class SQLModelBase(SQLModel): 9 | id: UUID = Field( 10 | default_factory=uuid4, 11 | primary_key=True, 12 | sa_column_kwargs={"server_default": text("gen_random_uuid()")}, 13 | ) 14 | 15 | created_at: datetime = Field( 16 | default_factory=datetime.utcnow, 17 | sa_column_kwargs={"server_default": text("current_timestamp(0)")}, 18 | ) 19 | 20 | updated_at: datetime = Field( 21 | default_factory=datetime.utcnow, 22 | sa_column_kwargs={ 23 | "server_default": text("current_timestamp(0)"), 24 | "onupdate": text("current_timestamp(0)"), 25 | }, 26 | ) 27 | 28 | 29 | class PydanticModelBase(BaseModel): 30 | class Config: 31 | orm_mode = True 32 | -------------------------------------------------------------------------------- /fastapi_app/models/base_file.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 3 | from sqlmodel import Field, Column, ForeignKey 4 | from fastapi_app.utils.s3_client import s3_client 5 | from .base import SQLModelBase, PydanticModelBase 6 | 7 | 8 | class BaseFile(SQLModelBase): 9 | file_key: str = Field(index=True) 10 | content_type: str = Field() 11 | account_id: UUID = Field( 12 | sa_column=Column( 13 | SA_UUID(as_uuid=True), 14 | ForeignKey("accounts.id", ondelete="CASCADE"), 15 | nullable=False, 16 | index=True, 17 | ), 18 | ) 19 | 20 | @classmethod 21 | def bucket_name(cls) -> str: 22 | raise NotImplementedError("Subclasses must implement this property") 23 | 24 | def image_url(self) -> str: 25 | signed_url = s3_client.generate_presigned_url( 26 | "get_object", 27 | Params={"Bucket": self.bucket_name(), "Key": self.file_key}, 28 | ExpiresIn=3600, # URL valid for 1 hour (3600 seconds) 29 | ) 30 | return signed_url 31 | 32 | 33 | class BaseFileCreate(PydanticModelBase): 34 | file_key: str 35 | content_type: str 36 | account_id: UUID 37 | -------------------------------------------------------------------------------- /fastapi_app/models/domain.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union, Callable, ClassVar 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from sqlmodel import Relationship, Field, Column, ForeignKey 5 | from .base import SQLModelBase, PydanticModelBase 6 | 7 | if TYPE_CHECKING: 8 | from .account import Account 9 | 10 | 11 | class Domain(SQLModelBase, table=True): 12 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "domains" 13 | 14 | name: str = Field() 15 | account_id: UUID = Field( 16 | sa_column=Column( 17 | SA_UUID(as_uuid=True), 18 | ForeignKey("accounts.id", ondelete="CASCADE"), 19 | nullable=False, 20 | index=True, 21 | ), 22 | ) 23 | 24 | account: "Account" = Relationship(back_populates="domains") 25 | 26 | 27 | class DomainCreate(PydanticModelBase): 28 | name: str 29 | account_id: UUID 30 | -------------------------------------------------------------------------------- /fastapi_app/models/event.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict, List, Union, Callable, ClassVar, Any, Optional 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from datetime import datetime 5 | from sqlmodel import Relationship, Field, ForeignKey 6 | from sqlalchemy import Column, Index, text 7 | from sqlalchemy.dialects.postgresql import JSONB 8 | from .base import SQLModelBase, PydanticModelBase 9 | 10 | if TYPE_CHECKING: 11 | from .account import Account 12 | from .screenshot import Screenshot 13 | from .event_documentation_example import EventDocumentationExample 14 | 15 | 16 | class Event(SQLModelBase, table=True): 17 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "events" 18 | 19 | __table_args__ = ( 20 | Index("index_events_on_name_and_account_id", "name", "account_id"), 21 | ) 22 | 23 | name: str = Field() 24 | path: str = Field() 25 | domain: str = Field() 26 | provider: str = Field() 27 | properties: Dict[str, Any] = Field(sa_column=Column(JSONB)) 28 | account_id: UUID = Field( 29 | sa_column=Column( 30 | SA_UUID(as_uuid=True), 31 | ForeignKey("accounts.id", ondelete="CASCADE"), 32 | nullable=False, 33 | index=True, 34 | ), 35 | ) 36 | created_at: datetime = Field( 37 | default_factory=datetime.utcnow, 38 | sa_column_kwargs={"server_default": text("current_timestamp(0)")}, 39 | index=True, 40 | ) 41 | 42 | account: "Account" = Relationship(back_populates="events") 43 | screenshot: "Screenshot" = Relationship( 44 | back_populates="event", sa_relationship_kwargs={"uselist": False} 45 | ) 46 | event_documentation_example: Optional["EventDocumentationExample"] = Relationship( 47 | back_populates="event", sa_relationship_kwargs={"uselist": False} 48 | ) 49 | 50 | 51 | class EventCreate(PydanticModelBase): 52 | name: str 53 | path: str 54 | domain: str 55 | provider: str 56 | properties: Dict[str, Any] 57 | account_id: UUID 58 | -------------------------------------------------------------------------------- /fastapi_app/models/event_documentation.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Union, Callable, ClassVar 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from datetime import datetime 5 | from sqlmodel import Relationship, Field, ARRAY, String, Column, ForeignKey 6 | from sqlalchemy import Index 7 | from .base import SQLModelBase, PydanticModelBase 8 | 9 | if TYPE_CHECKING: 10 | from .account import Account 11 | from .event_documentation_example import EventDocumentationExample 12 | from .event_schema import EventSchema 13 | 14 | 15 | class EventDocumentation(SQLModelBase, table=True): 16 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "event_documentations" 17 | 18 | name: str = Field() 19 | last_seen: datetime = Field() 20 | domains: List[str] = Field( 21 | sa_column=Column(ARRAY(String), default=lambda: [], nullable=False) 22 | ) 23 | paths: List[str] = Field( 24 | sa_column=Column(ARRAY(String), default=lambda: [], nullable=False) 25 | ) 26 | account_id: UUID = Field( 27 | sa_column=Column( 28 | SA_UUID(as_uuid=True), 29 | ForeignKey("accounts.id", ondelete="CASCADE"), 30 | nullable=False, 31 | index=True, 32 | ), 33 | ) 34 | 35 | event_documentation_examples: List["EventDocumentationExample"] = Relationship( 36 | back_populates="event_documentation", 37 | ) 38 | event_schema: "EventSchema" = Relationship( 39 | back_populates="event_documentation", sa_relationship_kwargs={"uselist": False} 40 | ) 41 | account: "Account" = Relationship(back_populates="event_documentations") 42 | 43 | __table_args__ = ( 44 | Index( 45 | "index_event_documentations_on_name_and_account_id", "name", "account_id" 46 | ), 47 | ) 48 | 49 | 50 | class EventDocumentationCreate(PydanticModelBase): 51 | name: str 52 | last_seen: datetime 53 | account_id: UUID 54 | domains: List[str] 55 | paths: List[str] 56 | 57 | 58 | class EventDocumentationUpdate(PydanticModelBase): 59 | last_seen: datetime 60 | -------------------------------------------------------------------------------- /fastapi_app/models/event_documentation_example.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union, Callable, ClassVar 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from sqlmodel import Relationship, Field, Column, ForeignKey 5 | from .base import SQLModelBase, PydanticModelBase 6 | 7 | if TYPE_CHECKING: 8 | from .account import Account 9 | from .event import Event 10 | from .event_documentation import EventDocumentation 11 | 12 | 13 | class EventDocumentationExample(SQLModelBase, table=True): 14 | __tablename__: ClassVar[ 15 | Union[str, Callable[..., str]] 16 | ] = "event_documentation_examples" 17 | 18 | event_documentation_id: UUID = Field( 19 | sa_column=Column( 20 | SA_UUID(as_uuid=True), 21 | ForeignKey("event_documentations.id", ondelete="CASCADE"), 22 | nullable=False, 23 | index=True, 24 | ), 25 | ) 26 | event_id: UUID = Field( 27 | sa_column=Column( 28 | SA_UUID(as_uuid=True), 29 | ForeignKey("events.id", ondelete="CASCADE"), 30 | nullable=False, 31 | index=True, 32 | unique=True, 33 | ), 34 | ) 35 | account_id: UUID = Field( 36 | sa_column=Column( 37 | SA_UUID(as_uuid=True), 38 | ForeignKey("accounts.id", ondelete="CASCADE"), 39 | nullable=False, 40 | index=True, 41 | ), 42 | ) 43 | 44 | event_documentation: "EventDocumentation" = Relationship( 45 | back_populates="event_documentation_examples", 46 | ) 47 | event: "Event" = Relationship(back_populates="event_documentation_example") 48 | account: "Account" = Relationship() 49 | 50 | 51 | class EventDocumentationExampleCreate(PydanticModelBase): 52 | event_documentation_id: UUID 53 | event_id: UUID 54 | account_id: UUID 55 | -------------------------------------------------------------------------------- /fastapi_app/models/event_schema.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union, Callable, ClassVar, Any, Dict 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from sqlmodel import Relationship, Field, ForeignKey 5 | from sqlalchemy import Column 6 | from sqlalchemy.dialects.postgresql import JSONB 7 | from .base import SQLModelBase, PydanticModelBase 8 | 9 | if TYPE_CHECKING: 10 | from .account import Account 11 | from .event_documentation import EventDocumentation 12 | 13 | 14 | class EventSchema(SQLModelBase, table=True): 15 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "event_schemas" 16 | 17 | json_schema: Dict[str, Any] = Field(default={}, sa_column=Column(JSONB)) 18 | account_id: UUID = Field( 19 | sa_column=Column( 20 | SA_UUID(as_uuid=True), 21 | ForeignKey("accounts.id", ondelete="CASCADE"), 22 | nullable=False, 23 | index=True, 24 | ), 25 | ) 26 | event_documentation_id: UUID = Field( 27 | sa_column=Column( 28 | SA_UUID(as_uuid=True), 29 | ForeignKey("event_documentations.id", ondelete="CASCADE"), 30 | nullable=False, 31 | index=True, 32 | ), 33 | ) 34 | 35 | event_documentation: "EventDocumentation" = Relationship( 36 | back_populates="event_schema" 37 | ) 38 | account: "Account" = Relationship(back_populates="event_schemas") 39 | 40 | 41 | class EventSchemaCreate(PydanticModelBase): 42 | json_schema: Dict[str, Any] 43 | account_id: UUID 44 | event_documentation_id: UUID 45 | 46 | 47 | class EventSchemaUpdate(PydanticModelBase): 48 | json_schema: Dict[str, Any] 49 | -------------------------------------------------------------------------------- /fastapi_app/models/screenshot.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union, Callable, ClassVar 2 | from uuid import UUID 3 | from sqlalchemy.dialects.postgresql import UUID as SA_UUID 4 | from sqlmodel import Relationship, Field, Column, ForeignKey 5 | from .base_file import BaseFile, BaseFileCreate 6 | 7 | if TYPE_CHECKING: 8 | from .account import Account 9 | from .event import Event 10 | 11 | 12 | class Screenshot(BaseFile, table=True): 13 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "screenshots" 14 | 15 | event_id: UUID = Field( 16 | sa_column=Column( 17 | SA_UUID(as_uuid=True), 18 | ForeignKey("events.id", ondelete="CASCADE"), 19 | nullable=False, 20 | index=True, 21 | unique=True, 22 | ), 23 | ) 24 | account_id: UUID = Field( 25 | sa_column=Column( 26 | SA_UUID(as_uuid=True), 27 | ForeignKey("accounts.id", ondelete="CASCADE"), 28 | nullable=False, 29 | index=True, 30 | ), 31 | ) 32 | 33 | @classmethod 34 | def bucket_name(cls) -> str: 35 | return "fastapi_app-screenshots" 36 | 37 | event: "Event" = Relationship(back_populates="screenshot") 38 | account: "Account" = Relationship(back_populates="screenshots") 39 | 40 | 41 | class ScreenshotCreate(BaseFileCreate): 42 | event_id: UUID 43 | -------------------------------------------------------------------------------- /fastapi_app/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, ClassVar, Union, Callable 2 | from uuid import UUID 3 | from sqlmodel import Relationship, Field 4 | from .base import SQLModelBase, PydanticModelBase 5 | 6 | if TYPE_CHECKING: 7 | from .account import Account 8 | 9 | 10 | class User(SQLModelBase, table=True): 11 | __tablename__: ClassVar[Union[str, Callable[..., str]]] = "users" 12 | 13 | email: str = Field(unique=True, index=True) 14 | first_name: Optional[str] = Field(default=None) 15 | last_name: Optional[str] = Field(default=None) 16 | image_url: Optional[str] = Field(default=None) 17 | supertokens_id: str = Field(unique=True, index=True) 18 | account_id: Optional[UUID] = Field( 19 | default=None, foreign_key="accounts.id", index=True 20 | ) 21 | 22 | account: Optional["Account"] = Relationship(back_populates="users") 23 | 24 | 25 | class UserCreate(PydanticModelBase): 26 | email: str 27 | first_name: Optional[str] 28 | last_name: Optional[str] 29 | image_url: Optional[str] 30 | supertokens_id: str 31 | 32 | 33 | class UserUpdate(PydanticModelBase): 34 | first_name: Optional[str] = None 35 | last_name: Optional[str] = None 36 | image_url: Optional[str] = None 37 | account_id: Optional[UUID] = None 38 | -------------------------------------------------------------------------------- /fastapi_app/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petrgazarov/FastAPI-app/664feaaca5fc2eee5966a1f2e4a6a9be32b3b6e6/fastapi_app/schemas/__init__.py -------------------------------------------------------------------------------- /fastapi_app/schemas/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import * 2 | from .domain import * 3 | from .event import * 4 | from .screenshot import * 5 | from .user import * 6 | from .event_documentation import * 7 | -------------------------------------------------------------------------------- /fastapi_app/schemas/api/domain.py: -------------------------------------------------------------------------------- 1 | from fastapi_app import models 2 | 3 | 4 | class DomainCreate(models.PydanticModelBase): 5 | name: str 6 | -------------------------------------------------------------------------------- /fastapi_app/schemas/api/event.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from uuid import UUID 3 | from fastapi_app import models 4 | 5 | 6 | class EventCreate_EventData(models.PydanticModelBase): 7 | name: str 8 | path: str 9 | domain: str 10 | provider: str 11 | properties: Dict[str, Any] 12 | image_data: str 13 | 14 | 15 | class EventCreate(models.PydanticModelBase): 16 | event_data: EventCreate_EventData 17 | write_key: UUID 18 | -------------------------------------------------------------------------------- /fastapi_app/schemas/api/event_documentation.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List 2 | from datetime import datetime 3 | from uuid import UUID 4 | from fastapi_app import models 5 | 6 | 7 | class EventDocumentationsGet_EventSchema(models.PydanticModelBase): 8 | json_schema: Dict[str, Any] 9 | 10 | 11 | class EventDocumentationsGet_Screenshot(models.PydanticModelBase): 12 | id: UUID 13 | image_url: str 14 | 15 | 16 | class EventDocumentationsGet_Event(models.PydanticModelBase): 17 | id: UUID 18 | name: str 19 | path: str 20 | domain: str 21 | provider: str 22 | properties: Dict[str, Any] 23 | screenshot: EventDocumentationsGet_Screenshot 24 | 25 | 26 | class EventDocumentationsGet_EventDocumentationExample(models.PydanticModelBase): 27 | id: UUID 28 | event: EventDocumentationsGet_Event 29 | 30 | 31 | class EventDocumentationsGet(models.PydanticModelBase): 32 | id: UUID 33 | name: str 34 | last_seen: datetime 35 | domains: List[str] 36 | paths: List[str] 37 | event_schema: EventDocumentationsGet_EventSchema 38 | event_documentation_examples: List[EventDocumentationsGet_EventDocumentationExample] 39 | -------------------------------------------------------------------------------- /fastapi_app/schemas/api/screenshot.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | from fastapi_app import models 3 | 4 | 5 | class ScreenshotCreate(models.PydanticModelBase): 6 | image_data: bytes 7 | content_type: str 8 | account_id: UUID 9 | event_id: UUID 10 | -------------------------------------------------------------------------------- /fastapi_app/schemas/api/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from uuid import UUID 3 | from fastapi_app import models 4 | 5 | 6 | class UserGet_Domain(models.PydanticModelBase): 7 | id: UUID 8 | name: str 9 | 10 | 11 | class UserGet_Account(models.PydanticModelBase): 12 | id: UUID 13 | write_key: UUID 14 | domains: List[UserGet_Domain] 15 | 16 | 17 | class UserGet(models.PydanticModelBase): 18 | id: UUID 19 | first_name: Optional[str] 20 | last_name: Optional[str] 21 | email: str 22 | image_url: Optional[str] 23 | account: Optional[UserGet_Account] 24 | 25 | 26 | class UserUpdate(models.PydanticModelBase): 27 | first_name: Optional[str] 28 | last_name: Optional[str] 29 | -------------------------------------------------------------------------------- /fastapi_app/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import * 2 | from .event import * 3 | from .domain import * 4 | from .user import * 5 | from .event_documentation import * 6 | -------------------------------------------------------------------------------- /fastapi_app/services/account.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | from uuid import UUID 4 | from fastapi_app import crud, models 5 | from .base import BaseService 6 | 7 | 8 | class AccountsService(BaseService): 9 | db: AsyncSession 10 | 11 | def __init__(self, db: AsyncSession, user_id: UUID): 12 | self.db = db 13 | self.user_id = user_id 14 | 15 | async def create_account(self) -> models.Account: 16 | return await crud.Account(db=self.db).create(models.AccountCreate()) 17 | -------------------------------------------------------------------------------- /fastapi_app/services/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from pydantic import UUID4 3 | 4 | 5 | class BaseService: 6 | account_id: UUID4 7 | db: AsyncSession 8 | 9 | def __init__(self, db: AsyncSession, account_id: UUID4): 10 | self.account_id = account_id 11 | self.db = db 12 | -------------------------------------------------------------------------------- /fastapi_app/services/domain.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi_app import crud, models 3 | from .base import BaseService 4 | from uuid import UUID 5 | 6 | 7 | class DomainsService(BaseService): 8 | async def create_domain(self, domain: models.DomainCreate) -> models.Domain: 9 | return await crud.Domain(db=self.db).create(domain) 10 | 11 | async def delete_domain(self, id: UUID) -> models.Domain: 12 | return await crud.Domain(db=self.db).delete(id=id) 13 | -------------------------------------------------------------------------------- /fastapi_app/services/event.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from fastapi_app import crud, models 3 | from fastapi_app.schemas import api as schemas_api 4 | from .base import BaseService 5 | 6 | 7 | class EventsService(BaseService): 8 | async def create_event( 9 | self, event_data: schemas_api.EventCreate_EventData 10 | ) -> models.Event: 11 | event = await crud.Event(db=self.db).create( 12 | models.EventCreate( 13 | name=event_data.name, 14 | path=event_data.path, 15 | domain=event_data.domain, 16 | provider=event_data.provider, 17 | properties=event_data.properties, 18 | account_id=self.account_id, 19 | ) 20 | ) 21 | if event: 22 | decoded_image_data = base64.b64decode(event_data.image_data.split(",")[1]) 23 | await crud.Screenshot(db=self.db).create( 24 | schemas_api.ScreenshotCreate( 25 | image_data=decoded_image_data, 26 | content_type="image/jpeg", 27 | account_id=self.account_id, 28 | event_id=event.id, 29 | ) 30 | ) 31 | return event 32 | 33 | async def delete_events_by_name(self, name: str) -> bool: 34 | return await crud.Event(db=self.db).delete_by_name( 35 | name=name, account_id=self.account_id 36 | ) 37 | 38 | async def delete_all_events(self) -> bool: 39 | await crud.Event(db=self.db).delete_all({"account_id": self.account_id}) 40 | await crud.EventDocumentation(db=self.db).delete_all( 41 | {"account_id": self.account_id} 42 | ) 43 | return True 44 | -------------------------------------------------------------------------------- /fastapi_app/services/event_documentation.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | from sqlalchemy import asc 3 | from genson import SchemaBuilder # type: ignore 4 | from fastapi_app import crud, models 5 | from .base import BaseService 6 | 7 | 8 | class EventDocumentationsService(BaseService): 9 | async def get_event_documentations(self) -> Sequence[models.EventDocumentation]: 10 | return await crud.EventDocumentation(db=self.db).find_all_with_schemas( 11 | where={"account_id": self.account_id}, 12 | order_by=asc(getattr(models.EventDocumentation, "name")), 13 | ) 14 | 15 | async def calculate_event_documentation( 16 | self, event: models.Event 17 | ) -> models.EventDocumentation: 18 | event_documentation = await crud.EventDocumentation( 19 | db=self.db 20 | ).find_by_with_schema({"name": event.name, "account_id": event.account_id}) 21 | if event_documentation is None: 22 | event_documentation = await self.create_event_documentation_for_event(event) 23 | else: 24 | event_documentation = await self.update_event_documentation_for_event( 25 | event_documentation, event 26 | ) 27 | return event_documentation 28 | 29 | async def create_event_documentation_for_event( 30 | self, event: models.Event 31 | ) -> models.EventDocumentation: 32 | event_documentation = await crud.EventDocumentation(db=self.db).create( 33 | models.EventDocumentationCreate( 34 | name=event.name, 35 | last_seen=event.created_at, 36 | account_id=event.account_id, 37 | domains=[event.domain], 38 | paths=[event.path], 39 | ) 40 | ) 41 | schema_builder = SchemaBuilder() 42 | schema_builder.add_object(event.properties) 43 | await crud.EventSchema(db=self.db).create( 44 | models.EventSchemaCreate( 45 | json_schema=schema_builder.to_schema(), 46 | account_id=self.account_id, 47 | event_documentation_id=event_documentation.id, 48 | ) 49 | ) 50 | await crud.EventDocumentationExample(db=self.db).create( 51 | models.EventDocumentationExampleCreate( 52 | event_documentation_id=event_documentation.id, 53 | event_id=event.id, 54 | account_id=self.account_id, 55 | ) 56 | ) 57 | return event_documentation 58 | 59 | async def update_event_documentation_for_event( 60 | self, event_documentation: models.EventDocumentation, event: models.Event 61 | ) -> models.EventDocumentation: 62 | schema_builder = SchemaBuilder() 63 | schema_builder.add_schema(event_documentation.event_schema.json_schema) 64 | schema_builder.add_object(event.properties) 65 | if event.path not in event_documentation.paths: 66 | event_documentation.paths.append(event.path) 67 | if event.domain not in event_documentation.domains: 68 | event_documentation.domains.append(event.domain) 69 | await crud.EventDocumentation(db=self.db).update( 70 | instance=event_documentation, 71 | data=models.EventDocumentationUpdate(last_seen=event.created_at), 72 | ) 73 | await crud.EventSchema(db=self.db).update( 74 | instance=event_documentation.event_schema, 75 | data=models.EventSchemaUpdate(json_schema=schema_builder.to_schema()), 76 | ) 77 | 78 | # TODO: update EventDocumentationExamples 79 | return event_documentation 80 | -------------------------------------------------------------------------------- /fastapi_app/services/user.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import select 2 | from sqlalchemy.orm import joinedload 3 | from fastapi_app import models, crud 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from typing import Optional 6 | 7 | 8 | class UsersService: 9 | db: AsyncSession 10 | 11 | def __init__(self, db: AsyncSession): 12 | self.db = db 13 | 14 | async def get_user_with_account(self, supertokens_id: str) -> Optional[models.User]: 15 | stmt = ( 16 | select(models.User) 17 | .where(models.User.supertokens_id == supertokens_id) 18 | .options( 19 | joinedload(models.User.account, innerjoin=False).selectinload( 20 | models.Account.domains 21 | ) 22 | ) 23 | ) 24 | result = await self.db.execute(stmt) 25 | return result.scalar_one_or_none() 26 | 27 | async def create_user(self, payload: models.UserCreate) -> Optional[models.User]: 28 | return await crud.User(db=self.db).create(payload) 29 | 30 | async def update_user( 31 | self, instance: models.User, payload: models.UserUpdate 32 | ) -> models.User: 33 | return await crud.User(db=self.db).update(instance, payload) 34 | -------------------------------------------------------------------------------- /fastapi_app/subapps/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import * 2 | from .web_app import * 3 | from .internal import * 4 | -------------------------------------------------------------------------------- /fastapi_app/subapps/client.py: -------------------------------------------------------------------------------- 1 | from fastapi import ( 2 | FastAPI, 3 | Depends, 4 | Request, 5 | Response, 6 | HTTPException, 7 | ) 8 | from fastapi.responses import JSONResponse 9 | from fastapi.middleware.cors import CORSMiddleware 10 | from fastapi_app import services, crud, dependencies, utils, models 11 | from fastapi_app.background_tasks import calculate_event_documentation 12 | from fastapi_app.schemas import api as schemas_api 13 | 14 | logger = utils.AppLogger().get_logger() 15 | 16 | client_app = FastAPI( 17 | dependencies=[ 18 | Depends(dependencies.get_db), 19 | ], 20 | ) 21 | 22 | client_app.add_middleware( 23 | CORSMiddleware, 24 | allow_origins=["*"], 25 | allow_credentials=True, 26 | allow_methods=["*"], 27 | allow_headers=["*"], 28 | ) 29 | 30 | 31 | @client_app.exception_handler(Exception) 32 | async def unhandled_exception_handler(request: Request, exc: Exception) -> Response: 33 | logger.error(exc) 34 | return JSONResponse( 35 | status_code=500, 36 | content={"error": "Internal server error"}, 37 | ) 38 | 39 | 40 | client_app.router.route_class = utils.ValidationErrorLoggingRoute 41 | 42 | 43 | @client_app.post("/events") 44 | async def create_event( 45 | request: Request, 46 | payload: schemas_api.EventCreate, 47 | ) -> models.Event: 48 | account = await crud.Account(db=request.state.db).find_by( 49 | {"write_key": payload.write_key} 50 | ) 51 | if account is None: 52 | raise HTTPException(status_code=404) 53 | event = await services.EventsService( 54 | db=request.state.db, account_id=account.id 55 | ).create_event(payload.event_data) 56 | 57 | calculate_event_documentation.apply_async( 58 | args=[account.id, event.id], 59 | message_properties={"MessageGroupId": account.id}, 60 | ) 61 | return event 62 | -------------------------------------------------------------------------------- /fastapi_app/subapps/internal.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import FastAPI, Depends, Request, HTTPException 3 | from fastapi_app import models, dependencies, services, crud, utils 4 | 5 | internal_app = FastAPI( 6 | dependencies=[ 7 | Depends(dependencies.verify_internal_auth_token), 8 | Depends(dependencies.get_db), 9 | ], 10 | ) 11 | 12 | internal_app.router.route_class = utils.ValidationErrorLoggingRoute 13 | 14 | 15 | @internal_app.post("/users", response_model=models.User) 16 | async def create_user(request: Request, payload: models.UserCreate) -> Any: 17 | try: 18 | return await services.UsersService(db=request.state.db).create_user(payload) 19 | except crud.EmailAlreadyExistsError as e: 20 | raise HTTPException(status_code=400, detail=str(e)) 21 | -------------------------------------------------------------------------------- /fastapi_app/subapps/web_app.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | from fastapi import FastAPI, Depends, Request 3 | from fastapi.routing import APIRoute 4 | from fastapi.responses import JSONResponse 5 | from starlette.middleware.cors import CORSMiddleware 6 | from supertokens_python.framework.fastapi import get_middleware 7 | from supertokens_python import get_all_cors_headers 8 | from fastapi_app import models, services, dependencies, utils, config 9 | from fastapi_app.utils.init_supertokens import init_supertokens 10 | from fastapi_app.schemas import api as schemas_api 11 | from uuid import UUID 12 | 13 | logger = utils.AppLogger().get_logger() 14 | 15 | 16 | def custom_generate_unique_id(route: APIRoute) -> str: 17 | return f"{route.tags[0]}-{route.name}" 18 | 19 | 20 | settings = config.get_settings() 21 | 22 | init_supertokens() 23 | 24 | web_app = FastAPI( 25 | dependencies=[ 26 | Depends(dependencies.verify_session), 27 | Depends(dependencies.get_db), 28 | Depends(dependencies.get_current_user), 29 | ], 30 | generate_unique_id_function=custom_generate_unique_id, 31 | ) 32 | 33 | web_app.add_middleware(get_middleware()) 34 | web_app.add_middleware( 35 | CORSMiddleware, 36 | allow_origins=[settings.app_url], 37 | allow_credentials=True, 38 | allow_methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], 39 | allow_headers=["Content-Type"] + get_all_cors_headers(), 40 | ) 41 | 42 | web_app.router.route_class = utils.ValidationErrorLoggingRoute 43 | 44 | 45 | @web_app.exception_handler(Exception) 46 | async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: 47 | logger.error(exc) 48 | return JSONResponse( 49 | status_code=500, 50 | content={"error": "Internal server error"}, 51 | ) 52 | 53 | 54 | @web_app.get("/event_documentations", tags=["event_documentations"]) 55 | async def get_event_documentations( 56 | request: Request, 57 | ) -> Sequence[schemas_api.EventDocumentationsGet]: 58 | current_user = request.state.current_user 59 | event_documentations = await services.EventDocumentationsService( 60 | account_id=current_user.account.id, db=request.state.db 61 | ).get_event_documentations() 62 | 63 | result = [ 64 | schemas_api.EventDocumentationsGet( 65 | id=event_doc.id, 66 | name=event_doc.name, 67 | last_seen=event_doc.last_seen, 68 | domains=event_doc.domains, 69 | paths=event_doc.paths, 70 | event_schema=schemas_api.EventDocumentationsGet_EventSchema( 71 | json_schema=event_doc.event_schema.json_schema 72 | ), 73 | event_documentation_examples=list( 74 | map( 75 | lambda example: schemas_api.EventDocumentationsGet_EventDocumentationExample( 76 | id=example.id, 77 | event=schemas_api.EventDocumentationsGet_Event( 78 | id=example.event.id, 79 | name=example.event.name, 80 | path=example.event.path, 81 | domain=example.event.domain, 82 | provider=example.event.provider, 83 | properties=example.event.properties, 84 | screenshot=schemas_api.EventDocumentationsGet_Screenshot( 85 | id=example.event.screenshot.id, 86 | image_url=example.event.screenshot.image_url(), 87 | ), 88 | ), 89 | ), 90 | event_doc.event_documentation_examples, 91 | ) 92 | ), 93 | ) 94 | for event_doc in event_documentations 95 | ] 96 | return result 97 | 98 | 99 | @web_app.delete("/events/{name}", tags=["events"]) 100 | async def delete_events_by_name(name: str, request: Request) -> bool: 101 | current_user = request.state.current_user 102 | return await services.EventsService( 103 | account_id=current_user.account.id, db=request.state.db 104 | ).delete_events_by_name(name) 105 | 106 | 107 | @web_app.delete("/events", tags=["events"]) 108 | async def delete_all_events(request: Request) -> bool: 109 | current_user = request.state.current_user 110 | return await services.EventsService( 111 | account_id=current_user.account.id, db=request.state.db 112 | ).delete_all_events() 113 | 114 | 115 | @web_app.post("/domains", tags=["domains"]) 116 | async def create_domain( 117 | payload: schemas_api.DomainCreate, request: Request 118 | ) -> models.Domain: 119 | current_user = request.state.current_user 120 | domain_data = models.DomainCreate( 121 | name=payload.name, 122 | account_id=current_user.account.id, 123 | ) 124 | return await services.DomainsService( 125 | account_id=current_user.account.id, db=request.state.db 126 | ).create_domain(domain_data) 127 | 128 | 129 | @web_app.delete("/domains/{id}", tags=["domains"]) 130 | async def delete_domain(id: UUID, request: Request) -> models.Domain: 131 | current_user = request.state.current_user 132 | return await services.DomainsService( 133 | account_id=current_user.account.id, db=request.state.db 134 | ).delete_domain(id=id) 135 | 136 | 137 | @web_app.get("/users/me", response_model=schemas_api.UserGet, tags=["users"]) 138 | async def get_current_user(request: Request) -> Any: 139 | return request.state.current_user 140 | 141 | 142 | @web_app.patch("/users/me", tags=["users"]) 143 | async def update_current_user( 144 | request: Request, payload: schemas_api.UserUpdate 145 | ) -> models.User: 146 | updated_user = await services.UsersService(db=request.state.db).update_user( 147 | instance=request.state.current_user, 148 | payload=models.UserUpdate.from_orm(payload), 149 | ) 150 | return updated_user 151 | 152 | 153 | @web_app.post("/accounts", tags=["accounts"]) 154 | async def create_account(request: Request) -> models.Account: 155 | current_user = request.state.current_user 156 | account = await services.AccountsService( 157 | db=request.state.db, user_id=current_user.id 158 | ).create_account() 159 | update_user_data = models.UserUpdate(account_id=account.id) 160 | await services.UsersService(db=request.state.db).update_user( 161 | instance=current_user, payload=update_user_data 162 | ) 163 | return account 164 | -------------------------------------------------------------------------------- /fastapi_app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import * 2 | from .route_handler import * 3 | -------------------------------------------------------------------------------- /fastapi_app/utils/init_supertokens.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Dict, Any 2 | from supertokens_python import init, InputAppInfo, SupertokensConfig 3 | from supertokens_python.recipe import thirdparty, session 4 | from supertokens_python.recipe.thirdparty import Github 5 | from supertokens_python.recipe.thirdparty.interfaces import ( 6 | APIInterface, 7 | APIOptions, 8 | SignInUpPostOkResult, 9 | SignInUpPostNoEmailGivenByProviderResponse, 10 | ) 11 | from supertokens_python.recipe.thirdparty.provider import Provider 12 | from supertokens_python.recipe import dashboard 13 | from supertokens_python.types import GeneralErrorResponse 14 | import requests 15 | from fastapi_app import database, services, models, config 16 | from .logger import AppLogger 17 | 18 | 19 | settings = config.get_settings() 20 | 21 | logger = AppLogger().get_logger() 22 | 23 | 24 | class GitHubApiError(Exception): 25 | pass 26 | 27 | 28 | def get_github_user_info(access_token: str) -> Dict[str, str | None]: 29 | headers = { 30 | "Authorization": f"Bearer {access_token}", 31 | "Content-Type": "application/json", 32 | } 33 | 34 | response = requests.get( 35 | "https://api.github.com/user", 36 | headers=headers, 37 | ) 38 | 39 | if response.status_code == 200: 40 | user_data = response.json() 41 | name = user_data["name"] 42 | return { 43 | "email": user_data["email"], 44 | "first_name": " ".join(name.split(" ")[:-1]) if name else None, 45 | "last_name": name.split(" ")[-1] if name else None, 46 | "image_url": user_data["avatar_url"], 47 | } 48 | else: 49 | raise GitHubApiError("Error: {response.status_code} - {response.text}") 50 | 51 | 52 | def override_thirdparty_apis(original_implementation: APIInterface) -> APIInterface: 53 | original_sign_in_up_post = original_implementation.sign_in_up_post 54 | 55 | async def sign_in_up_post( 56 | provider: Provider, 57 | code: str, 58 | redirect_uri: str, 59 | client_id: Union[str, None], 60 | auth_code_response: Union[Dict[str, Any], None], 61 | api_options: APIOptions, 62 | user_context: Dict[str, Any], 63 | ) -> ( 64 | SignInUpPostOkResult 65 | | SignInUpPostNoEmailGivenByProviderResponse 66 | | GeneralErrorResponse 67 | ): 68 | response = await original_sign_in_up_post( 69 | provider, 70 | code, 71 | redirect_uri, 72 | client_id, 73 | auth_code_response, 74 | api_options, 75 | user_context, 76 | ) 77 | 78 | if isinstance(response, SignInUpPostOkResult): 79 | match provider.id: 80 | case "github": 81 | user_info = get_github_user_info( 82 | access_token=response.auth_code_response["access_token"], 83 | ) 84 | case _: 85 | raise Exception("Unsupported provider") 86 | 87 | if response.created_new_user: 88 | db_gen = database.get_db() 89 | db = await anext(db_gen) 90 | await services.UsersService(db=db).create_user( 91 | models.UserCreate( 92 | email=str(user_info["email"]), 93 | first_name=user_info["first_name"], 94 | last_name=user_info["last_name"], 95 | supertokens_id=response.user.user_id, 96 | image_url=user_info["image_url"], 97 | ) 98 | ) 99 | 100 | return response 101 | 102 | original_implementation.sign_in_up_post = sign_in_up_post 103 | return original_implementation 104 | 105 | 106 | def init_supertokens() -> None: 107 | init( 108 | app_info=InputAppInfo( 109 | app_name="FastAPI App", 110 | api_domain="localhost:8000", 111 | website_domain=settings.app_url, 112 | api_base_path="/auth", 113 | website_base_path="/auth", 114 | api_gateway_path="/api", 115 | ), 116 | supertokens_config=SupertokensConfig( 117 | connection_uri=settings.supertokens_connection_uri, 118 | api_key=settings.supertokens_api_key, 119 | ), 120 | framework="fastapi", 121 | recipe_list=[ 122 | dashboard.init(), 123 | session.init(cookie_domain="localhost:8000"), 124 | thirdparty.init( 125 | override=thirdparty.InputOverrideConfig(apis=override_thirdparty_apis), 126 | sign_in_and_up_feature=thirdparty.SignInAndUpFeature( 127 | providers=[ 128 | Github( 129 | client_id=settings.github_client_id, 130 | client_secret=settings.github_client_secret, 131 | ) 132 | ] 133 | ), 134 | ), 135 | ], 136 | mode="asgi", 137 | ) 138 | -------------------------------------------------------------------------------- /fastapi_app/utils/logger.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import logging 3 | from rich.console import Console 4 | from rich.logging import RichHandler 5 | from .singleton import SingletonMeta 6 | 7 | 8 | class AppLogger(metaclass=SingletonMeta): 9 | _logger: logging.Logger 10 | 11 | def __init__(self) -> None: 12 | self._logger = logging.getLogger(__name__) 13 | 14 | def get_logger(self) -> logging.Logger: 15 | return self._logger 16 | 17 | 18 | class RichConsoleHandler(RichHandler): 19 | def __init__(self, width: int = 200, style: Any = None, **kwargs: Any) -> None: 20 | super().__init__( 21 | console=Console(color_system="256", width=width, style=style), **kwargs 22 | ) 23 | -------------------------------------------------------------------------------- /fastapi_app/utils/route_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Coroutine, Any 2 | from fastapi import Request, Response, HTTPException 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.routing import APIRoute 5 | from .logger import AppLogger 6 | 7 | logger = AppLogger().get_logger() 8 | 9 | 10 | # log request body on validation error 11 | class ValidationErrorLoggingRoute(APIRoute): 12 | def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: 13 | original_route_handler = super().get_route_handler() 14 | 15 | async def custom_route_handler(request: Request) -> Response: 16 | try: 17 | return await original_route_handler(request) 18 | except RequestValidationError as exc: 19 | body = await request.body() 20 | detail = {"errors": exc.errors(), "body": body.decode()} 21 | logger.error(detail) 22 | raise HTTPException(status_code=422, detail=detail) 23 | 24 | return custom_route_handler 25 | -------------------------------------------------------------------------------- /fastapi_app/utils/s3_client.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from fastapi_app import config 3 | 4 | settings = config.get_settings() 5 | 6 | s3_client = boto3.client( 7 | "s3", 8 | aws_access_key_id=settings.aws_access_key_id, 9 | aws_secret_access_key=settings.aws_secret_access_key, 10 | region_name="us-east-1", 11 | ) 12 | -------------------------------------------------------------------------------- /fastapi_app/utils/singleton.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type, Any, Tuple 2 | from threading import Lock 3 | 4 | 5 | class SingletonMeta(type): 6 | """ 7 | This is a thread-safe implementation of Singleton. 8 | """ 9 | 10 | _instances: Dict[Type[Any], Any] = {} 11 | 12 | _lock: Lock = Lock() 13 | 14 | def __call__(cls: Any, *args: Tuple[Any], **kwargs: Dict[str, Any]) -> Any: 15 | with cls._lock: 16 | if cls not in cls._instances: 17 | instance = super().__call__(*args, **kwargs) 18 | cls._instances[cls] = instance 19 | return cls._instances[cls] 20 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | 4 | strict = True 5 | 6 | [pydantic-mypy] 7 | init_forbid_extra = True 8 | init_typed = True 9 | warn_required_dynamic_aliases = True 10 | warn_untyped_fields = True 11 | 12 | [mypy-supertokens_python.*] 13 | follow_imports = skip 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi_app" 3 | version = "0.1.0" 4 | description = "Example app using FastAPI, asyncio, SQLModel, Celery, Alembic and Supertokens" 5 | authors = ["Petr Gazarov "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | fastapi = "~0.95" 10 | uvicorn = "~0.28" 11 | more_itertools = "*" 12 | sqlmodel = "0.0.8" 13 | pydantic = "<2.0" 14 | toolz = "~0.12" 15 | genson = "~1.2" 16 | asyncpg = "~0.29" 17 | greenlet = "~3.0" 18 | alembic = "~1.10" 19 | rich = "~13.7" 20 | supertokens-python = "~0.12" 21 | requests = "~2.31" 22 | boto3 = "~1.34" 23 | celery = { extras = ["sqs"], version = "~5.2" } 24 | psycopg2 = "^2.9.9" 25 | 26 | [tool.poetry.dev-dependencies] 27 | mypy = "~1.2" 28 | python-dotenv = "~1.0" 29 | black = "~24.2" 30 | types-requests = "~2.31" 31 | types-PyMySQL = "~1.1" 32 | types-Pygments = "~2.17" 33 | types-psycopg2 = "~2.9" 34 | types-python-dateutil = "~2.8" 35 | types-ujson = "~5.9" 36 | boto3-stubs = { extras = ["s3"], version = "~1.34" } 37 | pre-commit = "~3.5" 38 | --------------------------------------------------------------------------------