├── .dockerignore ├── .gitignore ├── Dockerfile ├── alembic.ini ├── docker-compose.yml ├── env.example ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── .gitkeep │ ├── 01_b11f147413e8_test.py │ └── 02_675a2fb1ef50_test2.py ├── pyproject.toml ├── requirements.txt └── src ├── __init__.py ├── __main__.py ├── api ├── __init__.py ├── common │ ├── __init__.py │ ├── cache │ │ ├── __init__.py │ │ └── redis.py │ ├── docs.py │ ├── exceptions.py │ ├── middlewares │ │ ├── __init__.py │ │ └── process_time.py │ ├── providers │ │ ├── __init__.py │ │ └── stub.py │ └── responses │ │ ├── __init__.py │ │ ├── json.py │ │ └── orjson.py ├── setup.py └── v1 │ ├── __init__.py │ ├── dependencies.py │ ├── endpoints │ ├── __init__.py │ ├── auth.py │ ├── healthcheck.py │ └── user.py │ ├── handlers │ ├── __init__.py │ ├── auth.py │ ├── commands │ │ ├── __init__.py │ │ ├── base.py │ │ ├── mediator.py │ │ ├── setup.py │ │ └── user │ │ │ ├── __init__.py │ │ │ ├── create.py │ │ │ └── select.py │ └── login.py │ ├── middlewares │ └── __init__.py │ └── setup.py ├── common ├── __init__.py ├── dto │ ├── __init__.py │ ├── base.py │ ├── status.py │ ├── token.py │ └── user.py ├── exceptions.py ├── interfaces │ ├── __init__.py │ ├── context.py │ ├── crud.py │ ├── encrypt.py │ ├── gateway.py │ └── hasher.py ├── serializers │ ├── __init__.py │ ├── default.py │ ├── json.py │ └── orjson.py └── types.py ├── core ├── __init__.py ├── gunicorn_server.py ├── logger.py ├── settings.py └── uvicorn_server.py ├── database ├── __init__.py ├── converter.py ├── core │ ├── __init__.py │ ├── connection.py │ └── manager.py ├── exceptions.py ├── gateway.py ├── models │ ├── __init__.py │ ├── base │ │ ├── __init__.py │ │ ├── core.py │ │ └── mixins │ │ │ ├── __init__.py │ │ │ ├── with_id.py │ │ │ ├── with_time.py │ │ │ └── with_uuid.py │ └── user.py ├── repositories │ ├── __init__.py │ ├── base.py │ ├── crud.py │ ├── types │ │ ├── __init__.py │ │ ├── repository.py │ │ └── user.py │ └── user.py └── tools.py └── services ├── __init__.py ├── gateway.py ├── security ├── __init__.py ├── argon2.py └── jwt.py └── user.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | __pycache__/ 3 | *.pyc 4 | .DS_Store 5 | *.env 6 | .idea/ 7 | .pytest_cache/ 8 | tests/ 9 | .ruff_cache/ 10 | .venv/ 11 | venv/ 12 | *_example 13 | *.log 14 | logs/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.log 3 | *.pot 4 | *.pyc 5 | __pycache__/ 6 | local_settings.py 7 | db.sqlite3 8 | db.sqlite3-journal 9 | media 10 | 11 | *.py[cod] 12 | *$py.class 13 | 14 | *.so 15 | 16 | 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | 37 | *.manifest 38 | *.spec 39 | 40 | 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | 60 | *.mo 61 | 62 | 63 | 64 | instance/ 65 | .webassets-cache 66 | 67 | 68 | .scrapy 69 | 70 | 71 | docs/_build/ 72 | 73 | 74 | .pybuilder/ 75 | target/ 76 | 77 | 78 | .ipynb_checkpoints 79 | 80 | 81 | profile_default/ 82 | ipython_config.py 83 | 84 | 85 | .pdm.toml 86 | 87 | 88 | __pypackages__/ 89 | 90 | 91 | celerybeat-schedule 92 | celerybeat.pid 93 | 94 | 95 | *.sage.py 96 | 97 | 98 | .env 99 | .venv 100 | env/ 101 | venv/ 102 | ENV/ 103 | env.bak/ 104 | venv.bak/ 105 | 106 | .spyderproject 107 | .spyproject 108 | 109 | 110 | .ropeproject 111 | 112 | 113 | /site 114 | 115 | 116 | .mypy_cache/ 117 | .dmypy.json 118 | dmypy.json 119 | 120 | 121 | .pyre/ 122 | 123 | 124 | .pytype/ 125 | 126 | 127 | cython_debug/ 128 | 129 | 130 | .idea/**/workspace.xml 131 | .idea/**/tasks.xml 132 | .idea/**/usage.statistics.xml 133 | .idea/**/dictionaries 134 | .idea/**/shelf 135 | 136 | 137 | .idea/**/aws.xml 138 | 139 | 140 | .idea/**/contentModel.xml 141 | 142 | 143 | .idea/**/dataSources/ 144 | .idea/**/dataSources.ids 145 | .idea/**/dataSources.local.xml 146 | .idea/**/sqlDataSources.xml 147 | .idea/**/dynamic.xml 148 | .idea/**/uiDesigner.xml 149 | .idea/**/dbnavigator.xml 150 | 151 | 152 | .idea/**/gradle.xml 153 | .idea/**/libraries 154 | 155 | 156 | cmake-build-*/ 157 | 158 | 159 | .idea/**/mongoSettings.xml 160 | 161 | 162 | *.iws 163 | 164 | 165 | out/ 166 | 167 | 168 | .idea_modules/ 169 | 170 | atlassian-ide-plugin.xml 171 | 172 | 173 | .idea/replstate.xml 174 | 175 | 176 | .idea/sonarlint/ 177 | 178 | 179 | com_crashlytics_export_strings.xml 180 | crashlytics.properties 181 | crashlytics-build.properties 182 | fabric.properties 183 | 184 | 185 | .idea/httpRequests 186 | 187 | 188 | .idea/caches/build_file_checksums.ser 189 | 190 | 191 | 192 | .idea/* 193 | 194 | !.idea/codeStyles 195 | !.idea/runConfigurations 196 | 197 | 198 | poetry.toml 199 | 200 | 201 | .ruff_cache/ 202 | 203 | 204 | pyrightconfig.json 205 | 206 | *.rsuser 207 | *.suo 208 | *.user 209 | *.userosscache 210 | *.sln.docstates 211 | 212 | 213 | *.userprefs 214 | 215 | 216 | mono_crash.* 217 | 218 | 219 | [Dd]ebug/ 220 | [Dd]ebugPublic/ 221 | [Rr]elease/ 222 | [Rr]eleases/ 223 | x64/ 224 | x86/ 225 | [Aa][Rr][Mm]/ 226 | [Aa][Rr][Mm]64/ 227 | bld/ 228 | [Bb]in/ 229 | [Oo]bj/ 230 | [Ll]og/ 231 | [Ll]ogs/ 232 | 233 | 234 | .vs/ 235 | 236 | 237 | 238 | Generated\ Files/ 239 | 240 | 241 | [Tt]est[Rr]esult*/ 242 | [Bb]uild[Ll]og.* 243 | 244 | 245 | *.VisualState.xml 246 | TestResult.xml 247 | nunit-*.xml 248 | 249 | 250 | [Dd]ebugPS/ 251 | [Rr]eleasePS/ 252 | dlldata.c 253 | 254 | 255 | BenchmarkDotNet.Artifacts/ 256 | 257 | 258 | project.lock.json 259 | project.fragment.lock.json 260 | artifacts/ 261 | 262 | 263 | StyleCopReport.xml 264 | 265 | 266 | *_i.c 267 | *_p.c 268 | *_h.h 269 | *.ilk 270 | *.meta 271 | *.obj 272 | *.iobj 273 | *.pch 274 | *.pdb 275 | *.ipdb 276 | *.pgc 277 | *.pgd 278 | *.rsp 279 | *.sbr 280 | *.tlb 281 | *.tli 282 | *.tlh 283 | *.tmp 284 | *.tmp_proj 285 | *_wpftmp.csproj 286 | *.vspscc 287 | *.vssscc 288 | .builds 289 | *.pidb 290 | *.svclog 291 | *.scc 292 | 293 | 294 | _Chutzpah* 295 | 296 | 297 | ipch/ 298 | *.aps 299 | *.ncb 300 | *.opendb 301 | *.opensdf 302 | *.sdf 303 | *.cachefile 304 | *.VC.db 305 | *.VC.VC.opendb 306 | 307 | 308 | *.psess 309 | *.vsp 310 | *.vspx 311 | *.sap 312 | 313 | 314 | *.e2e 315 | 316 | 317 | $tf/ 318 | 319 | 320 | *.gpState 321 | 322 | 323 | _ReSharper*/ 324 | *.[Rr]e[Ss]harper 325 | *.DotSettings.user 326 | 327 | 328 | _TeamCity* 329 | 330 | 331 | *.dotCover 332 | 333 | 334 | .axoCover/* 335 | !.axoCover/settings.json 336 | 337 | 338 | coverage*[.json, .xml, .info] 339 | 340 | 341 | *.coverage 342 | *.coveragexml 343 | 344 | 345 | _NCrunch_* 346 | .*crunch*.local.xml 347 | nCrunchTemp_* 348 | 349 | 350 | *.mm.* 351 | AutoTest.Net/ 352 | 353 | .sass-cache/ 354 | 355 | 356 | [Ee]xpress/ 357 | 358 | 359 | DocProject/buildhelp/ 360 | DocProject/Help/*.HxT 361 | DocProject/Help/*.HxC 362 | DocProject/Help/*.hhc 363 | DocProject/Help/*.hhk 364 | DocProject/Help/*.hhp 365 | DocProject/Help/Html2 366 | DocProject/Help/html 367 | 368 | 369 | publish/ 370 | 371 | 372 | *.[Pp]ublish.xml 373 | *.azurePubxml 374 | 375 | *.pubxml 376 | *.publishproj 377 | 378 | 379 | PublishScripts/ 380 | 381 | *.nupkg 382 | 383 | 384 | **/[Pp]ackages/* 385 | 386 | !**/[Pp]ackages/build/ 387 | 388 | *.nuget.props 389 | *.nuget.targets 390 | 391 | 392 | csx/ 393 | *.build.csdef 394 | 395 | 396 | ecf/ 397 | rcf/ 398 | 399 | 400 | AppPackages/ 401 | BundleArtifacts/ 402 | Package.StoreAssociation.xml 403 | _pkginfo.txt 404 | *.appx 405 | *.appxbundle 406 | *.appxupload 407 | 408 | 409 | *.[Cc]ache 410 | 411 | !?*.[Cc]ache/ 412 | 413 | 414 | ClientBin/ 415 | ~$* 416 | *~ 417 | *.dbmdl 418 | *.dbproj.schemaview 419 | *.jfm 420 | *.pfx 421 | *.publishsettings 422 | orleans.codegen.cs 423 | 424 | 425 | Generated_Code/ 426 | 427 | 428 | _UpgradeReport_Files/ 429 | Backup*/ 430 | UpgradeLog*.XML 431 | UpgradeLog*.htm 432 | ServiceFabricBackup/ 433 | *.rptproj.bak 434 | 435 | 436 | *.mdf 437 | *.ldf 438 | *.ndf 439 | 440 | *.rdl.data 441 | *.bim.layout 442 | *.bim_*.settings 443 | *.rptproj.rsuser 444 | *- [Bb]ackup.rdl 445 | *- [Bb]ackup ([0-9]).rdl 446 | *- [Bb]ackup ([0-9][0-9]).rdl 447 | 448 | 449 | FakesAssemblies/ 450 | 451 | 452 | *.GhostDoc.xml 453 | 454 | 455 | .ntvs_analysis.dat 456 | node_modules/ 457 | 458 | 459 | *.plg 460 | 461 | 462 | *.opt 463 | 464 | 465 | *.vbw 466 | 467 | 468 | **/*.HTMLClient/GeneratedArtifacts 469 | **/*.DesktopClient/GeneratedArtifacts 470 | **/*.DesktopClient/ModelManifest.xml 471 | **/*.Server/GeneratedArtifacts 472 | **/*.Server/ModelManifest.xml 473 | _Pvt_Extensions 474 | 475 | 476 | .paket/paket.exe 477 | paket-files/ 478 | 479 | 480 | .fake/ 481 | 482 | .cr/personal 483 | 484 | *.tss 485 | 486 | 487 | *.jmconfig 488 | 489 | 490 | *.btp.cs 491 | *.btm.cs 492 | *.odx.cs 493 | *.xsd.cs 494 | 495 | 496 | OpenCover/ 497 | 498 | 499 | ASALocalRun/ 500 | 501 | 502 | *.binlog 503 | 504 | 505 | *.nvuser 506 | 507 | .mfractor/ 508 | 509 | 510 | .localhistory/ 511 | 512 | 513 | healthchecksdb 514 | 515 | 516 | MigrationBackup/ 517 | 518 | 519 | .ionide/ 520 | .DS_Store 521 | **/.DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.5 2 | 3 | WORKDIR /usr/src/project 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | COPY . . 9 | 10 | RUN pip install --upgrade pip && \ 11 | pip install --no-cache-dir -r requirements.txt -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = ./migrations 3 | 4 | prepend_sys_path = . 5 | 6 | [loggers] 7 | keys = root,sqlalchemy,alembic 8 | 9 | [handlers] 10 | keys = console 11 | 12 | [formatters] 13 | keys = generic 14 | 15 | [logger_root] 16 | level = WARN 17 | handlers = console 18 | qualname = 19 | 20 | [logger_sqlalchemy] 21 | level = WARN 22 | handlers = 23 | qualname = sqlalchemy.engine 24 | 25 | [logger_alembic] 26 | level = INFO 27 | handlers = 28 | qualname = alembic 29 | 30 | [handler_console] 31 | class = StreamHandler 32 | args = (sys.stderr,) 33 | level = NOTSET 34 | formatter = generic 35 | 36 | [formatter_generic] 37 | format = %(levelname)-5.5s [%(name)s] %(message)s 38 | datefmt = %H:%M:%S -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | backend: 5 | build: . 6 | restart: always 7 | container_name: backend 8 | networks: 9 | - backend-network 10 | volumes: 11 | - ./:/usr/src/project/ 12 | depends_on: 13 | - redis 14 | - postgres 15 | working_dir: /usr/src/project/ 16 | command: /bin/sh -c 'alembic upgrade head && python -m src' 17 | ports: 18 | - '8080:8080' 19 | 20 | redis: 21 | image: redis:latest 22 | restart: always 23 | container_name: redis 24 | networks: 25 | - backend-network 26 | 27 | postgres: 28 | image: postgres:latest 29 | restart: always 30 | container_name: postgres 31 | volumes: 32 | - postgres_data:/var/lib/postgresql/data/ 33 | environment: 34 | POSTGRES_DB: ${DB_NAME} 35 | POSTGRES_USER: ${DB_USER} 36 | POSTGRES_PASSWORD: ${DB_PASSWORD} 37 | networks: 38 | - backend-network 39 | env_file: 40 | - ./.env 41 | ports: 42 | - '5432' 43 | 44 | volumes: 45 | postgres_data: 46 | 47 | networks: 48 | backend-network: -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | DB_URI=postgresql+asyncpg://{}:{}@{}:{}/{} 2 | DB_HOST=host 3 | DB_PORT=port 4 | DB_USER=user 5 | DB_NAME=name 6 | DB_PASSWORD=pwd 7 | 8 | SERVER_HOST=0.0.0.0 9 | SERVER_PORT=8080 10 | SERVER_ORIGINS=["http://localhost", "http://localhost:8080", "http://127.0.0.1", "http://127.0.0.1:8080"] 11 | SERVER_METHODS=["OPTIONS", "DELETE", "POST", "GET", "PATCH"] 12 | SERVER_HEADERS=["Authorization", "Accept", "Content-Type"] 13 | CIPHER_ALGORITHM=RS256 14 | CIPHER_SECRET_KEY=b64pemsecret 15 | CIPHER_PUBLIC_KEY=b64pempublic 16 | CIPHER_ACCESS_TOKEN_EXPIRE_SECONDS=1800 17 | CIPHER_REFRESH_TOKEN_EXPIRE_SECONDS=604800 18 | 19 | REDIS_HOST=host 20 | 21 | LOG_LEVEL=INFO 22 | PROJECT_NAME=Test 23 | PROJECT_VERSION=0.0.1 -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | from typing import Iterable, List, Optional, Union 4 | 5 | from alembic import context 6 | from alembic.operations.ops import MigrationScript 7 | from alembic.runtime.migration import MigrationContext 8 | from alembic.script import ScriptDirectory 9 | from sqlalchemy import pool 10 | from sqlalchemy.engine import Connection 11 | from sqlalchemy.ext.asyncio import async_engine_from_config 12 | 13 | from src.core.settings import load_settings 14 | from src.database.models import Base 15 | 16 | config = context.config 17 | config.set_main_option("sqlalchemy.url", load_settings().db.url) 18 | 19 | if config.config_file_name is not None: 20 | fileConfig(config.config_file_name) 21 | 22 | target_metadata = Base.metadata 23 | 24 | 25 | def add_number_to_migrations( 26 | context: MigrationContext, 27 | revision: Union[str, Iterable[Optional[str]]], 28 | directives: List[MigrationScript], 29 | ) -> None: 30 | migration_script = directives[0] 31 | head_revision = ScriptDirectory.from_config(context.config).get_current_head() # type: ignore 32 | if head_revision is None: 33 | new_rev_id = 1 34 | else: 35 | last_rev_id = int(head_revision.split("_")[0]) 36 | new_rev_id = last_rev_id + 1 37 | 38 | migration_script.rev_id = f"{new_rev_id:02}_{migration_script.rev_id}" 39 | 40 | 41 | def run_migrations_offline() -> None: 42 | """Run migrations in 'offline' mode. 43 | 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | 49 | Calls to context.execute() here emit the given string to the 50 | script output. 51 | 52 | """ 53 | url = config.get_main_option("sqlalchemy.url") 54 | context.configure( 55 | url=url, 56 | target_metadata=target_metadata, 57 | literal_binds=True, 58 | dialect_opts={"paramstyle": "named"}, 59 | ) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | def do_run_migrations(connection: Connection) -> None: 66 | context.configure( 67 | connection=connection, 68 | target_metadata=target_metadata, 69 | process_revision_directives=add_number_to_migrations, 70 | ) 71 | 72 | with context.begin_transaction(): 73 | context.run_migrations() 74 | 75 | 76 | async def run_async_migrations() -> None: 77 | """In this scenario we need to create an Engine 78 | and associate a connection with the context. 79 | 80 | """ 81 | 82 | connectable = async_engine_from_config( 83 | config.get_section(config.config_ini_section, {}), 84 | prefix="sqlalchemy.", 85 | poolclass=pool.NullPool, 86 | ) 87 | 88 | async with connectable.connect() as connection: 89 | await connection.run_sync(do_run_migrations) 90 | 91 | await connectable.dispose() 92 | 93 | 94 | def run_migrations_online() -> None: 95 | """Run migrations in 'online' mode.""" 96 | 97 | asyncio.run(run_async_migrations()) 98 | 99 | 100 | if context.is_offline_mode(): 101 | run_migrations_offline() 102 | else: 103 | run_migrations_online() 104 | -------------------------------------------------------------------------------- /migrations/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 typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /migrations/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/migrations/versions/.gitkeep -------------------------------------------------------------------------------- /migrations/versions/01_b11f147413e8_test.py: -------------------------------------------------------------------------------- 1 | """test 2 | 3 | Revision ID: 01_b11f147413e8 4 | Revises: 5 | Create Date: 2024-04-28 06:08:54.818549 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "01_b11f147413e8" 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "user", 25 | sa.Column("login", sa.String(), nullable=False), 26 | sa.Column("password", sa.String(), nullable=False), 27 | sa.Column( 28 | "id", 29 | sa.UUID(), 30 | server_default=sa.text("(gen_random_uuid())"), 31 | nullable=False, 32 | ), 33 | sa.Column( 34 | "created_at", 35 | sa.DateTime(timezone=True), 36 | server_default=sa.text("(CURRENT_TIMESTAMP)"), 37 | nullable=False, 38 | ), 39 | sa.Column( 40 | "updated_at", 41 | sa.DateTime(timezone=True), 42 | server_default=sa.text("(CURRENT_TIMESTAMP)"), 43 | nullable=False, 44 | ), 45 | sa.PrimaryKeyConstraint("id"), 46 | sa.UniqueConstraint("login"), 47 | ) 48 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 49 | # ### end Alembic commands ### 50 | 51 | 52 | def downgrade() -> None: 53 | # ### commands auto generated by Alembic - please adjust! ### 54 | op.drop_index(op.f("ix_user_id"), table_name="user") 55 | op.drop_table("user") 56 | # ### end Alembic commands ### 57 | -------------------------------------------------------------------------------- /migrations/versions/02_675a2fb1ef50_test2.py: -------------------------------------------------------------------------------- 1 | """test2 2 | 3 | Revision ID: 02_675a2fb1ef50 4 | Revises: 01_b11f147413e8 5 | Create Date: 2024-04-28 07:13:25.404952 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "02_675a2fb1ef50" 16 | down_revision: Union[str, None] = "01_b11f147413e8" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.drop_constraint("user_login_key", "user", type_="unique") 24 | op.create_index("idx_lower_login", "user", [sa.text("lower(login)")], unique=True) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade() -> None: 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_index("idx_lower_login", table_name="user") 31 | op.create_unique_constraint("user_login_key", "user", ["login"]) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | # disable_error_code = ["union-attr", "var-annotated"] 3 | warn_unused_ignores = false 4 | follow_imports_for_stubs = true 5 | pretty = true 6 | show_absolute_path = true 7 | hide_error_codes = false 8 | show_error_context = true 9 | strict = true 10 | warn_unreachable = true 11 | warn_no_return = true 12 | 13 | [lint.tool.ruff] 14 | ignore = [ 15 | "E501", 16 | "B008", 17 | "C901", 18 | "W191", 19 | "UP007", 20 | "UP006", 21 | ] 22 | select = [ 23 | "E", 24 | "W", 25 | "F", 26 | "I", 27 | "C", 28 | "B", 29 | "UP", 30 | ] 31 | 32 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.13.1 2 | annotated-types==0.6.0 3 | anyio==4.3.0 4 | argon2-cffi==23.1.0 5 | argon2-cffi-bindings==21.2.0 6 | asyncpg==0.29.0 7 | cffi==1.16.0 8 | click==8.1.7 9 | cryptography==42.0.5 10 | fastapi==0.110.2 11 | greenlet==3.0.3 12 | gunicorn==22.0.0 13 | h11==0.14.0 14 | idna==3.7 15 | Mako==1.3.3 16 | MarkupSafe==2.1.5 17 | orjson==3.10.1 18 | packaging==24.0 19 | pycparser==2.22 20 | pydantic==2.7.0 21 | pydantic-settings==2.2.1 22 | pydantic_core==2.18.1 23 | PyJWT==2.8.0 24 | python-dotenv==1.0.1 25 | redis==5.0.4 26 | sniffio==1.3.1 27 | SQLAlchemy==2.0.29 28 | starlette==0.37.2 29 | types-cffi==1.16.0.20240331 30 | types-pyOpenSSL==24.1.0.20240425 31 | types-redis==4.6.0.20240425 32 | types-setuptools==69.5.0.20240423 33 | typing_extensions==4.11.0 34 | uvicorn==0.29.0 35 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/__init__.py -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | from src.api.setup import init_app 2 | from src.api.v1.setup import init_app_v1 3 | from src.core.settings import PROJECT_NAME, PROJECT_VERSION, load_settings 4 | # from src.core.uvicorn_server import run_api_uvicorn 5 | from src.core.gunicorn_server import run_api_gunicorn 6 | 7 | 8 | def main() -> None: 9 | settings = load_settings() 10 | app = init_app( 11 | init_app_v1(settings, title=PROJECT_NAME, version=PROJECT_VERSION), 12 | settings=settings, 13 | ) 14 | run_api_gunicorn(app, settings) 15 | # run_api_uvicorn(app, settings) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/__init__.py -------------------------------------------------------------------------------- /src/api/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/common/__init__.py -------------------------------------------------------------------------------- /src/api/common/cache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/common/cache/__init__.py -------------------------------------------------------------------------------- /src/api/common/cache/redis.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Final, 4 | List, 5 | Literal, 6 | Optional, 7 | TypeVar, 8 | Union, 9 | ) 10 | 11 | import orjson 12 | import redis.asyncio as aioredis 13 | 14 | from src.common.dto.base import DTO 15 | from src.common.serializers.orjson import orjson_dumps 16 | from src.core.settings import RedisSettings 17 | 18 | ValueType = TypeVar("ValueType", float, int, str, bytes, bool) 19 | 20 | 21 | class NonSerializableObjectsProvidedError(Exception): 22 | pass 23 | 24 | 25 | ONE_MINUTE: Final[int] = 60 26 | ONE_HOUR: Final[int] = 3600 27 | ONE_DAY: Final[int] = 86_400 28 | ONE_MONTH: Final[int] = 2_592_000 29 | 30 | 31 | def _str_key(key: Any) -> str: 32 | return str(key) 33 | 34 | 35 | class RedisCache: 36 | __slots__ = ("_redis",) 37 | 38 | # why mypy thinks that Redis is Generic if it isn't. Wtf 39 | def __init__(self, redis: aioredis.Redis) -> None: # type: ignore 40 | self._redis = redis 41 | 42 | async def get_single(self, key: Any) -> Optional[str]: 43 | return await self._redis.get(_str_key(key)) 44 | 45 | async def set_single( 46 | self, 47 | key: Union[str, Any], 48 | value: ValueType, 49 | expire_seconds: Optional[int] = ONE_HOUR, 50 | expire_milliseconds: Optional[int] = None, 51 | return_origin: bool = True, 52 | **additional: Any, 53 | ) -> Optional[Union[ValueType, bool]]: 54 | serialized = None 55 | if isinstance(value, (DTO, dict, list)): 56 | try: 57 | serialized = orjson_dumps(value) 58 | except orjson.JSONDecodeError as e: 59 | raise NonSerializableObjectsProvidedError( 60 | "Some of object that you provided is not serializable" 61 | ) from e 62 | 63 | set_value = await self._redis.set( 64 | _str_key(key), 65 | serialized or value, 66 | ex=expire_seconds, 67 | px=expire_milliseconds, 68 | **additional, 69 | ) 70 | if return_origin: 71 | return value 72 | 73 | return set_value 74 | 75 | async def delete(self, *keys: Any) -> None: 76 | r_keys = (_str_key(key) for key in keys) 77 | await self._redis.delete(*r_keys) 78 | 79 | async def set_list( 80 | self, 81 | key: Any, 82 | *values: str, 83 | side: Literal["right", "left"] = "left", 84 | expire_seconds: Optional[int] = None, 85 | expire_milliseconds: Optional[int] = None, 86 | ) -> int: 87 | key = _str_key(key) 88 | 89 | push = self._redis.lpush if side == "left" else self._redis.rpush 90 | 91 | result = await push(key, *values) 92 | 93 | if expire_seconds: 94 | await self._redis.expire(key, expire_seconds) 95 | if expire_milliseconds: 96 | await self._redis.pexpire(key, expire_milliseconds) 97 | 98 | return result 99 | 100 | async def get_list(self, key: Any, start: int = 0, end: int = -1) -> List[str]: 101 | return await self._redis.lrange(_str_key(key), start, end) 102 | 103 | async def pop(self, key: Any, value: str, count: int = 0) -> int: 104 | return await self._redis.lrem(_str_key(key), count, value) 105 | 106 | 107 | def get_redis(settings: RedisSettings, **kw: Any) -> RedisCache: 108 | return RedisCache( 109 | aioredis.Redis( 110 | host=settings.host, 111 | port=settings.port, 112 | password=settings.password, 113 | decode_responses=True, 114 | **kw 115 | ) 116 | ) 117 | -------------------------------------------------------------------------------- /src/api/common/docs.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class BaseDoc(BaseModel): 5 | message: str 6 | 7 | 8 | class UnAuthorizedError(BaseDoc): 9 | pass 10 | 11 | 12 | class NotFoundError(BaseDoc): 13 | pass 14 | 15 | 16 | class BadRequestError(BaseDoc): 17 | pass 18 | 19 | 20 | class TooManyRequestsError(BaseDoc): 21 | pass 22 | 23 | 24 | class ServiceUnavailableError(BaseDoc): 25 | pass 26 | 27 | 28 | class ForbiddenError(BaseDoc): 29 | pass 30 | 31 | 32 | class ServiceNotImplementedError(BaseDoc): 33 | pass 34 | 35 | 36 | class ConflictError(BaseDoc): 37 | pass 38 | -------------------------------------------------------------------------------- /src/api/common/exceptions.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Awaitable, Callable 3 | 4 | from fastapi import FastAPI 5 | from fastapi.exceptions import RequestValidationError 6 | from starlette import status 7 | from starlette.requests import Request 8 | 9 | from src.api.common.responses import DefaultJSONResponse 10 | from src.common.exceptions import ( 11 | AppException, 12 | BadRequestError, 13 | ConflictError, 14 | ForbiddenError, 15 | NotFoundError, 16 | ServiceNotImplementedError, 17 | ServiceUnavailableError, 18 | TooManyRequestsError, 19 | UnAuthorizedError, 20 | ) 21 | from src.core.logger import log 22 | 23 | 24 | def setup_exception_handlers(app: FastAPI) -> None: 25 | app.add_exception_handler( 26 | UnAuthorizedError, error_handler(status.HTTP_401_UNAUTHORIZED) 27 | ) 28 | app.add_exception_handler(ConflictError, error_handler(status.HTTP_409_CONFLICT)) 29 | app.add_exception_handler(ForbiddenError, error_handler(status.HTTP_403_FORBIDDEN)) 30 | app.add_exception_handler(NotFoundError, error_handler(status.HTTP_404_NOT_FOUND)) 31 | app.add_exception_handler( 32 | BadRequestError, error_handler(status.HTTP_400_BAD_REQUEST) 33 | ) 34 | app.add_exception_handler( 35 | TooManyRequestsError, error_handler(status.HTTP_429_TOO_MANY_REQUESTS) 36 | ) 37 | app.add_exception_handler( 38 | ServiceUnavailableError, error_handler(status.HTTP_503_SERVICE_UNAVAILABLE) 39 | ) 40 | app.add_exception_handler( 41 | ServiceNotImplementedError, error_handler(status.HTTP_501_NOT_IMPLEMENTED) 42 | ) 43 | app.add_exception_handler( 44 | AppException, error_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) 45 | ) 46 | app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore 47 | app.add_exception_handler(Exception, unknown_exception_handler) 48 | 49 | 50 | async def unknown_exception_handler( 51 | request: Request, err: Exception 52 | ) -> DefaultJSONResponse: 53 | log.error("Handle error") 54 | log.exception(f"Unknown error occurred -> {err.args}") 55 | return DefaultJSONResponse( 56 | {"status": 500, "message": "Unknown Error"}, 57 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 58 | ) 59 | 60 | 61 | async def validation_exception_handler( 62 | request: Request, err: RequestValidationError 63 | ) -> DefaultJSONResponse: 64 | log.error(f"Handle error: {type(err).__name__}") 65 | return DefaultJSONResponse( 66 | { 67 | "status": 400, 68 | "detail": [err.get("msg") for err in err._errors], 69 | "additional": [err.get("ctx") for err in err._errors], 70 | }, 71 | status_code=status.HTTP_400_BAD_REQUEST, 72 | ) 73 | 74 | 75 | def error_handler( 76 | status_code: int, 77 | ) -> Callable[..., Awaitable[DefaultJSONResponse]]: 78 | return partial(app_error_handler, status_code=status_code) 79 | 80 | 81 | async def app_error_handler( 82 | request: Request, err: AppException, status_code: int 83 | ) -> DefaultJSONResponse: 84 | return await handle_error( 85 | request=request, 86 | err=err, 87 | status_code=status_code, 88 | ) 89 | 90 | 91 | async def handle_error( 92 | request: Request, 93 | err: AppException, 94 | status_code: int, 95 | ) -> DefaultJSONResponse: 96 | log.error(f"Handle error: {type(err).__name__}") 97 | error_data = err.as_dict() 98 | 99 | return DefaultJSONResponse(**error_data, status_code=status_code) 100 | -------------------------------------------------------------------------------- /src/api/common/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from src.api.common.middlewares.process_time import ProcessMiddleware 5 | from src.core.settings import Settings 6 | 7 | 8 | def setup_global_middlewares(app: FastAPI, settings: Settings) -> None: 9 | app.add_middleware( 10 | CORSMiddleware, 11 | allow_origins=settings.server.origins, 12 | allow_credentials=True, 13 | allow_methods=settings.server.methods, 14 | allow_headers=settings.server.headers, 15 | ) 16 | app.add_middleware(ProcessMiddleware) 17 | -------------------------------------------------------------------------------- /src/api/common/middlewares/process_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Awaitable, Callable 3 | 4 | from starlette.middleware.base import BaseHTTPMiddleware 5 | from starlette.requests import Request 6 | from starlette.responses import Response 7 | from starlette.types import ASGIApp 8 | 9 | 10 | class ProcessMiddleware(BaseHTTPMiddleware): 11 | __slots__ = () 12 | 13 | def __init__(self, app: ASGIApp) -> None: 14 | super().__init__(app) 15 | 16 | async def dispatch( 17 | self, request: Request, call_next: Callable[[Request], Awaitable[Response]] 18 | ) -> Response: 19 | start = time.perf_counter() 20 | response = await call_next(request) 21 | stop = time.perf_counter() - start 22 | 23 | response.headers["X-Process-Time"] = f"{stop:.5f}" 24 | 25 | return response 26 | -------------------------------------------------------------------------------- /src/api/common/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from src.api.common.providers.stub import Stub 2 | 3 | __all__ = ("Stub",) 4 | -------------------------------------------------------------------------------- /src/api/common/providers/stub.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Hashable, cast 2 | 3 | 4 | class Stub: 5 | """ 6 | This class is used to prevent fastapi from digging into 7 | real dependencies attributes detecting them as request data 8 | 9 | So instead of 10 | `interactor: Annotated[Interactor, Depends()]` 11 | Write 12 | `interactor: Annotated[Interactor, Depends(Stub(Interactor))]` 13 | 14 | And then you can declare how to create it: 15 | `app.dependency_overrides[Interactor] = some_real_factory` 16 | 17 | """ 18 | 19 | def __init__(self, dependency: Callable[..., Any], **kwargs: Hashable) -> None: 20 | self._dependency = dependency 21 | self._kwargs = kwargs 22 | 23 | def __call__(self) -> None: 24 | raise NotImplementedError 25 | 26 | def __eq__(self, other: Any) -> bool: 27 | if isinstance(other, Stub): 28 | return ( 29 | self._dependency == other._dependency and self._kwargs == other._kwargs 30 | ) 31 | 32 | if not self._kwargs: 33 | return cast(bool, self._dependency == other) 34 | 35 | return False 36 | 37 | def __hash__(self) -> int: 38 | if not self._kwargs: 39 | return hash(self._dependency) 40 | serial = ( 41 | self._dependency, 42 | *self._kwargs.items(), 43 | ) 44 | return hash(serial) 45 | -------------------------------------------------------------------------------- /src/api/common/responses/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import orjson # type: ignore # noqa 3 | 4 | from src.api.common.responses.orjson import OkResponse as OkResponse 5 | from src.api.common.responses.orjson import ORJSONResponse as DefaultJSONResponse 6 | except ImportError: # pragma: no cover 7 | from src.api.responses.json import ( # type: ignore 8 | JSONResponse as DefaultJSONResponse, 9 | ) 10 | from src.api.responses.json import OkResponse as OkResponse # type: ignore 11 | 12 | 13 | __all__ = ( 14 | "OkResponse", 15 | "DefaultJSONResponse", 16 | ) 17 | -------------------------------------------------------------------------------- /src/api/common/responses/json.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Mapping, Optional 2 | 3 | from fastapi.responses import JSONResponse as _JSONResponse 4 | from starlette.background import BackgroundTask 5 | 6 | from src.common.serializers.json import json_dumps 7 | from src.common.types import ResultType 8 | 9 | 10 | class JSONResponse(_JSONResponse): 11 | def render(self, content: Any) -> bytes: 12 | return json_dumps(content) 13 | 14 | 15 | class OkResponse(JSONResponse, Generic[ResultType]): 16 | __slots__ = () 17 | 18 | def __init__( 19 | self, 20 | content: ResultType, 21 | status_code: int = 200, 22 | headers: Optional[Mapping[str, str]] = None, 23 | media_type: Optional[str] = None, 24 | background: Optional[BackgroundTask] = None, 25 | ) -> None: 26 | super().__init__(content, status_code, headers, media_type, background) 27 | -------------------------------------------------------------------------------- /src/api/common/responses/orjson.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, Mapping, Optional 2 | 3 | from fastapi.responses import ORJSONResponse as _ORJSONResponse 4 | from starlette.background import BackgroundTask 5 | 6 | from src.common.serializers.orjson import orjson_dumps 7 | from src.common.types import ResultType 8 | 9 | 10 | class ORJSONResponse(_ORJSONResponse): 11 | def render(self, content: Any) -> bytes: 12 | return orjson_dumps(content) 13 | 14 | 15 | class OkResponse(ORJSONResponse, Generic[ResultType]): 16 | __slots__ = () 17 | 18 | def __init__( 19 | self, 20 | content: ResultType, 21 | status_code: int = 200, 22 | headers: Optional[Mapping[str, str]] = None, 23 | media_type: Optional[str] = None, 24 | background: Optional[BackgroundTask] = None, 25 | ) -> None: 26 | super().__init__(content, status_code, headers, media_type, background) 27 | -------------------------------------------------------------------------------- /src/api/setup.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Tuple 2 | 3 | from fastapi import FastAPI 4 | 5 | from src.api.common.middlewares import setup_global_middlewares 6 | from src.api.common.responses import DefaultJSONResponse 7 | from src.core.logger import log 8 | from src.core.settings import Settings 9 | 10 | 11 | def init_app( 12 | *sub_apps: Tuple[str, FastAPI, Optional[str]], 13 | settings: Settings, 14 | **kw: Any, 15 | ) -> FastAPI: 16 | log.info("Initialize General") 17 | app = FastAPI( 18 | default_response_class=DefaultJSONResponse, 19 | docs_url=None, 20 | redoc_url=None, 21 | swagger_ui_oauth2_redirect_url=None, 22 | **kw, 23 | ) 24 | for apps in sub_apps: 25 | app.mount(*apps) 26 | 27 | setup_global_middlewares(app, settings) 28 | 29 | return app 30 | -------------------------------------------------------------------------------- /src/api/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/v1/__init__.py -------------------------------------------------------------------------------- /src/api/v1/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar 2 | 3 | from fastapi import FastAPI 4 | 5 | from src.api.common.cache.redis import RedisCache, get_redis 6 | from src.api.v1.handlers.commands import CommandMediatorProtocol 7 | from src.api.v1.handlers.commands.mediator import CommandMediator 8 | from src.api.v1.handlers.commands.setup import setup_command_mediator 9 | from src.common.interfaces.hasher import AbstractHasher 10 | from src.core.settings import Settings 11 | from src.database import DBGateway, create_database_factory 12 | from src.database.core.connection import create_sa_engine, create_sa_session_factory 13 | from src.database.core.manager import TransactionManager 14 | from src.services import create_service_gateway_factory 15 | from src.services.security.argon2 import get_argon2_hasher 16 | from src.services.security.jwt import TokenJWT 17 | 18 | DependencyType = TypeVar("DependencyType") 19 | 20 | 21 | def singleton(value: DependencyType) -> Callable[[], DependencyType]: 22 | def singleton_factory() -> DependencyType: 23 | return value 24 | 25 | return singleton_factory 26 | 27 | 28 | def setup_dependencies(app: FastAPI, settings: Settings) -> None: 29 | engine = create_sa_engine( 30 | settings.db.url, 31 | pool_size=settings.db.connection_pool_size, 32 | max_overflow=settings.db.connection_max_overflow, 33 | pool_pre_ping=settings.db.connection_pool_pre_ping, 34 | ) 35 | app.state.engine = engine 36 | session_factory = create_sa_session_factory(engine) 37 | database_factory = create_database_factory(TransactionManager, session_factory) 38 | service_factory = create_service_gateway_factory(database_factory) 39 | redis = get_redis(settings.redis) 40 | jwt = TokenJWT(settings.ciphers) 41 | hasher = get_argon2_hasher() 42 | 43 | mediator = CommandMediator() 44 | setup_command_mediator( 45 | mediator, 46 | gateway=service_factory, 47 | settings=settings, 48 | cache=redis, 49 | jwt=jwt, 50 | hasher=hasher, 51 | ) 52 | 53 | app.dependency_overrides[CommandMediatorProtocol] = singleton(mediator) 54 | app.dependency_overrides[RedisCache] = singleton(redis) 55 | app.dependency_overrides[TokenJWT] = singleton(jwt) 56 | app.dependency_overrides[DBGateway] = database_factory 57 | app.dependency_overrides[AbstractHasher] = singleton(hasher) 58 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, FastAPI 2 | 3 | from src.api.v1.endpoints.auth import auth_router 4 | from src.api.v1.endpoints.healthcheck import healthcheck_router 5 | from src.api.v1.endpoints.user import user_router 6 | 7 | router = APIRouter() 8 | router.include_router(healthcheck_router) 9 | router.include_router(auth_router) 10 | router.include_router(user_router) 11 | 12 | 13 | def setup_routers(app: FastAPI) -> None: 14 | app.include_router(router) 15 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, status 4 | 5 | from src.api.common.docs import ForbiddenError, NotFoundError, UnAuthorizedError 6 | from src.api.common.responses import OkResponse 7 | from src.api.v1.handlers.auth import Authorization 8 | from src.api.v1.handlers.login import Login 9 | from src.common.dto import Status, Token, TokensExpire 10 | 11 | auth_router = APIRouter(prefix="/auth", tags=["auth"]) 12 | 13 | 14 | @auth_router.post( 15 | "/login", 16 | response_model=Token, 17 | status_code=status.HTTP_200_OK, 18 | responses={ 19 | status.HTTP_404_NOT_FOUND: {"model": NotFoundError}, 20 | status.HTTP_401_UNAUTHORIZED: {"model": UnAuthorizedError}, 21 | }, 22 | ) 23 | async def login_endpoint( 24 | login: Annotated[TokensExpire, Depends(Login())], 25 | ) -> OkResponse[Token]: 26 | response = OkResponse(Token(token=login.tokens.access)) 27 | response.set_cookie( 28 | "refresh_token", 29 | value=login.tokens.refresh, 30 | expires=login.refresh_expire, 31 | httponly=True, 32 | secure=True, 33 | ) 34 | 35 | return response 36 | 37 | 38 | @auth_router.post( 39 | "/refresh", 40 | response_model=Token, 41 | status_code=status.HTTP_200_OK, 42 | responses={ 43 | status.HTTP_401_UNAUTHORIZED: {"model": UnAuthorizedError}, 44 | status.HTTP_403_FORBIDDEN: {"model": ForbiddenError}, 45 | }, 46 | ) 47 | async def refresh_endpoint( 48 | verified: Annotated[TokensExpire, Depends(Authorization().verify_refresh)], 49 | ) -> OkResponse[Token]: 50 | response = OkResponse(Token(token=verified.tokens.access)) 51 | response.set_cookie( 52 | "refresh_token", 53 | value=verified.tokens.refresh, 54 | expires=verified.refresh_expire, 55 | httponly=True, 56 | secure=True, 57 | ) 58 | 59 | return response 60 | 61 | 62 | @auth_router.post( 63 | "/logout", 64 | response_model=Status, 65 | status_code=status.HTTP_200_OK, 66 | responses={ 67 | status.HTTP_401_UNAUTHORIZED: {"model": UnAuthorizedError}, 68 | status.HTTP_403_FORBIDDEN: {"model": ForbiddenError}, 69 | }, 70 | ) 71 | async def logout_endpoint( 72 | status: Annotated[Status, Depends(Authorization().invalidate_refresh)], 73 | ) -> OkResponse[Status]: 74 | response = OkResponse(status) 75 | response.delete_cookie("refresh_token") 76 | 77 | return response 78 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/healthcheck.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from fastapi import APIRouter, status 4 | 5 | healthcheck_router = APIRouter(tags=["healthcheck"]) 6 | 7 | 8 | @healthcheck_router.get( 9 | "/healthcheck", 10 | response_model=None, 11 | status_code=status.HTTP_200_OK, 12 | ) 13 | async def healthcheck_endpoint() -> Dict[str, Any]: 14 | return {"ok": True} 15 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/user.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, status 4 | 5 | import src.common.dto as dto 6 | from src.api.common.docs import ( 7 | ConflictError, 8 | ForbiddenError, 9 | NotFoundError, 10 | UnAuthorizedError, 11 | ) 12 | from src.api.common.providers import Stub 13 | from src.api.common.responses import OkResponse 14 | from src.api.v1.handlers.auth import Authorization 15 | from src.api.v1.handlers.commands import CommandMediatorProtocol, GetUserQuery 16 | from src.common.interfaces.hasher import AbstractHasher 17 | 18 | user_router = APIRouter(prefix="/users", tags=["user"]) 19 | 20 | 21 | @user_router.post( 22 | "", 23 | response_model=dto.User, 24 | status_code=status.HTTP_201_CREATED, 25 | responses={ 26 | status.HTTP_409_CONFLICT: {"model": ConflictError}, 27 | }, 28 | ) 29 | async def create_user_endpoint( 30 | body: dto.CreateUser, 31 | mediator: Annotated[ 32 | CommandMediatorProtocol, Depends(Stub(CommandMediatorProtocol)) 33 | ], 34 | hasher: Annotated[AbstractHasher, Depends(Stub(AbstractHasher))], 35 | ) -> OkResponse[dto.User]: 36 | result = await mediator.send(body, hasher=hasher) 37 | return OkResponse(result, status_code=status.HTTP_201_CREATED) 38 | 39 | 40 | @user_router.get( 41 | "/me", 42 | response_model=dto.User, 43 | status_code=status.HTTP_200_OK, 44 | responses={ 45 | status.HTTP_404_NOT_FOUND: {"model": NotFoundError}, 46 | status.HTTP_401_UNAUTHORIZED: {"model": UnAuthorizedError}, 47 | status.HTTP_403_FORBIDDEN: {"model": ForbiddenError}, 48 | }, 49 | ) 50 | async def get_me_endpoint( 51 | user: Annotated[dto.User, Depends(Authorization())], 52 | mediator: Annotated[ 53 | CommandMediatorProtocol, Depends(Stub(CommandMediatorProtocol)) 54 | ], 55 | ) -> OkResponse[dto.User]: 56 | result = await mediator.send(GetUserQuery(user_id=user.id)) 57 | return OkResponse(result) 58 | -------------------------------------------------------------------------------- /src/api/v1/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/v1/handlers/__init__.py -------------------------------------------------------------------------------- /src/api/v1/handlers/auth.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Annotated, Any, Literal, Optional 3 | 4 | from fastapi import Depends, Request 5 | from fastapi.concurrency import run_in_threadpool 6 | from fastapi.openapi.models import HTTPBearer as HTTPBearerModel 7 | from fastapi.security.base import SecurityBase 8 | from fastapi.security.utils import get_authorization_scheme_param 9 | 10 | from src.api.common.cache.redis import RedisCache 11 | from src.api.common.providers import Stub 12 | from src.common.dto import Fingerprint, Status, Tokens, TokensExpire, User 13 | from src.common.exceptions import ForbiddenError 14 | from src.database import DBGateway 15 | from src.database.converter import from_model_to_dto 16 | from src.services.security.jwt import TokenJWT 17 | 18 | TokenType = Literal["access", "refresh"] 19 | 20 | 21 | class Authorization(SecurityBase): 22 | def __init__(self, *permissions: Any) -> None: 23 | self.model = HTTPBearerModel() 24 | self.scheme_name = type(self).__name__ 25 | self._permission = permissions 26 | 27 | async def __call__( 28 | self, 29 | request: Request, 30 | jwt: Annotated[TokenJWT, Depends(Stub(TokenJWT))], 31 | database: Annotated[DBGateway, Depends(Stub(DBGateway))], 32 | ) -> User: 33 | token = self._get_token(request) 34 | return await self._verify_token(jwt, database, token, "access") 35 | 36 | async def verify_refresh( 37 | self, 38 | body: Fingerprint, 39 | *, 40 | request: Request, 41 | jwt: Annotated[TokenJWT, Depends(Stub(TokenJWT))], 42 | database: Annotated[DBGateway, Depends(Stub(DBGateway))], 43 | cache: Annotated[RedisCache, Depends(Stub(RedisCache))], 44 | ) -> TokensExpire: 45 | token = request.cookies.get("refresh_token", "") 46 | user = await self._verify_token(jwt, database, token, "refresh") 47 | 48 | token_pairs = await cache.get_list(str(user.id)) 49 | verified: Optional[str] = None 50 | 51 | for pair in token_pairs: 52 | data = pair.split("::") 53 | if len(data) < 2: 54 | await cache.delete(str(user.id)) 55 | raise ForbiddenError( 56 | "Broken separator, try to login again. Token is not valid anymore" 57 | ) 58 | fp, cached_token, *_ = data 59 | if fp == body.fingerprint and cached_token == token: 60 | verified = pair 61 | break 62 | 63 | if not verified: 64 | await cache.delete(str(user.id)) 65 | raise ForbiddenError("Token is not valid anymore") 66 | 67 | await cache.pop(str(user.id), verified) 68 | _, access = await run_in_threadpool(jwt.create, typ="access", sub=str(user.id)) 69 | expire, refresh = await run_in_threadpool( 70 | jwt.create, typ="refresh", sub=str(user.id) 71 | ) 72 | await cache.set_list(str(user.id), f"{body.fingerprint}::{refresh.token}") 73 | 74 | return TokensExpire( 75 | refresh_expire=expire, 76 | tokens=Tokens(access=access.token, refresh=refresh.token), 77 | ) 78 | 79 | async def invalidate_refresh( 80 | self, 81 | request: Request, 82 | jwt: Annotated[TokenJWT, Depends(Stub(TokenJWT))], 83 | database: Annotated[DBGateway, Depends(Stub(DBGateway))], 84 | cache: Annotated[RedisCache, Depends(Stub(RedisCache))], 85 | ) -> Status: 86 | token = request.cookies.get("refresh_token", "") 87 | user = await self._verify_token(jwt, database, token, "refresh") 88 | token_pairs = await cache.get_list(str(user.id)) 89 | for pair in token_pairs: 90 | data = pair.split("::") 91 | if len(data) < 2: 92 | await cache.delete(str(user.id)) 93 | break 94 | _, cached_token, *_ = data 95 | if cached_token == token: 96 | await cache.pop(str(user.id), pair) 97 | break 98 | 99 | return Status(ok=True) 100 | 101 | async def _verify_token( 102 | self, 103 | jwt: TokenJWT, 104 | database: DBGateway, 105 | token: str, 106 | token_type: TokenType, 107 | ) -> User: 108 | payload = await run_in_threadpool(jwt.verify_token, token) 109 | user_id = payload.get("sub") 110 | actual_token_type = payload.get("type") 111 | 112 | if actual_token_type != token_type: 113 | raise ForbiddenError("Invalid token") 114 | async with database: 115 | user = await database.user().get_one(user_id=uuid.UUID(user_id)) 116 | 117 | if not user: 118 | raise ForbiddenError("Not authenticated") 119 | 120 | return from_model_to_dto(user, User) 121 | 122 | def _get_token(self, request: Request) -> str: 123 | authorization = request.headers.get("Authorization") 124 | scheme, token = get_authorization_scheme_param(authorization) 125 | if not (authorization and scheme and token): 126 | raise ForbiddenError("Not authenticated") 127 | if scheme.lower() != "bearer": 128 | raise ForbiddenError("Invalid authentication credentials") 129 | 130 | return token 131 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import ( 3 | Any, 4 | Dict, 5 | Optional, 6 | Protocol, 7 | Set, 8 | Type, 9 | Union, 10 | get_args, 11 | get_origin, 12 | get_overloads, 13 | get_type_hints, 14 | overload, 15 | ) 16 | 17 | import src.common.dto as dto 18 | from src.api.v1.handlers.commands.base import QT, RT, Command, CommandProtocol 19 | from src.api.v1.handlers.commands.mediator import AwaitableProxy, CommandType 20 | from src.api.v1.handlers.commands.user import ( 21 | CreateUserCommand, 22 | GetUserCommand, 23 | GetUserQuery, 24 | ) 25 | from src.common.interfaces.hasher import AbstractHasher 26 | 27 | __all__ = ( 28 | "CreateUserCommand", 29 | "GetUserQuery", 30 | "GetUserCommand", 31 | ) 32 | 33 | 34 | class CommandMediatorProtocol(Protocol): 35 | # there you should add an overload for your command 36 | # it need to auto registry your command and also typing in routes 37 | @overload 38 | def send( 39 | self, query: dto.CreateUser, *, hasher: AbstractHasher 40 | ) -> AwaitableProxy[CreateUserCommand, dto.User]: ... 41 | @overload 42 | def send(self, query: GetUserQuery) -> AwaitableProxy[GetUserCommand, dto.User]: ... 43 | 44 | # default one, should leave unchanged at the bottom of the protocol 45 | def send(self, query: QT, **kwargs: Any) -> AwaitableProxy[CommandType, RT]: ... 46 | 47 | 48 | def _predict_dependency_or_raise( 49 | actual: Dict[str, Any], 50 | expectable: Dict[str, Any], 51 | non_checkable: Optional[Set[str]] = None, 52 | ) -> Dict[str, Any]: 53 | if not non_checkable: 54 | non_checkable = set() 55 | 56 | missing = [k for k in actual if k not in expectable and k not in non_checkable] 57 | if missing: 58 | details = ", ".join(f"`{k}`:`{actual[k]}`" for k in missing) 59 | raise TypeError(f"Did you forget to set dependency for {details}?") 60 | 61 | return {k: value if (value := expectable.get(k)) else actual[k] for k in actual} 62 | 63 | 64 | def _retrieve_command_params(command: CommandProtocol) -> Dict[str, Any]: 65 | return {k: v.annotation for k, v in inspect.signature(command).parameters.items()} 66 | 67 | 68 | def get_query_commands() -> ( 69 | Dict[Type[dto.DTO], Dict[str, Union[Type[CommandProtocol], Any]]] 70 | ): 71 | commands = {} 72 | overloads = get_overloads(CommandMediatorProtocol.send) 73 | for send in overloads: 74 | hints = get_type_hints(send) 75 | query, proxy = hints.get("query"), hints.get("return") 76 | 77 | if not query or not proxy: 78 | raise TypeError( 79 | "Did you forget to annotate your overloads? " 80 | "It should contain :query: param and :return: AwaitableProxy generic" 81 | ) 82 | origin = get_origin(proxy) 83 | if origin is not AwaitableProxy: 84 | raise TypeError("Return type must be a type of AwaitableProxy.") 85 | 86 | args = get_args(proxy) 87 | 88 | if len(args) < 2: 89 | raise TypeError("AwaitableProxy must have two generic parameters") 90 | 91 | command = args[0] 92 | if not issubclass(command, Command): 93 | raise TypeError("command must inherit from base Command class.") 94 | 95 | commands[query] = {"command": command, **_retrieve_command_params(command)} 96 | 97 | return commands 98 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Generic, Protocol, TypeVar, runtime_checkable 3 | 4 | QT = TypeVar("QT") 5 | RT = TypeVar("RT") 6 | 7 | 8 | @runtime_checkable 9 | class CommandProtocol(Protocol): 10 | def __init__(self, **dependencies: Any) -> None: ... 11 | async def __call__(self, query: Any, **kwargs: Any) -> Any: ... 12 | async def execute(self, query: Any, **kwargs: Any) -> Any: ... 13 | 14 | 15 | class Command(CommandProtocol, Generic[QT, RT]): 16 | __slots__ = () 17 | 18 | async def __call__(self, query: QT, **kwargs: Any) -> RT: 19 | return await self.execute(query=query, **kwargs) 20 | 21 | @abc.abstractmethod 22 | async def execute(self, query: QT, **kwargs: Any) -> RT: 23 | raise NotImplementedError 24 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/mediator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Generator, Generic, Type, TypeVar, Union, cast 2 | 3 | from src.api.v1.handlers.commands.base import QT, RT, CommandProtocol 4 | 5 | CommandType = TypeVar("CommandType", bound=CommandProtocol) 6 | 7 | 8 | class AwaitableProxy(Generic[CommandType, RT]): 9 | __slots__ = ( 10 | "_command", 11 | "_kw", 12 | ) 13 | 14 | def __init__(self, command: CommandType, **kw: Any) -> None: 15 | self._command = command 16 | self._kw = kw 17 | 18 | def __await__(self) -> Generator[None, None, RT]: 19 | result = yield from self._command(**self._kw).__await__() 20 | return cast(RT, result) 21 | 22 | 23 | def _resolve_factory( 24 | command_or_factory: Union[Callable[[], CommandProtocol], CommandProtocol], 25 | ) -> CommandProtocol: 26 | if isinstance(command_or_factory, CommandProtocol): 27 | return command_or_factory 28 | return command_or_factory() 29 | 30 | 31 | class CommandMediator: 32 | def __init__(self) -> None: 33 | self._commands: Dict[ 34 | Type[Any], Union[Callable[[], CommandProtocol], CommandProtocol] 35 | ] = {} 36 | 37 | def add( 38 | self, 39 | query: Type[QT], 40 | command_or_factory: Union[Callable[[], CommandProtocol], CommandProtocol], 41 | ) -> None: 42 | self._commands[query] = command_or_factory 43 | 44 | def send(self, query: QT, **kwargs: Any) -> AwaitableProxy[CommandProtocol, RT]: 45 | handler = _resolve_factory(self._commands[type(query)]) 46 | return AwaitableProxy(handler, query=query, **kwargs) 47 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/setup.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Type, Union 2 | 3 | from src.api.v1.handlers.commands import _predict_dependency_or_raise, get_query_commands 4 | from src.api.v1.handlers.commands.base import CommandProtocol 5 | from src.api.v1.handlers.commands.mediator import CommandMediator 6 | 7 | 8 | def create_command_lazy( 9 | command: Type[CommandProtocol], **dependencies: Union[Callable[[], Any], Any] 10 | ) -> Callable[[], CommandProtocol]: 11 | def _create() -> CommandProtocol: 12 | return command( 13 | **{k: v() if callable(v) else v for k, v in dependencies.items()} 14 | ) 15 | 16 | return _create 17 | 18 | 19 | def setup_command_mediator(mediator: CommandMediator, **kw: Any) -> None: 20 | for query, command in get_query_commands().items(): 21 | mediator.add( 22 | query=query, 23 | command_or_factory=create_command_lazy( 24 | **_predict_dependency_or_raise(command, kw, {"command"}) 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/user/__init__.py: -------------------------------------------------------------------------------- 1 | from src.api.v1.handlers.commands.user.create import CreateUserCommand 2 | from src.api.v1.handlers.commands.user.select import GetUserCommand, GetUserQuery 3 | 4 | __all__ = ( 5 | "CreateUserCommand", 6 | "GetUserQuery", 7 | "GetUserCommand", 8 | ) 9 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/user/create.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import src.common.dto as dto 4 | from src.api.v1.handlers.commands.base import Command 5 | from src.services.gateway import ServiceGateway 6 | 7 | 8 | class CreateUserCommand(Command[dto.CreateUser, dto.User]): 9 | __slots__ = ("_gateway",) 10 | 11 | def __init__(self, gateway: ServiceGateway) -> None: 12 | self._gateway = gateway 13 | 14 | async def execute(self, query: dto.CreateUser, **kwargs: Any) -> dto.User: 15 | async with self._gateway: 16 | await self._gateway.database.manager.create_transaction() 17 | 18 | return await self._gateway.user().create(query, **kwargs) 19 | -------------------------------------------------------------------------------- /src/api/v1/handlers/commands/user/select.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any 3 | 4 | import src.common.dto as dto 5 | from src.api.v1.handlers.commands.base import Command 6 | from src.services.gateway import ServiceGateway 7 | 8 | 9 | class GetUserQuery(dto.DTO): 10 | user_id: uuid.UUID 11 | 12 | 13 | class GetUserCommand(Command[GetUserQuery, dto.User]): 14 | __slots__ = ("_gateway",) 15 | 16 | def __init__(self, gateway: ServiceGateway) -> None: 17 | self._gateway = gateway 18 | 19 | async def execute(self, query: GetUserQuery, **kwargs: Any) -> dto.User: 20 | async with self._gateway: 21 | return await self._gateway.user().get_one(user_id=query.user_id) 22 | -------------------------------------------------------------------------------- /src/api/v1/handlers/login.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Final 2 | 3 | from fastapi import Depends 4 | from fastapi.concurrency import run_in_threadpool 5 | 6 | import src.common.dto as dto 7 | from src.api.common.cache.redis import RedisCache 8 | from src.api.common.providers import Stub 9 | from src.common.exceptions import NotFoundError, UnAuthorizedError 10 | from src.common.interfaces.hasher import AbstractHasher 11 | from src.database.gateway import DBGateway 12 | from src.services.security.jwt import TokenJWT 13 | 14 | DEFAULT_TOKENS_COUNT: Final[int] = 5 15 | 16 | 17 | class Login: 18 | async def __call__( 19 | self, 20 | body: dto.UserLogin, 21 | hasher: Annotated[AbstractHasher, Depends(Stub(AbstractHasher))], 22 | cache: Annotated[RedisCache, Depends(Stub(RedisCache))], 23 | jwt: Annotated[TokenJWT, Depends(Stub(TokenJWT))], 24 | database: Annotated[DBGateway, Depends(Stub(DBGateway))], 25 | ) -> dto.TokensExpire: 26 | async with database: 27 | user = await database.user().get_one(login=body.login) 28 | 29 | if not user: 30 | raise NotFoundError("User not found") 31 | 32 | if not hasher.verify_password(user.password, body.password): 33 | raise UnAuthorizedError("Incorrect password") 34 | 35 | _, access = await run_in_threadpool(jwt.create, typ="access", sub=str(user.id)) 36 | expire, refresh = await run_in_threadpool( 37 | jwt.create, typ="refresh", sub=str(user.id) 38 | ) 39 | tokens = await cache.get_list(str(user.id)) 40 | if len(tokens) > DEFAULT_TOKENS_COUNT: 41 | await cache.delete(str(user.id)) 42 | 43 | await cache.set_list(str(user.id), f"{body.fingerprint}::{refresh.token}") 44 | 45 | return dto.TokensExpire( 46 | refresh_expire=expire, 47 | tokens=dto.Tokens(access=access.token, refresh=refresh.token), 48 | ) 49 | -------------------------------------------------------------------------------- /src/api/v1/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/api/v1/middlewares/__init__.py -------------------------------------------------------------------------------- /src/api/v1/setup.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from typing import Any, AsyncIterator, Optional, Tuple 3 | 4 | from fastapi import FastAPI 5 | 6 | from src.api.common.exceptions import setup_exception_handlers 7 | from src.api.common.responses import DefaultJSONResponse 8 | from src.api.v1.dependencies import setup_dependencies 9 | from src.api.v1.endpoints import setup_routers 10 | from src.core.logger import log 11 | from src.core.settings import Settings 12 | 13 | 14 | @asynccontextmanager 15 | async def lifespan(app: FastAPI) -> AsyncIterator[None]: 16 | yield 17 | if hasattr(app.state, "engine"): 18 | await app.state.engine.dispose() 19 | 20 | 21 | def init_app_v1( 22 | settings: Settings, 23 | title: str = "FastAPI", 24 | version: str = "0.1.0", 25 | docs_url: Optional[str] = "/docs", 26 | redoc_url: Optional[str] = "/redoc", 27 | swagger_ui_oauth2_redirect_url: Optional[str] = "/docs/oauth2-redirect", 28 | **kw: Any, 29 | ) -> Tuple[str, FastAPI, Optional[str]]: 30 | log.info("Initialize V1 API") 31 | app = FastAPI( 32 | title=title, 33 | version=version, 34 | default_response_class=DefaultJSONResponse, 35 | lifespan=lifespan, 36 | docs_url=docs_url, 37 | redoc_url=redoc_url, 38 | swagger_ui_oauth2_redirect_url=swagger_ui_oauth2_redirect_url, 39 | **kw, 40 | ) 41 | setup_dependencies(app, settings) 42 | setup_routers(app) 43 | setup_exception_handlers(app) 44 | 45 | return ("/api/v1", app, None) 46 | -------------------------------------------------------------------------------- /src/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/common/__init__.py -------------------------------------------------------------------------------- /src/common/dto/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from src.common.dto.base import DTO 4 | from src.common.dto.status import Status 5 | from src.common.dto.token import Token, Tokens, TokensExpire 6 | from src.common.dto.user import CreateUser, Fingerprint, User, UserLogin 7 | 8 | 9 | # this is a hack for recursive imports pydantic types 10 | def rebuild_models() -> None: 11 | module = importlib.import_module(__name__) 12 | for model_name in set(__all__): 13 | model = getattr(module, model_name, None) 14 | if model and issubclass(model, DTO): 15 | model.model_rebuild() 16 | 17 | 18 | __all__ = ( 19 | "DTO", 20 | "Token", 21 | "Tokens", 22 | "User", 23 | "CreateUser", 24 | "Status", 25 | "UserLogin", 26 | "TokensExpire", 27 | "Fingerprint", 28 | ) 29 | 30 | # this should be unchanged 31 | rebuild_models() 32 | -------------------------------------------------------------------------------- /src/common/dto/base.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from pydantic import BaseModel 4 | 5 | DTOType = TypeVar("DTOType", bound="DTO") 6 | 7 | 8 | class DTO(BaseModel): 9 | pass 10 | -------------------------------------------------------------------------------- /src/common/dto/status.py: -------------------------------------------------------------------------------- 1 | from src.common.dto.base import DTO 2 | 3 | 4 | class Status(DTO): 5 | ok: bool 6 | -------------------------------------------------------------------------------- /src/common/dto/token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from src.common.dto.base import DTO 4 | 5 | 6 | class Token(DTO): 7 | token: str 8 | 9 | 10 | class Tokens(DTO): 11 | access: str 12 | refresh: str 13 | 14 | 15 | class TokensExpire(DTO): 16 | refresh_expire: datetime 17 | tokens: Tokens 18 | -------------------------------------------------------------------------------- /src/common/dto/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from src.common.dto.base import DTO 6 | 7 | 8 | class User(DTO): 9 | id: uuid.UUID 10 | login: str 11 | 12 | 13 | class CreateUser(DTO): 14 | login: str 15 | password: str 16 | 17 | 18 | class Fingerprint(DTO): 19 | fingerprint: str 20 | 21 | 22 | class UserLogin(Fingerprint): 23 | login: str 24 | password: str 25 | -------------------------------------------------------------------------------- /src/common/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | 4 | class AppException(Exception): 5 | def __init__( 6 | self, 7 | message: str = "App exception", 8 | headers: Optional[Dict[str, Any]] = None, 9 | ) -> None: 10 | self.content = {"message": message} 11 | self.headers = headers 12 | 13 | def as_dict(self) -> Dict[str, Any]: 14 | return self.__dict__ 15 | 16 | 17 | class DetailedError(AppException): 18 | def __init__( 19 | self, 20 | message: str, 21 | headers: Optional[Dict[str, Any]] = None, 22 | **additional: Any, 23 | ) -> None: 24 | super().__init__(message=message, headers=headers) 25 | self.content |= additional 26 | 27 | def __str__(self) -> str: 28 | return f"{type(self).__name__}: {self.content}\nHeaders: {self.headers or ''}" 29 | 30 | 31 | class UnAuthorizedError(DetailedError): 32 | pass 33 | 34 | 35 | class NotFoundError(DetailedError): 36 | pass 37 | 38 | 39 | class BadRequestError(DetailedError): 40 | pass 41 | 42 | 43 | class TooManyRequestsError(DetailedError): 44 | pass 45 | 46 | 47 | class ServiceUnavailableError(DetailedError): 48 | pass 49 | 50 | 51 | class ForbiddenError(DetailedError): 52 | pass 53 | 54 | 55 | class ServiceNotImplementedError(DetailedError): 56 | pass 57 | 58 | 59 | class ConflictError(DetailedError): 60 | pass 61 | -------------------------------------------------------------------------------- /src/common/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/common/interfaces/__init__.py -------------------------------------------------------------------------------- /src/common/interfaces/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import TracebackType 4 | from typing import Optional, Protocol, Type 5 | 6 | 7 | class AsyncContextManager(Protocol): 8 | async def __aexit__( 9 | self, 10 | exc_type: Optional[Type[BaseException]], 11 | exc_value: Optional[BaseException], 12 | traceback: Optional[TracebackType], 13 | ) -> None: ... 14 | 15 | async def __aenter__(self) -> AsyncContextManager: ... 16 | -------------------------------------------------------------------------------- /src/common/interfaces/crud.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import ( 3 | Any, 4 | Generic, 5 | Mapping, 6 | Optional, 7 | Sequence, 8 | Type, 9 | TypeVar, 10 | ) 11 | 12 | EntryType = TypeVar("EntryType") 13 | 14 | 15 | class AbstractCRUDRepository(abc.ABC, Generic[EntryType]): 16 | __slots__ = ("model",) 17 | 18 | def __init__(self, model: Type[EntryType]) -> None: 19 | self.model = model 20 | 21 | @abc.abstractmethod 22 | async def insert(self, **values: Mapping[str, Any]) -> Optional[EntryType]: 23 | raise NotImplementedError 24 | 25 | @abc.abstractmethod 26 | async def insert_many( 27 | self, values: Sequence[Mapping[str, Any]] 28 | ) -> Sequence[EntryType]: 29 | raise NotImplementedError 30 | 31 | @abc.abstractmethod 32 | async def select(self, *clauses: Any) -> Optional[EntryType]: 33 | raise NotImplementedError 34 | 35 | @abc.abstractmethod 36 | async def select_many( 37 | self, *clauses: Any, offset: Optional[int] = None, limit: Optional[int] = None 38 | ) -> Sequence[EntryType]: 39 | raise NotImplementedError 40 | 41 | @abc.abstractmethod 42 | async def update( 43 | self, *clauses: Any, **values: Mapping[str, Any] 44 | ) -> Sequence[EntryType]: 45 | raise NotImplementedError 46 | 47 | @abc.abstractmethod 48 | async def update_many(self, values: Sequence[Mapping[str, Any]]) -> Any: 49 | raise NotImplementedError 50 | 51 | @abc.abstractmethod 52 | async def delete(self, *clauses: Any) -> Sequence[EntryType]: 53 | raise NotImplementedError 54 | 55 | async def exists(self, *clauses: Any) -> bool: 56 | raise NotImplementedError 57 | 58 | async def count(self, *clauses: Any) -> int: 59 | raise NotImplementedError 60 | -------------------------------------------------------------------------------- /src/common/interfaces/encrypt.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class AbstractEncrypt(Protocol): 5 | def encrypt(self, plain_text: str) -> str: ... 6 | 7 | def decrypt(self, encrypted_text: str) -> str: ... 8 | -------------------------------------------------------------------------------- /src/common/interfaces/gateway.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from types import TracebackType 5 | from typing import Optional, Type, TypeVar 6 | 7 | from src.common.interfaces.context import AsyncContextManager 8 | 9 | GatewayType = TypeVar("GatewayType", bound="BaseGateway") 10 | 11 | 12 | class BaseGateway(abc.ABC): 13 | __slots__ = ("__context_manager",) 14 | 15 | def __init__(self, context_manager: AsyncContextManager) -> None: 16 | self.__context_manager = context_manager 17 | 18 | async def __aenter__(self: GatewayType) -> GatewayType: 19 | await self.__context_manager.__aenter__() 20 | return self 21 | 22 | async def __aexit__( 23 | self, 24 | exc_type: Optional[Type[BaseException]], 25 | exc_value: Optional[BaseException], 26 | traceback: Optional[TracebackType], 27 | ) -> None: 28 | await self.__context_manager.__aexit__(exc_type, exc_value, traceback) 29 | -------------------------------------------------------------------------------- /src/common/interfaces/hasher.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class AbstractHasher(Protocol): 5 | def hash_password(self, plain: str) -> str: ... 6 | 7 | def verify_password(self, hashed: str, plain: str) -> bool: ... 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/common/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/common/serializers/__init__.py -------------------------------------------------------------------------------- /src/common/serializers/default.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | def _predict_bytes(value: Any) -> Optional[bytes]: 8 | match value: 9 | case str(): 10 | return value.encode() 11 | case bytes(): 12 | return value 13 | case _: 14 | return None 15 | 16 | 17 | def _default(value: Any) -> Optional[Union[str, Dict[str, Any]]]: 18 | match value: 19 | case BaseModel(): 20 | return value.model_dump(mode="json", exclude_none=True, by_alias=True) 21 | case UUID(): 22 | return str(value) 23 | case Exception(): 24 | return value.args[0] if len(value.args) > 0 else "Unknown error" 25 | case _: 26 | return None 27 | -------------------------------------------------------------------------------- /src/common/serializers/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from src.common.serializers.default import _default, _predict_bytes 5 | 6 | 7 | def json_dumps(value: Any) -> bytes: 8 | return ( 9 | _predict_bytes(value) 10 | or json.dumps( 11 | value, 12 | default=_default, 13 | ensure_ascii=False, 14 | separators=(",", ":"), 15 | allow_nan=False, 16 | ).encode() 17 | ) 18 | -------------------------------------------------------------------------------- /src/common/serializers/orjson.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Any 3 | 4 | from src.common.serializers.default import _default, _predict_bytes 5 | from src.common.serializers.json import json_dumps 6 | 7 | try: 8 | import orjson as orjson # type: ignore # noqa 9 | except ImportError: # pragma: no cover 10 | orjson = None # type: ignore 11 | 12 | 13 | def orjson_dumps(value: Any) -> bytes: 14 | if not orjson: 15 | warnings.warn( 16 | message="orjson is not installed. Consider to install it `pip install orjson`. Using default json serializer", 17 | stacklevel=1, 18 | ) 19 | return json_dumps(value) 20 | return _predict_bytes(value) or orjson.dumps( 21 | value, 22 | default=_default, 23 | option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY, 24 | ) 25 | -------------------------------------------------------------------------------- /src/common/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | ResultType = TypeVar("ResultType") 4 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/gunicorn_server.py: -------------------------------------------------------------------------------- 1 | # mypy: ignore-errors 2 | import logging 3 | import multiprocessing as mp 4 | from typing import Any, Dict, Optional 5 | 6 | from gunicorn.app.base import Application 7 | 8 | from src.core.logger import log 9 | from src.core.settings import Settings 10 | 11 | system = logging.basicConfig() 12 | 13 | 14 | def workers_count() -> int: 15 | return (mp.cpu_count() * 2) + 1 16 | 17 | 18 | class GunicornApplication(Application): 19 | def __init__( 20 | self, app: Any, options: Optional[Dict[str, Any]] = None, **kw: Any 21 | ) -> None: 22 | self._options = options or {} 23 | self._app = app 24 | super().__init__(**kw) 25 | 26 | def load_config(self) -> None: 27 | for key, value in self._options.items(): 28 | if key in self.cfg.settings and value is not None: 29 | self.cfg.set(key.lower(), value) 30 | 31 | def load(self) -> Any: 32 | return self._app 33 | 34 | 35 | def run_api_gunicorn(app: Any, settings: Settings, **kwargs: Any) -> None: 36 | options = { 37 | "bind": f"{settings.server.host}:{settings.server.port}", 38 | "worker_class": "uvicorn.workers.UvicornWorker", 39 | "preload_app": True, 40 | "timeout": 3600, 41 | "workers": workers_count(), 42 | "accesslog": "-", 43 | # "worker_connections": 1000, # 1000 is default value 44 | # "max_requests": 1000, 45 | # "max_requests_jitter": 50, 46 | # "threads": 4, 47 | } 48 | gunicorn_app = GunicornApplication(app, options | kwargs) 49 | log.info("Running API Gunicorn") 50 | gunicorn_app.run() 51 | -------------------------------------------------------------------------------- /src/core/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from src.core.settings import ( 4 | DATETIME_FORMAT, 5 | LOGGING_FORMAT, 6 | PROJECT_NAME, 7 | ) 8 | from src.core.settings import LOG_LEVEL as LOG_LEVEL 9 | 10 | logging.basicConfig(level=LOG_LEVEL, format=LOGGING_FORMAT, datefmt=DATETIME_FORMAT) 11 | 12 | log = logging.getLogger(PROJECT_NAME) 13 | -------------------------------------------------------------------------------- /src/core/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import ( 4 | Final, 5 | List, 6 | Optional, 7 | Union, 8 | ) 9 | 10 | from dotenv import find_dotenv, load_dotenv 11 | from pydantic import Field 12 | from pydantic_settings import BaseSettings, SettingsConfigDict 13 | 14 | load_dotenv(find_dotenv(raise_error_if_not_found=True)) 15 | 16 | _PathLike = Union[os.PathLike[str], str, Path] 17 | 18 | 19 | LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG") 20 | PROJECT_NAME = os.getenv("PROJECT_NAME", "") 21 | PROJECT_VERSION = os.getenv("PROJECT_VERSION", "") 22 | 23 | LOGGING_FORMAT: Final[str] = "%(asctime)s %(name)s %(levelname)s -> %(message)s" 24 | DATETIME_FORMAT: Final[str] = "%Y.%m.%d %H:%M" 25 | 26 | 27 | def root_dir() -> Path: 28 | return Path(__file__).resolve().parent.parent.parent 29 | 30 | 31 | def path(*paths: _PathLike, base_path: Optional[_PathLike] = None) -> str: 32 | if base_path is None: 33 | base_path = root_dir() 34 | 35 | return os.path.join(base_path, *paths) 36 | 37 | 38 | class DatabaseSettings(BaseSettings): 39 | model_config = SettingsConfigDict( 40 | env_file="./.env", 41 | env_file_encoding="utf-8", 42 | case_sensitive=False, 43 | env_prefix="DB_", 44 | extra="ignore", 45 | ) 46 | 47 | uri: str = Field(default="") 48 | name: str = Field(default="") 49 | host: Optional[str] = None 50 | port: Optional[int] = None 51 | user: Optional[str] = None 52 | password: Optional[str] = None 53 | connection_pool_size: int = 10 54 | connection_max_overflow: int = 90 55 | connection_pool_pre_ping: bool = True 56 | 57 | @property 58 | def url(self) -> str: 59 | if "sqlite" in self.uri: 60 | return self.uri.format(self.name) 61 | return self.uri.format( 62 | self.user, 63 | self.password, 64 | self.host, 65 | self.port, 66 | self.name, 67 | ) 68 | 69 | 70 | class RedisSettings(BaseSettings): 71 | model_config = SettingsConfigDict( 72 | env_file="./.env", 73 | env_file_encoding="utf-8", 74 | case_sensitive=False, 75 | env_prefix="REDIS_", 76 | extra="ignore", 77 | ) 78 | 79 | host: str = "127.0.0.1" 80 | port: int = 6379 81 | password: Optional[str] = None 82 | 83 | 84 | class ServerSettings(BaseSettings): 85 | model_config = SettingsConfigDict( 86 | env_file="./.env", 87 | env_file_encoding="utf-8", 88 | case_sensitive=False, 89 | env_prefix="SERVER_", 90 | extra="ignore", 91 | ) 92 | methods: List[str] = ["*"] 93 | headers: List[str] = ["*"] 94 | origins: List[str] = ["*"] 95 | host: str = "127.0.0.1" 96 | port: int = 8080 97 | 98 | 99 | class CipherSettings(BaseSettings): 100 | model_config = SettingsConfigDict( 101 | env_file="./.env", 102 | env_file_encoding="utf-8", 103 | case_sensitive=False, 104 | env_prefix="CIPHER_", 105 | extra="ignore", 106 | ) 107 | algorithm: str = "" 108 | secret_key: str = "" 109 | public_key: str = "" 110 | access_token_expire_seconds: int = 0 111 | refresh_token_expire_seconds: int = 0 112 | 113 | 114 | class Settings(BaseSettings): 115 | db: DatabaseSettings 116 | redis: RedisSettings 117 | server: ServerSettings 118 | ciphers: CipherSettings 119 | 120 | 121 | def load_settings( 122 | db: Optional[DatabaseSettings] = None, 123 | redis: Optional[RedisSettings] = None, 124 | server: Optional[ServerSettings] = None, 125 | ciphers: Optional[CipherSettings] = None, 126 | ) -> Settings: 127 | return Settings( 128 | db=db or DatabaseSettings(), 129 | redis=redis or RedisSettings(), 130 | server=server or ServerSettings(), 131 | ciphers=ciphers or CipherSettings(), 132 | ) 133 | -------------------------------------------------------------------------------- /src/core/uvicorn_server.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import uvicorn 4 | 5 | from src.core.logger import LOG_LEVEL, log 6 | from src.core.settings import Settings 7 | 8 | 9 | def run_api_uvicorn(app: Any, config: Settings, **kw: Any) -> None: 10 | uv_config = uvicorn.Config( 11 | app, 12 | host=config.server.host, 13 | port=config.server.port, 14 | log_level=LOG_LEVEL.lower(), 15 | server_header=False, 16 | **kw, 17 | ) 18 | server = uvicorn.Server(uv_config) 19 | log.info("Running API Uvicorn") 20 | server.run() 21 | -------------------------------------------------------------------------------- /src/database/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Type 2 | 3 | from src.database.core.connection import SessionFactoryType 4 | from src.database.core.manager import TransactionManager 5 | from src.database.gateway import DBGateway 6 | 7 | 8 | def create_database_factory( 9 | manager: Type[TransactionManager], session_factory: SessionFactoryType 10 | ) -> Callable[[], DBGateway]: 11 | def _create() -> DBGateway: 12 | return DBGateway(manager(session_factory())) 13 | 14 | return _create 15 | 16 | 17 | __all__ = ("DBGateway",) 18 | -------------------------------------------------------------------------------- /src/database/converter.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from src.common.dto.base import DTOType 4 | from src.database.models.base import ModelType 5 | 6 | 7 | def from_model_to_dto(model: ModelType, dto: Type[DTOType]) -> DTOType: 8 | return dto(**model.as_dict()) 9 | -------------------------------------------------------------------------------- /src/database/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/database/core/__init__.py -------------------------------------------------------------------------------- /src/database/core/connection.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.ext.asyncio import ( 4 | AsyncEngine, 5 | AsyncSession, 6 | async_sessionmaker, 7 | create_async_engine, 8 | ) 9 | 10 | SessionFactoryType = async_sessionmaker[AsyncSession] 11 | 12 | 13 | def create_sa_engine(url: str, **kwargs: Any) -> AsyncEngine: 14 | return create_async_engine(url, **kwargs) 15 | 16 | 17 | def create_sa_session_factory(engine: AsyncEngine) -> SessionFactoryType: 18 | return async_sessionmaker(engine, autoflush=False, expire_on_commit=False) 19 | -------------------------------------------------------------------------------- /src/database/core/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import TracebackType 4 | from typing import Optional, Type, Union 5 | 6 | from sqlalchemy.exc import SQLAlchemyError 7 | from sqlalchemy.ext.asyncio import ( 8 | AsyncSession, 9 | AsyncSessionTransaction, 10 | async_sessionmaker, 11 | ) 12 | 13 | from src.database.exceptions import CommitError, RollbackError 14 | 15 | 16 | class TransactionManager: 17 | __slots__ = ( 18 | "session", 19 | "_transaction", 20 | ) 21 | 22 | def __init__( 23 | self, session_or_factory: Union[AsyncSession, async_sessionmaker[AsyncSession]] 24 | ) -> None: 25 | if isinstance(session_or_factory, async_sessionmaker): 26 | self.session = session_or_factory() 27 | else: 28 | self.session = session_or_factory 29 | 30 | self._transaction: Optional[AsyncSessionTransaction] = None 31 | 32 | async def __aexit__( 33 | self, 34 | exc_type: Optional[Type[BaseException]], 35 | exc_value: Optional[BaseException], 36 | traceback: Optional[TracebackType], 37 | ) -> None: 38 | if self._transaction: 39 | if exc_type: 40 | await self.rollback() 41 | else: 42 | await self.commit() 43 | 44 | await self.close_transaction() 45 | 46 | async def __aenter__(self) -> TransactionManager: 47 | return self 48 | 49 | async def commit(self) -> None: 50 | try: 51 | await self.session.commit() 52 | except SQLAlchemyError as err: 53 | raise CommitError from err 54 | 55 | async def rollback(self) -> None: 56 | try: 57 | await self.session.rollback() 58 | except SQLAlchemyError as err: 59 | raise RollbackError from err 60 | 61 | async def create_transaction(self) -> None: 62 | if not self.session.in_transaction() and self.session.is_active: 63 | self._transaction = await self.session.begin() 64 | 65 | async def close_transaction(self) -> None: 66 | if self.session.is_active: 67 | await self.session.close() 68 | -------------------------------------------------------------------------------- /src/database/exceptions.py: -------------------------------------------------------------------------------- 1 | class DatabaseError(Exception): 2 | pass 3 | 4 | 5 | class CommitError(DatabaseError): 6 | pass 7 | 8 | 9 | class RollbackError(DatabaseError): 10 | pass 11 | 12 | 13 | class InvalidParamsError(DatabaseError): 14 | pass 15 | -------------------------------------------------------------------------------- /src/database/gateway.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from src.common.interfaces.gateway import BaseGateway 4 | from src.database.core.manager import TransactionManager 5 | from src.database.repositories import UserRepository 6 | from src.database.repositories.types import RepositoryType 7 | 8 | 9 | class DBGateway(BaseGateway): 10 | __slots__ = ("manager",) 11 | 12 | def __init__(self, manager: TransactionManager) -> None: 13 | self.manager = manager 14 | super().__init__(manager) 15 | 16 | def user(self) -> UserRepository: 17 | return self._init_repo(UserRepository) 18 | 19 | def _init_repo(self, cls: Type[RepositoryType]) -> RepositoryType: 20 | return cls(self.manager.session) 21 | -------------------------------------------------------------------------------- /src/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Dict, List, Mapping, Type 3 | 4 | from sqlalchemy.orm import RelationshipProperty 5 | 6 | from src.database.models.base import Base 7 | from src.database.models.user import User 8 | 9 | __all__ = ( 10 | "Base", 11 | "User", 12 | ) 13 | 14 | 15 | def _retrieve_relationships() -> ( 16 | Dict[Type[Base], List[RelationshipProperty[Type[Base]]]] 17 | ): 18 | return { 19 | mapper.class_: list(mapper.relationships.values()) 20 | for mapper in Base.registry.mappers 21 | } 22 | 23 | 24 | MODELS_RELATIONSHIPS_NODE: Mapping[ 25 | Type[Base], List[RelationshipProperty[Type[Base]]] 26 | ] = MappingProxyType(_retrieve_relationships()) 27 | -------------------------------------------------------------------------------- /src/database/models/base/__init__.py: -------------------------------------------------------------------------------- 1 | from src.database.models.base.core import Base, ModelType 2 | 3 | __all__ = ( 4 | "Base", 5 | "ModelType", 6 | ) 7 | -------------------------------------------------------------------------------- /src/database/models/base/core.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, TypeVar 3 | 4 | from sqlalchemy.ext.declarative import declared_attr 5 | from sqlalchemy.orm import DeclarativeBase 6 | 7 | ModelType = TypeVar("ModelType", bound="Base") 8 | 9 | 10 | class Base(DeclarativeBase): 11 | __abstract__: bool = True 12 | 13 | @declared_attr.directive 14 | def __tablename__(cls) -> str: 15 | return re.sub(r"(? str: 18 | params = ", ".join( 19 | f"{attr}={value!r}" 20 | for attr, value in self.__dict__.items() 21 | if not attr.startswith("_") 22 | ) 23 | return f"{type(self).__name__}({params})" 24 | 25 | def as_dict(self) -> Dict[str, Any]: 26 | result: Dict[str, Any] = {} 27 | for attr, value in self.__dict__.items(): 28 | if attr.startswith("_"): 29 | continue 30 | if isinstance(value, Base): 31 | result[attr] = value.as_dict() 32 | elif isinstance(value, (list, tuple)): 33 | result[attr] = type(value)( 34 | v.as_dict() if isinstance(v, Base) else v for v in value 35 | ) 36 | else: 37 | result[attr] = value 38 | 39 | return result 40 | -------------------------------------------------------------------------------- /src/database/models/base/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from src.database.models.base.mixins.with_id import ModelWithIDMixin 2 | from src.database.models.base.mixins.with_time import ModelWithTimeMixin 3 | from src.database.models.base.mixins.with_uuid import ModelWithUUIDMixin 4 | 5 | __all__ = ( 6 | 'ModelWithIDMixin', 7 | 'ModelWithUUIDMixin', 8 | 'ModelWithTimeMixin', 9 | ) 10 | -------------------------------------------------------------------------------- /src/database/models/base/mixins/with_id.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | 5 | class ModelWithIDMixin: 6 | """Base model class that represents ID with an integer type""" 7 | 8 | id: Mapped[int] = mapped_column( 9 | Integer, 10 | primary_key=True, 11 | index=True, 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /src/database/models/base/mixins/with_time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import DateTime, func 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | 7 | class ModelWithTimeMixin: 8 | 9 | created_at: Mapped[datetime] = mapped_column( 10 | DateTime(timezone=True), 11 | nullable=False, 12 | server_default=func.now() 13 | ) 14 | updated_at: Mapped[datetime] = mapped_column( 15 | DateTime(timezone=True), 16 | nullable=False, 17 | onupdate=func.now(), 18 | server_default=func.now() 19 | ) 20 | -------------------------------------------------------------------------------- /src/database/models/base/mixins/with_uuid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy import text 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | 8 | class ModelWithUUIDMixin: 9 | """BaseUUID model class that represents ID with an UUID type""" 10 | 11 | id: Mapped[uuid.UUID] = mapped_column( 12 | UUID(as_uuid=True), 13 | primary_key=True, 14 | default=uuid.uuid4, 15 | server_default=text('gen_random_uuid()'), 16 | index=True, 17 | nullable=False 18 | ) 19 | -------------------------------------------------------------------------------- /src/database/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import func 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | from sqlalchemy.schema import Index 4 | 5 | from src.database.models.base import Base 6 | from src.database.models.base.mixins import ModelWithTimeMixin, ModelWithUUIDMixin 7 | 8 | 9 | class User(ModelWithUUIDMixin, ModelWithTimeMixin, Base): 10 | login: Mapped[str] = mapped_column() 11 | password: Mapped[str] 12 | 13 | __table_args__ = (Index("idx_lower_login", func.lower(login), unique=True),) 14 | -------------------------------------------------------------------------------- /src/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from src.database.repositories.user import UserRepository 2 | 3 | __all__ = ("UserRepository",) 4 | -------------------------------------------------------------------------------- /src/database/repositories/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Generic, Type 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from src.database.models.base import ModelType 7 | from src.database.repositories.crud import CRUDRepository 8 | from src.database.repositories.types.repository import Repository 9 | 10 | 11 | class BaseRepository(Repository, Generic[ModelType]): 12 | __slots__ = ("_session", "_crud") 13 | 14 | def __init__(self, session: AsyncSession) -> None: 15 | self._session = session 16 | self._crud = CRUDRepository(session, self.model) 17 | 18 | @property 19 | @abc.abstractmethod 20 | def model(self) -> Type[ModelType]: 21 | raise NotImplementedError("Please implement me!") 22 | -------------------------------------------------------------------------------- /src/database/repositories/crud.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ( 4 | Any, 5 | Mapping, 6 | Optional, 7 | Sequence, 8 | Type, 9 | cast, 10 | ) 11 | 12 | from sqlalchemy import ( 13 | ColumnExpressionArgument, 14 | CursorResult, 15 | delete, 16 | exists, 17 | func, 18 | insert, 19 | select, 20 | update, 21 | ) 22 | from sqlalchemy.ext.asyncio import AsyncSession 23 | 24 | from src.common.interfaces.crud import AbstractCRUDRepository 25 | from src.database.models.base import ModelType 26 | 27 | 28 | class CRUDRepository(AbstractCRUDRepository[ModelType]): 29 | __slots__ = ("_session",) 30 | 31 | def __init__(self, session: AsyncSession, model: Type[ModelType]) -> None: 32 | super().__init__(model) 33 | self._session = session 34 | 35 | async def insert(self, **values: Any) -> Optional[ModelType]: 36 | stmt = insert(self.model).values(**values).returning(self.model) 37 | return (await self._session.execute(stmt)).scalars().first() 38 | 39 | async def insert_many( 40 | self, data: Sequence[Mapping[str, Any]] 41 | ) -> Sequence[ModelType]: 42 | stmt = insert(self.model).returning(self.model) 43 | result = await self._session.scalars(stmt, data) 44 | return result.all() 45 | 46 | async def select( 47 | self, 48 | *clauses: ColumnExpressionArgument[bool], 49 | ) -> Optional[ModelType]: 50 | stmt = select(self.model).where(*clauses) 51 | return (await self._session.execute(stmt)).scalars().first() 52 | 53 | async def select_many( 54 | self, 55 | *clauses: ColumnExpressionArgument[bool], 56 | offset: Optional[int] = None, 57 | limit: Optional[int] = None, 58 | ) -> Sequence[ModelType]: 59 | stmt = select(self.model).where(*clauses).offset(offset).limit(limit) 60 | return (await self._session.execute(stmt)).scalars().all() 61 | 62 | async def update( 63 | self, *clauses: ColumnExpressionArgument[bool], **values: Any 64 | ) -> Sequence[ModelType]: 65 | stmt = update(self.model).where(*clauses).values(**values).returning(self.model) 66 | return (await self._session.execute(stmt)).scalars().all() 67 | 68 | async def update_many(self, data: Sequence[Mapping[str, Any]]) -> CursorResult[Any]: 69 | return await self._session.execute(update(self.model), data) 70 | 71 | async def delete( 72 | self, *clauses: ColumnExpressionArgument[bool] 73 | ) -> Sequence[ModelType]: 74 | stmt = delete(self.model).where(*clauses).returning(self.model) 75 | return (await self._session.execute(stmt)).scalars().all() 76 | 77 | async def exists(self, *clauses: ColumnExpressionArgument[bool]) -> bool: 78 | stmt = exists(select(self.model).where(*clauses)).select() 79 | return cast(bool, await self._session.scalar(stmt)) 80 | 81 | async def count(self, *clauses: ColumnExpressionArgument[bool]) -> int: 82 | stmt = select(func.count()).where(*clauses).select_from(self.model) 83 | return cast(int, await self._session.scalar(stmt)) 84 | 85 | def with_query_model(self, model: Type[ModelType]) -> CRUDRepository[ModelType]: 86 | return CRUDRepository(self._session, model) 87 | -------------------------------------------------------------------------------- /src/database/repositories/types/__init__.py: -------------------------------------------------------------------------------- 1 | from src.database.repositories.types.repository import Repository, RepositoryType 2 | from src.database.repositories.types.user import CreateUserType 3 | 4 | __all__ = ("Repository", "CreateUserType", "RepositoryType") 5 | -------------------------------------------------------------------------------- /src/database/repositories/types/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, TypeVar 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | RepositoryType = TypeVar("RepositoryType", bound="Repository") 6 | 7 | 8 | class Repository(Protocol): 9 | def __init__(self, session: AsyncSession) -> None: ... 10 | -------------------------------------------------------------------------------- /src/database/repositories/types/user.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from typing_extensions import Required 4 | 5 | 6 | class CreateUserType(TypedDict): 7 | login: Required[str] 8 | password: Required[str] 9 | -------------------------------------------------------------------------------- /src/database/repositories/user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Optional, Type, overload 3 | 4 | from typing_extensions import Unpack 5 | 6 | import src.database.models as models 7 | from src.database.exceptions import InvalidParamsError 8 | from src.database.repositories.base import BaseRepository 9 | from src.database.repositories.types.user import CreateUserType 10 | 11 | 12 | class UserRepository(BaseRepository[models.User]): 13 | __slots__ = () 14 | 15 | @property 16 | def model(self) -> Type[models.User]: 17 | return models.User 18 | 19 | async def create(self, **data: Unpack[CreateUserType]) -> Optional[models.User]: 20 | return await self._crud.insert(**data) 21 | 22 | @overload 23 | async def get_one(self, *, user_id: uuid.UUID) -> Optional[models.User]: ... 24 | @overload 25 | async def get_one(self, *, login: str) -> Optional[models.User]: ... 26 | async def get_one( 27 | self, *, user_id: Optional[uuid.UUID] = None, login: Optional[str] = None 28 | ) -> Optional[models.User]: 29 | if not any([user_id, login]): 30 | raise InvalidParamsError("at least one identifier must be provided") 31 | 32 | clause = self.model.id == user_id if user_id else self.model.login == login 33 | 34 | return await self._crud.select(clause) 35 | -------------------------------------------------------------------------------- /src/database/tools.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from functools import lru_cache, wraps 3 | from typing import ( 4 | Any, 5 | Awaitable, 6 | Callable, 7 | List, 8 | Optional, 9 | ParamSpec, 10 | Tuple, 11 | Type, 12 | TypeVar, 13 | Union, 14 | ) 15 | 16 | from sqlalchemy import Select, select 17 | from sqlalchemy.exc import IntegrityError 18 | from sqlalchemy.orm import Load, RelationshipProperty, joinedload, subqueryload 19 | 20 | from src.common.exceptions import AppException, ConflictError 21 | from src.database.models import MODELS_RELATIONSHIPS_NODE 22 | from src.database.models.base import Base, ModelType 23 | 24 | P = ParamSpec("P") 25 | R = TypeVar("R") 26 | 27 | 28 | def _bfs_search( 29 | start: Type[Base], 30 | end: str, 31 | ) -> List[RelationshipProperty[Type[Base]]]: 32 | queue = deque([[start]]) 33 | checked = set() 34 | 35 | while queue: 36 | path = queue.popleft() 37 | current_node = path[-1] 38 | 39 | if current_node in checked: 40 | continue 41 | checked.add(current_node) 42 | 43 | current_relations = MODELS_RELATIONSHIPS_NODE.get(current_node, []) 44 | 45 | for relation in current_relations: 46 | new_path: List[Any] = list(path) 47 | new_path.append(relation) 48 | 49 | if relation.key == end: 50 | return [ 51 | rel for rel in new_path if isinstance(rel, RelationshipProperty) 52 | ] 53 | 54 | queue.append(new_path + [relation.mapper.class_]) 55 | 56 | return [] 57 | 58 | 59 | def _construct_loads( 60 | relationships: List[RelationshipProperty[Type[Base]]], 61 | ) -> Optional[Load]: 62 | if not relationships: 63 | return None 64 | 65 | load: Optional[Load] = None 66 | for relationship in relationships: 67 | loader = joinedload if not relationship.uselist else subqueryload 68 | 69 | if load is None: 70 | load = loader(relationship) # type: ignore 71 | else: 72 | load = getattr(load, loader.__name__)(relationship) 73 | 74 | return load 75 | 76 | 77 | @lru_cache 78 | def select_with_relationships( 79 | *_should_load: str, 80 | model: Type[ModelType], 81 | query: Optional[Select[Tuple[Type[ModelType]]]] = None, 82 | ) -> Select[Tuple[Type[ModelType]]]: 83 | if query is None: 84 | query = select(model) 85 | 86 | options = [] 87 | to_load = set(_should_load) 88 | while to_load: 89 | # we dont care if path is the same, alchemy will remove it by itself 90 | result = _bfs_search(model, to_load.pop()) 91 | if not result: 92 | continue 93 | construct = _construct_loads(result) 94 | if construct: 95 | options += [construct] 96 | 97 | if options: 98 | return query.options(*options) 99 | 100 | return query 101 | 102 | 103 | def on_integrity( 104 | *uniques: str, 105 | should_raise: Union[Type[AppException], AppException] = ConflictError, 106 | base_message: str = "already in use", 107 | ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: 108 | def _wrapper(coro: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: 109 | @wraps(coro) 110 | async def _inner_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 111 | try: 112 | return await coro(*args, **kwargs) 113 | except IntegrityError as e: 114 | origin = str(e.orig) 115 | for uniq in uniques: 116 | if uniq in origin: 117 | if callable(should_raise): 118 | message = f"{uniq} {base_message}" 119 | raise should_raise(message) from e 120 | else: 121 | raise should_raise from e 122 | raise AppException() from e 123 | 124 | return _inner_wrapper 125 | 126 | return _wrapper 127 | -------------------------------------------------------------------------------- /src/services/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from src.database.gateway import DBGateway 4 | from src.services.gateway import ServiceGateway 5 | 6 | ServiceGatewayFactory = Callable[[], ServiceGateway] 7 | 8 | 9 | def create_service_gateway_factory( 10 | database: Callable[[], DBGateway], 11 | ) -> ServiceGatewayFactory: 12 | def _create_instance() -> ServiceGateway: 13 | return ServiceGateway(database()) 14 | 15 | return _create_instance 16 | 17 | 18 | __all__ = ( 19 | "ServiceGateway", 20 | "service_gateway_factory", 21 | ) 22 | -------------------------------------------------------------------------------- /src/services/gateway.py: -------------------------------------------------------------------------------- 1 | from src.common.interfaces.gateway import BaseGateway 2 | from src.database.gateway import DBGateway 3 | from src.services.user import UserService 4 | 5 | 6 | class ServiceGateway(BaseGateway): 7 | __slots__ = ("database",) 8 | 9 | def __init__(self, database: DBGateway) -> None: 10 | self.database = database 11 | super().__init__(database) 12 | 13 | def user(self) -> UserService: 14 | return UserService(self.database.user()) 15 | -------------------------------------------------------------------------------- /src/services/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hpphpro/fastapi_api_example/0affb7140cfe2dcfee1ce3e5b70d0b3288a4eb75/src/services/security/__init__.py -------------------------------------------------------------------------------- /src/services/security/argon2.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict 2 | from typing import Any, Dict, Literal 3 | 4 | from argon2 import Parameters, PasswordHasher 5 | from argon2.exceptions import VerificationError, VerifyMismatchError 6 | from argon2.profiles import ( 7 | CHEAPEST, 8 | PRE_21_2, 9 | RFC_9106_HIGH_MEMORY, 10 | RFC_9106_LOW_MEMORY, 11 | ) 12 | 13 | from src.common.interfaces.hasher import AbstractHasher 14 | 15 | ProfileType = Literal[ 16 | "RFC_9106_LOW_MEMORY", "RFC_9106_HIGH_MEMORY", "CHEAPEST", "PRE_21_2", "DEFAULT" 17 | ] 18 | PROFILES: Dict[str, Parameters] = { 19 | "RFC_9106_LOW_MEMORY": RFC_9106_LOW_MEMORY, 20 | "RFC_9106_HIGH_MEMORY": RFC_9106_HIGH_MEMORY, 21 | "CHEAPEST": CHEAPEST, 22 | "PRE_21_2": PRE_21_2, 23 | } 24 | 25 | 26 | class Argon2(AbstractHasher): 27 | __slots__ = ("_hasher",) 28 | 29 | def __init__(self, hasher: PasswordHasher) -> None: 30 | self._hasher = hasher 31 | 32 | def hash_password(self, plain: str) -> str: 33 | return self._hasher.hash(plain) 34 | 35 | def verify_password(self, hashed: str, plain: str) -> bool: 36 | try: 37 | return self._hasher.verify(hashed, plain) 38 | except (VerificationError, VerifyMismatchError): 39 | return False 40 | 41 | 42 | def get_argon2_hasher(profile: ProfileType = "DEFAULT", **kwargs: Any) -> Argon2: 43 | if profile == "DEFAULT": # only need if something gonna change in argon2 module 44 | kw = {} 45 | else: 46 | kw = asdict(PROFILES[profile]) 47 | kw.pop("version", None) 48 | 49 | return Argon2(PasswordHasher(**(kw | kwargs))) 50 | -------------------------------------------------------------------------------- /src/services/security/jwt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from datetime import datetime, timedelta, timezone 3 | from typing import ( 4 | Any, 5 | Dict, 6 | Literal, 7 | Optional, 8 | Tuple, 9 | cast, 10 | ) 11 | 12 | import jwt 13 | 14 | from src.common.dto.token import Token 15 | from src.common.exceptions import ServiceNotImplementedError, UnAuthorizedError 16 | from src.core.settings import CipherSettings 17 | 18 | TokenType = Literal["access", "refresh"] 19 | 20 | 21 | class TokenJWT: 22 | __slots__ = ("_settings",) 23 | 24 | def __init__(self, settings: CipherSettings) -> None: 25 | self._settings = settings 26 | 27 | def create( 28 | self, 29 | typ: TokenType, 30 | sub: str, 31 | expires_delta: Optional[timedelta] = None, 32 | **kw: Any, 33 | ) -> Tuple[datetime, Token]: 34 | now = datetime.now(timezone.utc) 35 | if expires_delta: 36 | expire = now + expires_delta 37 | else: 38 | seconds_delta = ( 39 | self._settings.access_token_expire_seconds 40 | if typ == "access" 41 | else self._settings.refresh_token_expire_seconds 42 | ) 43 | expire = now + timedelta(seconds=seconds_delta) 44 | 45 | if now >= expire: 46 | raise ServiceNotImplementedError("Invalid expiration delta was provided") 47 | 48 | to_encode = { 49 | "exp": expire, 50 | "sub": sub, 51 | "iat": now, 52 | "type": typ, 53 | } 54 | try: 55 | token = jwt.encode( 56 | to_encode | kw, 57 | base64.b64decode(self._settings.secret_key), 58 | self._settings.algorithm, 59 | ) 60 | except jwt.PyJWTError as e: 61 | raise UnAuthorizedError("Token is expired") from e 62 | 63 | return expire, Token(token=token) 64 | 65 | def verify_token(self, token: str) -> Dict[str, Any]: 66 | try: 67 | result = jwt.decode( 68 | token, 69 | base64.b64decode(self._settings.public_key), 70 | [self._settings.algorithm], 71 | ) 72 | except jwt.PyJWTError as e: 73 | raise UnAuthorizedError("Token is invalid or expired") from e 74 | 75 | return cast(Dict[str, Any], result) 76 | -------------------------------------------------------------------------------- /src/services/user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, overload 3 | 4 | import src.common.dto as dto 5 | from src.common.exceptions import ConflictError, NotFoundError 6 | from src.common.interfaces.hasher import AbstractHasher 7 | from src.database.converter import from_model_to_dto 8 | from src.database.repositories import UserRepository 9 | from src.database.tools import on_integrity 10 | 11 | 12 | class UserService: 13 | __slots__ = ("_repository",) 14 | 15 | def __init__(self, repository: UserRepository) -> None: 16 | self._repository = repository 17 | 18 | @on_integrity("login") 19 | async def create(self, data: dto.CreateUser, hasher: AbstractHasher) -> dto.User: 20 | data.password = hasher.hash_password(data.password) 21 | result = await self._repository.create(**data.model_dump()) 22 | 23 | if not result: 24 | raise ConflictError("This user already exists") 25 | 26 | return from_model_to_dto(result, dto.User) 27 | 28 | @overload 29 | async def get_one(self, *, user_id: uuid.UUID) -> dto.User: ... 30 | @overload 31 | async def get_one(self, *, login: str) -> dto.User: ... 32 | async def get_one(self, **kw: Any) -> dto.User: 33 | result = await self._repository.get_one(**kw) 34 | 35 | if not result: 36 | raise NotFoundError("User not found") 37 | 38 | return from_model_to_dto(result, dto.User) 39 | --------------------------------------------------------------------------------