├── .gitignore ├── .gitpod.yml ├── Dockerfile.dev ├── MANIFEST.in ├── alembic.ini ├── docker-compose.yaml ├── docs ├── README.md ├── api_first.png ├── api_second.png ├── api_user_post.png ├── auth.png ├── auth2.png ├── database.png ├── fastapi_workshop_linuxtips.pdf ├── pamps_postgres_create.sql └── user_routes1.png ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── d989fa6bfe59_initial.py │ └── e71d88346f62_posts.py ├── pamps ├── __init__.py ├── app.py ├── auth.py ├── cli.py ├── config.py ├── db.py ├── default.toml ├── models │ ├── __init__.py │ ├── post.py │ └── user.py ├── routes │ ├── __init__.py │ ├── auth.py │ ├── post.py │ └── user.py └── security.py ├── postgres ├── Dockerfile └── create-databases.sh ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── settings.toml ├── setup.py ├── test.sh └── tests ├── __init__.py ├── conftest.py └── test_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | *.egg-info 3 | __pycache__ 4 | **/__pycache__ 5 | **/**/__pycache__ 6 | **/**/**/__pycache__ 7 | .secrets.toml 8 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: pip install -e . 7 | 8 | 9 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Build the app image 2 | FROM python:3.10 3 | 4 | # Create directory for the app user 5 | RUN mkdir -p /home/app 6 | 7 | # Create the app user 8 | RUN groupadd app && useradd -g app app 9 | 10 | # Create the home directory 11 | ENV APP_HOME=/home/app/api 12 | RUN mkdir -p $APP_HOME 13 | WORKDIR $APP_HOME 14 | 15 | # install 16 | COPY . $APP_HOME 17 | RUN pip install -r requirements-dev.txt 18 | RUN pip install -e . 19 | 20 | RUN chown -R app:app $APP_HOME 21 | USER app 22 | 23 | CMD ["uvicorn","pamps.app:app","--host=0.0.0.0","--port=8000","--reload"] 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft pamps 2 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to migrations/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | sqlalchemy.url = driver://user:pass@localhost/dbname 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | api: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | ports: 9 | - "8000:8000" 10 | environment: 11 | PAMPS_DB__uri: "postgresql://postgres:postgres@db:5432/${PAMPS_DB:-pamps}" 12 | PAMPS_DB__connect_args: "{}" 13 | volumes: 14 | - .:/home/app/api 15 | depends_on: 16 | - db 17 | stdin_open: true 18 | tty: true 19 | db: 20 | build: postgres 21 | image: pamps_postgres-13-alpine-multi-user 22 | volumes: 23 | - $HOME/.postgres/pamps_db/data/postgresql:/var/lib/postgresql/data 24 | ports: 25 | # ATENÇÃO: Mude para 5432: se precisar acessar via host 26 | - "5435:5432" 27 | environment: 28 | - POSTGRES_DBS=pamps, pamps_test 29 | - POSTGRES_USER=postgres 30 | - POSTGRES_PASSWORD=postgres 31 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Workshop 2 | 3 | Criando uma aplicação web com API usando FastAPI 4 | 5 | >   6 | > Quer ver a API funcionando antes de tentar construir do zero? 7 | > clica aqui 8 | > [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/rochacbruno/fastapi-workshop) 9 | > ```console 10 | > # No terminal do gitpod execute: 11 | > docker compose up 12 | > 13 | > # Abre outro terminal clicando no `+` e execute: 14 | > docker compose exec api alembic upgrade head 15 | > ``` 16 | > 17 | > Pronto a API estará sendo servida na porta 8000 do seu gitpod, exemplo: 18 | > https://8000-seuuser-fastapiwork-random.random.gitpod.io/docs 19 | > 20 | > ``` 21 | > # Experimente também no terminal do gitpod: 22 | > pip install -e . 23 | > docker compose exec api pamps --help 24 | > ``` 25 | >   26 | 27 | 28 | > WARNING!!! Se `docker compose` não funcionar experimente colocar um `-` como em `docker-compose` 29 | 30 | ## Requisitos 31 | 32 | - Computador com Python 3.10 33 | - Docker & docker compose 34 | - Ou https://gitpod.io para um ambiente online 35 | - Um editor de códigos como VSCode, Sublime, Vim, Micro 36 | (Nota: Eu usarei o Micro-Editor) 37 | 38 | > **importante**: Os comandos apresentados serão executados em um terminal Linux, se estiver no Windows recomendo usar o WSL, uma máquina virtual ou um container Linux, ou por conta própria adaptar os comandos necessários. 39 | 40 | ## Ambiente 41 | 42 | Primeiro precisamos de um ambiente virtual para instalar 43 | as dependencias do projeto. 44 | 45 | ```console 46 | python -m venv .venv 47 | ``` 48 | 49 | E ativaremos a virtualenv 50 | 51 | ```console 52 | # Linux 53 | source .venv/bin/activate 54 | # Windows Power Shell 55 | .\venv\Scripts\activate.ps1 56 | ``` 57 | 58 | Vamos instalar ferramentas de produtividade neste ambiente e para isso vamos criar um arquivo chamado 59 | requirements-dev.txt 60 | 61 | 62 | ``` 63 | ipython # terminal 64 | ipdb # debugger 65 | sdb # debugger remoto 66 | pip-tools # lock de dependencias 67 | pytest # execução de testes 68 | pytest-order # ordenação de testes 69 | httpx # requests async para testes 70 | black # auto formatação 71 | flake8 # linter 72 | ``` 73 | 74 | Instalamos as dependencias iniciais. 75 | 76 | ```console 77 | pip install --upgrade pip 78 | pip install -r requirements-dev.txt 79 | ``` 80 | 81 | ## O Projeto 82 | 83 | Nosso projeto será um microblog estilo twitter, é 84 | um projeto simples porém com funcionalidade suficientes 85 | para exercitar as principais features de uma API. 86 | 87 | Vamos focar no backend, ou seja, na API apenas, 88 | o nome do projeto é "PAMPS" um nome aleatório que 89 | encontrei para uma rede social ficticia. 90 | 91 | ## Funcionalidades 92 | 93 | ### Usuários 94 | 95 | - Registro de novos usuários 96 | - Autenticação de usuários 97 | - Seguir outros usuários 98 | - Perfil com bio e listagem de posts, seguidores e seguidos 99 | 100 | ### Postagens 101 | 102 | - Criação de novo post 103 | - Edição de post 104 | - Remoção de post 105 | - Listagem de posts geral (home) 106 | - Listagem de posts seguidos (timeline) 107 | - Likes em postagens 108 | - Postagem pode ser resposta a outra postagem 109 | 110 | 111 | ## Estrutura de pastas e arquivos 112 | 113 | Script para criar os arquivos do projeto. 114 | 115 | ```bash 116 | # Arquivos na raiz 117 | touch setup.py 118 | touch {settings,.secrets}.toml 119 | touch {requirements,MANIFEST}.in 120 | touch Dockerfile.dev docker-compose.yaml 121 | 122 | # Imagem do banco de dados 123 | mkdir postgres 124 | touch postgres/{Dockerfile,create-databases.sh} 125 | 126 | # Aplicação 127 | mkdir -p pamps/{models,routes} 128 | touch pamps/default.toml 129 | touch pamps/{__init__,cli,app,auth,db,security,config}.py 130 | touch pamps/models/{__init__,post,user}.py 131 | touch pamps/routes/{__init__,auth,post,user}.py 132 | 133 | # Testes 134 | touch test.sh 135 | mkdir tests 136 | touch tests/{__init__,conftest,test_api}.py 137 | ``` 138 | 139 | Esta será a estrutura final (se preferir criar manualmente) 140 | 141 | ``` 142 | ❯ tree --filesfirst -L 3 -I docs 143 | . 144 | ├── docker-compose.yaml # Orquestração de containers 145 | ├── Dockerfile.dev # Imagem principal 146 | ├── MANIFEST.in # Arquivos incluidos na aplicação 147 | ├── requirements-dev.txt # Dependencias de ambiente dev 148 | ├── requirements.in # Dependencias de produção 149 | ├── .secrets.toml # Senhas locais 150 | ├── settings.toml # Configurações locais 151 | ├── setup.py # Instalação do projeto 152 | ├── test.sh # Pipeline de CI em ambiente dev 153 | ├── pamps 154 | │   ├── __init__.py 155 | │   ├── app.py # FastAPI app 156 | │   ├── auth.py # Autenticação via token 157 | │   ├── cli.py # Aplicação CLI `$ pamps adduser` etc 158 | │   ├── config.py # Inicialização da config 159 | │   ├── db.py # Conexão com o banco de dados 160 | │ ├── default.toml # Config default 161 | │   ├── security.py # Password Validation 162 | │   ├── models 163 | │   │   ├── __init__.py 164 | │   │   ├── post.py # ORM e Serializers de posts 165 | │   │   └── user.py # ORM e Serialziers de users 166 | │   └── routes 167 | │   ├── __init__.py 168 | │   ├── auth.py # Rotas de autenticação via JWT 169 | │   ├── post.py # CRUD de posts e likes 170 | │   └── user.py # CRUD de user e follows 171 | ├── postgres 172 | │   ├── create-databases.sh # Script de criação do DB 173 | │   └── Dockerfile # Imagem do SGBD 174 | └── tests 175 | ├── conftest.py # Config do Pytest 176 | ├── __init__.py 177 | └── test_api.py # Tests da API 178 | ``` 179 | 180 | ## Adicionando as dependencias 181 | 182 | Editaremos o arquivo `requirements.in` e adicionaremos 183 | 184 | ```plain 185 | fastapi 186 | uvicorn 187 | sqlmodel 188 | typer 189 | dynaconf 190 | jinja2 191 | python-jose[cryptography] 192 | passlib[bcrypt] 193 | python-multipart 194 | psycopg2-binary 195 | alembic 196 | rich 197 | ``` 198 | 199 | A partir deste arquivo vamos gerar um `requirements.txt` com os locks das 200 | versões. 201 | 202 | ```bash 203 | pip-compile requirements.in 204 | ``` 205 | 206 | E este comando irá gerar o arquivo `requirements.txt` organizado e com as versões 207 | pinadas. 208 | 209 | ## Criando a API base 210 | 211 | Vamos editar o arquico `pamps/app.py` 212 | 213 | ```python 214 | from fastapi import FastAPI 215 | 216 | app = FastAPI( 217 | title="Pamps", 218 | version="0.1.0", 219 | description="Pamps is a posting app", 220 | ) 221 | 222 | ``` 223 | 224 | ## Tornando a aplicação instalável 225 | 226 | `MANIFEST.in` 227 | ``` 228 | graft pamps 229 | ``` 230 | 231 | `setup.py` 232 | ```python 233 | import io 234 | import os 235 | from setuptools import find_packages, setup 236 | 237 | 238 | def read(*paths, **kwargs): 239 | content = "" 240 | with io.open( 241 | os.path.join(os.path.dirname(__file__), *paths), 242 | encoding=kwargs.get("encoding", "utf8"), 243 | ) as open_file: 244 | content = open_file.read().strip() 245 | return content 246 | 247 | 248 | def read_requirements(path): 249 | return [ 250 | line.strip() 251 | for line in read(path).split("\n") 252 | if not line.startswith(('"', "#", "-", "git+")) 253 | ] 254 | 255 | 256 | setup( 257 | name="pamps", 258 | version="0.1.0", 259 | description="Pamps is a social posting app", 260 | url="pamps.io", 261 | python_requires=">=3.8", 262 | long_description="Pamps is a social posting app", 263 | long_description_content_type="text/markdown", 264 | author="Melon Husky", 265 | packages=find_packages(exclude=["tests"]), 266 | include_package_data=True, 267 | install_requires=read_requirements("requirements.txt"), 268 | entry_points={ 269 | "console_scripts": ["pamps = pamps.cli:main"] 270 | } 271 | ) 272 | ``` 273 | 274 | ## Instalação 275 | 276 | O nosso objetivo é instalar a aplicação dentro do container, porém 277 | é recomendável que instale também no ambiente local pois 278 | desta maneira auto complete do editor irá funcionar. 279 | 280 | ```bash 281 | pip install -e . 282 | ``` 283 | 284 | ## Containers 285 | 286 | Vamos agora escrever o Dockerfile.dev responsável por executar nossa api 287 | 288 | `Dockerfile.dev` 289 | 290 | ```docker 291 | # Build the app image 292 | FROM python:3.10 293 | 294 | # Create directory for the app user 295 | RUN mkdir -p /home/app 296 | 297 | # Create the app user 298 | RUN groupadd app && useradd -g app app 299 | 300 | # Create the home directory 301 | ENV APP_HOME=/home/app/api 302 | RUN mkdir -p $APP_HOME 303 | WORKDIR $APP_HOME 304 | 305 | # install 306 | COPY . $APP_HOME 307 | RUN pip install -r requirements-dev.txt 308 | RUN pip install -e . 309 | 310 | RUN chown -R app:app $APP_HOME 311 | USER app 312 | 313 | CMD ["uvicorn","pamps.app:app","--host=0.0.0.0","--port=8000","--reload"] 314 | 315 | ``` 316 | 317 | Build the container 318 | 319 | ```bash 320 | docker build -f Dockerfile.dev -t pamps:latest . 321 | ``` 322 | 323 | Execute o container para testar 324 | 325 | ```console 326 | $ docker run --rm -it -v $(pwd):/home/app/api -p 8000:8000 pamps 327 | INFO: Will watch for changes in these directories: ['/home/app/api'] 328 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) 329 | INFO: Started reloader process [1] using StatReload 330 | INFO: Started server process [8] 331 | INFO: Waiting for application startup. 332 | INFO: Application startup complete. 333 | ``` 334 | 335 | Acesse: http://0.0.0.0:8000/docs 336 | 337 | ![API](api_first.png) 338 | 339 | A API vai ser atualizada automaticamente quando detectar mudanças no código, 340 | somente para teste edite `pamps/app.py` e adicione 341 | 342 | ```python 343 | @app.get("/") 344 | async def index(): 345 | return {"hello": "world"} 346 | ``` 347 | 348 | Agora acesse novamente http://0.0.0.0:8000/docs 349 | 350 | ![API](api_second.png) 351 | 352 | > **NOTA**: pode remover a rota `index()` pois foi apenas para testar, vamos 353 | > agora adicionar rotas de maneira mais organizada. 354 | 355 | 356 | ## Rodando um banco de dados em container 357 | 358 | Agora precisaremos de um banco de dados e vamos usar o PostgreSQL dentro de 359 | um container. 360 | 361 | Edite `postgres/create-databases.sh` 362 | ```bash 363 | #!/bin/bash 364 | 365 | set -e 366 | set -u 367 | 368 | function create_user_and_database() { 369 | local database=$1 370 | echo "Creating user and database '$database'" 371 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 372 | CREATE USER $database PASSWORD '$database'; 373 | CREATE DATABASE $database; 374 | GRANT ALL PRIVILEGES ON DATABASE $database TO $database; 375 | EOSQL 376 | } 377 | 378 | if [ -n "$POSTGRES_DBS" ]; then 379 | echo "Creating DB(s): $POSTGRES_DBS" 380 | for db in $(echo $POSTGRES_DBS | tr ',' ' '); do 381 | create_user_and_database $db 382 | done 383 | echo "Multiple databases created" 384 | fi 385 | ``` 386 | 387 | O script acima vai ser executado no inicio da execução do Postgres de forma 388 | que quando a aplicação iniciar teremos certeza de que o banco de dados está criado. 389 | 390 | Agora precisamos do Sistema de BD rodando e vamos criar uma imagem com Postgres. 391 | 392 | Edite `postres/Dockerfile` 393 | ```docker 394 | FROM postgres:alpine3.14 395 | COPY create-databases.sh /docker-entrypoint-initdb.d/ 396 | ``` 397 | 398 | ## Docker compose 399 | 400 | Agora para iniciar a nossa API + o Banco de dados vamos precisar de um 401 | orquestrador de containers, em produção isso será feito com Kubernetes 402 | mas no ambiente de desenvolvimento podemos usar o docker compose. 403 | 404 | Edite o arquivo `docker-compose.yaml` 405 | 406 | - Definimos 2 serviços `api` e `db` 407 | - Informamos os parametros de build com os dockerfiles 408 | - Na `api` abrimos a porta `8000` 409 | - Na `api` passamos 2 variáveis de ambiente `PAMPS_DB__uri` e `PAMPS_DB_connect_args` para usarmos na conexão com o DB 410 | - Marcamos que a `api` depende do `db` para iniciar. 411 | - No `db` informamos o setup básico do postgres e pedimos para criar 2 bancos de dados, um para a app e um para testes. 412 | 413 | ```yaml 414 | version: '3.9' 415 | 416 | services: 417 | api: 418 | build: 419 | context: . 420 | dockerfile: Dockerfile.dev 421 | ports: 422 | - "8000:8000" 423 | environment: 424 | PAMPS_DB__uri: "postgresql://postgres:postgres@db:5432/${PAMPS_DB:-pamps}" 425 | PAMPS_DB__connect_args: "{}" 426 | volumes: 427 | - .:/home/app/api 428 | depends_on: 429 | - db 430 | stdin_open: true 431 | tty: true 432 | db: 433 | build: postgres 434 | image: pamps_postgres-13-alpine-multi-user 435 | volumes: 436 | - $HOME/.postgres/pamps_db/data/postgresql:/var/lib/postgresql/data 437 | ports: 438 | - "5432:5432" 439 | environment: 440 | - POSTGRES_DBS=pamps, pamps_test 441 | - POSTGRES_USER=postgres 442 | - POSTGRES_PASSWORD=postgres 443 | ``` 444 | 445 | O próximo passo é executar com 446 | 447 | ```bash 448 | docker compose up 449 | ``` 450 | 451 | ## Definindo os models com Pydantic 452 | 453 | ![database](database.png) 454 | 455 | https://dbdesigner.page.link/qQHdqeYRTqKUfmrt7 456 | 457 | Vamos modelar o banco de dados definido acima usando o SQLModel, que é 458 | uma biblioteca que integra o SQLAlchemy e o Pydantic e funciona muito bem 459 | com o FastAPI. 460 | 461 | Vamos começar a estruturar os model principal para armazenar os usuários 462 | 463 | edite o arquivo `pamps/models/user.py` 464 | ```python 465 | """User related data models""" 466 | from typing import Optional 467 | from sqlmodel import Field, SQLModel 468 | 469 | 470 | class User(SQLModel, table=True): 471 | """Represents the User Model""" 472 | 473 | id: Optional[int] = Field(default=None, primary_key=True) 474 | email: str = Field(unique=True, nullable=False) 475 | username: str = Field(unique=True, nullable=False) 476 | avatar: Optional[str] = None 477 | bio: Optional[str] = None 478 | password: str = Field(nullable=False) 479 | ``` 480 | 481 | No arquivo `pamps/models/__init__.py` adicione 482 | 483 | ```python 484 | from sqlmodel import SQLModel 485 | from .user import User 486 | 487 | __all__ = ["User", "SQLModel"] 488 | ``` 489 | 490 | ## Settings 491 | 492 | Agora que temos pelo menos uma tabela mapeada para uma classe precisamos 493 | estabelecer conexão com o banco de dados e para isso precisamos carregar 494 | configurações 495 | 496 | Edite o arquivo `pamps/default.toml` 497 | ```toml 498 | [default] 499 | 500 | [default.db] 501 | uri = "" 502 | connect_args = {check_same_thread=false} 503 | echo = false 504 | ``` 505 | 506 | Lembra que no `docker-compose.yaml` passamos as variáveis `PAMPS_DB...` 507 | aquelas variáveis vão sobrescrever os valores definidos no default 508 | settings. 509 | 510 | Vamos agora inicializar a biblioteca de configurações: 511 | 512 | Edite `pamps/config.py` 513 | ```python 514 | """Settings module""" 515 | import os 516 | 517 | from dynaconf import Dynaconf 518 | 519 | HERE = os.path.dirname(os.path.abspath(__file__)) 520 | 521 | settings = Dynaconf( 522 | envvar_prefix="pamps", 523 | preload=[os.path.join(HERE, "default.toml")], 524 | settings_files=["settings.toml", ".secrets.toml"], 525 | environments=["development", "production", "testing"], 526 | env_switcher="pamps_env", 527 | load_dotenv=False, 528 | ) 529 | ``` 530 | 531 | No arquivo acima estamos definindo que o objeto `settings` irá 532 | carregar variáveis do arquivo `default.toml` e em seguida dos arquivos 533 | `settings.toml` e `.secrets.toml` e que será possivel usar `PAMPS_` como 534 | prefixo nas variáveis de ambiente para sobrescrever os valores. 535 | 536 | 537 | ## Conexão com o banco de dados 538 | 539 | Edite `pamps/db.py` 540 | 541 | ```python 542 | """Database connection""" 543 | from sqlmodel import create_engine 544 | from .config import settings 545 | 546 | engine = create_engine( 547 | settings.db.uri, 548 | echo=settings.db.echo, 549 | connect_args=settings.db.connect_args, 550 | ) 551 | ``` 552 | 553 | Criamos um objeto `engine` que aponta para uma conexão com o banco de 554 | dados e para isso usamos as variáveis que lemos do `settings`. 555 | 556 | ## Database Migrations 557 | 558 | Portanto agora já temos uma tabela mapeada e um conexão com o banco de dados 559 | precisamos agora garantir que a estrutura da tabela existe dentro do banco 560 | de dados. 561 | 562 | Para isso vamos usar a biblioteca `alembic` que gerencia migrações, ou seja, 563 | alterações na estrutura das tabelas. 564 | 565 | Começamos na raiz do repositório e rodando: 566 | 567 | ```bash 568 | alembic init migrations 569 | ``` 570 | 571 | O alembic irá criar um arquivo chamado `alembic.ini` e uma pasta chamada `migrations` que servirá para armazenar o histórico de alterações do banco de dados. 572 | 573 | Começaremos editando o arquivo `migrations/env.py` 574 | 575 | ```py 576 | # No topo do arquivo adicionamos 577 | from pamps import models 578 | from pamps.db import engine 579 | from pamps.config import settings 580 | 581 | 582 | # Perto da linha 23 mudamos de 583 | # target_metadata = None 584 | # para 585 | target_metadata = models.SQLModel.metadata 586 | 587 | # Na função `run_migrations_offline()` mudamos 588 | # url = config.get_main_option("sqlalchemy.url") 589 | # para 590 | url = settings.db.uri 591 | 592 | # Na função `run_migration_online` mudamos 593 | # connectable = engine_from_config... 594 | #para 595 | connectable = engine 596 | ``` 597 | 598 | Agora precisamos fazer só mais um ajuste 599 | edite `migrations/script.py.mako` e em torno da linha 10 600 | adicione 601 | 602 | ```mako 603 | #from alembic import op 604 | #import sqlalchemy as sa 605 | import sqlmodel # linha NOVA 606 | ``` 607 | 608 | Agora sim podemos começar a usar o **alembic** para gerenciar as 609 | migrations, precisamos executar este comando dentro do container 610 | portando execute 611 | 612 | ```console 613 | $ docker compose exec api /bin/bash 614 | app@c5dd026e8f92:~/api$ # este é o shell dentro do container 615 | ``` 616 | 617 | > IMPORTANTE!!!: todos os comandos serão executados no shell dentro do container!!! 618 | 619 | E dentro do prompt do container rode: 620 | 621 | ```console 622 | $ alembic revision --autogenerate -m "initial" 623 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 624 | INFO [alembic.runtime.migration] Will assume transactional DDL. 625 | INFO [alembic.autogenerate.compare] Detected added table 'user' 626 | Generating /home/app/api/migrations/versions/ee59b23815d3_initial.py ... done 627 | ``` 628 | 629 | Repare que o alembic identificou o nosso model `User` e gerou uma migration 630 | inicial que fará a criação desta tabela no banco de dados. 631 | 632 | Podemos aplicar a migration rodando dentro do container: 633 | 634 | ```console 635 | $ alembic upgrade head 636 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 637 | INFO [alembic.runtime.migration] Will assume transactional DDL. 638 | INFO [alembic.runtime.migration] Running upgrade -> ee59b23815d3, initial 639 | ``` 640 | 641 | E neste momento a tabela será criada no Postgres, podemos verificar se 642 | está funcionando ainda dentro do container: 643 | 644 | > **DICA** pode usar um client como https://antares-sql.app para se conectar 645 | > ao banco de dados. 646 | 647 | ```console 648 | $ ipython 649 | >>> 650 | ``` 651 | 652 | Digite 653 | 654 | ```python 655 | from sqlmodel import Session, select 656 | from pamps.db import engine 657 | from pamps.models import User 658 | 659 | with Session(engine) as session: 660 | print(list(session.exec(select(User)))) 661 | ``` 662 | 663 | O resultado será uma lista vazia `[]` indicando que ainda não temos nenhum 664 | usuário no banco de dados. 665 | 666 | Foi preciso muito **boilerplate** para conseguir se conectar ao banco de dados 667 | para facilitar a nossa vida vamos adicionar uma aplicação `cli` onde vamos poder 668 | executar tarefas administrativas no shell. 669 | 670 | ## Criando a CLI base 671 | 672 | Edite `pamps/cli.py` 673 | 674 | ```python 675 | import typer 676 | from rich.console import Console 677 | from rich.table import Table 678 | from sqlmodel import Session, select 679 | 680 | from .config import settings 681 | from .db import engine 682 | from .models import User 683 | 684 | main = typer.Typer(name="Pamps CLI") 685 | 686 | 687 | @main.command() 688 | def shell(): 689 | """Opens interactive shell""" 690 | _vars = { 691 | "settings": settings, 692 | "engine": engine, 693 | "select": select, 694 | "session": Session(engine), 695 | "User": User, 696 | } 697 | typer.echo(f"Auto imports: {list(_vars.keys())}") 698 | try: 699 | from IPython import start_ipython 700 | 701 | start_ipython( 702 | argv=["--ipython-dir=/tmp", "--no-banner"], user_ns=_vars 703 | ) 704 | except ImportError: 705 | import code 706 | 707 | code.InteractiveConsole(_vars).interact() 708 | 709 | 710 | @main.command() 711 | def user_list(): 712 | """Lists all users""" 713 | table = Table(title="Pamps users") 714 | fields = ["username", "email"] 715 | for header in fields: 716 | table.add_column(header, style="magenta") 717 | 718 | with Session(engine) as session: 719 | users = session.exec(select(User)) 720 | for user in users: 721 | table.add_row(user.username, user.email) 722 | 723 | Console().print(table) 724 | ``` 725 | 726 | E agora no shell do container podemos executar 727 | 728 | ```console 729 | $ pamps --help 730 | 731 | Usage: pamps [OPTIONS] COMMAND [ARGS]... 732 | 733 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮ 734 | │ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the │ 735 | │ specified shell. │ 736 | │ [default: None] │ 737 | │ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the │ 738 | │ specified shell, to copy it or │ 739 | │ customize the installation. │ 740 | │ [default: None] │ 741 | │ --help Show this message and exit. │ 742 | ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ 743 | ╭─ Commands ───────────────────────────────────────────────────────────────────────────────────╮ 744 | │ shell Opens interactive shell │ 745 | │ user-list Lists all users │ 746 | ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ 747 | ``` 748 | 749 | E cada um dos comandos: 750 | 751 | ```console 752 | $ pamps user-list 753 | Pamps users 754 | ┏━━━━━━━━━━┳━━━━━━━┓ 755 | ┃ username ┃ email ┃ 756 | ┡━━━━━━━━━━╇━━━━━━━┩ 757 | └──────────┴───────┘ 758 | 759 | ``` 760 | 761 | e 762 | 763 | ```console 764 | $ pamps shell 765 | Auto imports: ['settings', 'engine', 'select', 'session', 'User'] 766 | 767 | In [1]: session.exec(select(User)) 768 | Out[1]: 769 | 770 | In [2]: settings.db 771 | Out[2]: 772 | ``` 773 | 774 | Ainda não temos usuários cadastrados pois ainda está faltando uma parte importante 775 | **criptografar as senhas** para os usuários. 776 | 777 | ## Hash passwords 778 | 779 | 780 | Precisamos ser capazes de encryptar as senhas dos usuários e para isso tem alguns 781 | requisitos, primeiro precisamos de uma chave em nosso arquivo de settings: 782 | 783 | Edite `pamps/default.toml` e adicione ao final 784 | 785 | ```toml 786 | [default.security] 787 | # Set secret key in .secrets.toml 788 | # SECRET_KEY = "" 789 | ALGORITHM = "HS256" 790 | ACCESS_TOKEN_EXPIRE_MINUTES = 30 791 | REFRESH_TOKEN_EXPIRE_MINUTES = 600 792 | ``` 793 | 794 | Como o próprio comentário acima indica, vamos colocar uma secret key no 795 | arquivo `.secrets.toml` na raiz do repositório. 796 | 797 | ```toml 798 | [development] 799 | dynaconf_merge = true 800 | 801 | [development.security] 802 | # openssl rand -hex 32 803 | SECRET_KEY = "ONLYFORDEVELOPMENT" 804 | ``` 805 | 806 | > NOTA: repare que estamos agora usando a seção `environment` e isso tem a ver 807 | > com o modo como o dynaconf gerencia os settings, esses valores serão 808 | > carregados apenas durante a execução em desenvolvimento. 809 | 810 | Você pode gerar uma secret key mais segura se quiser usando 811 | ```console 812 | $ python -c "print(__import__('secrets').token_hex(32))" 813 | b9483cc8a0bad1c2fe31e6d9d6a36c4a96ac23859a264b69a0badb4b32c538f8 814 | 815 | # OU 816 | 817 | $ openssl rand -hex 32 818 | b9483cc8a0bad1c2fe31e6d9d6a36c4a96ac23859a264b69a0badb4b32c538f8 819 | ``` 820 | 821 | Agora vamos editar `pamps/security.py` e adicionar alguns elementos 822 | 823 | ```python 824 | """Security utilities""" 825 | from passlib.context import CryptContext 826 | 827 | from pamps.config import settings 828 | 829 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 830 | 831 | 832 | SECRET_KEY = settings.security.secret_key 833 | ALGORITHM = settings.security.algorithm 834 | 835 | 836 | def verify_password(plain_password, hashed_password) -> bool: 837 | """Verifies a hash against a password""" 838 | return pwd_context.verify(plain_password, hashed_password) 839 | 840 | 841 | def get_password_hash(password) -> str: 842 | """Generates a hash from plain text""" 843 | return pwd_context.hash(password) 844 | 845 | 846 | class HashedPassword(str): 847 | """Takes a plain text password and hashes it. 848 | use this as a field in your SQLModel 849 | class User(SQLModel, table=True): 850 | username: str 851 | password: HashedPassword 852 | """ 853 | 854 | @classmethod 855 | def __get_validators__(cls): 856 | # one or more validators may be yielded which will be called in the 857 | # order to validate the input, each validator will receive as an input 858 | # the value returned from the previous validator 859 | yield cls.validate 860 | 861 | @classmethod 862 | def validate(cls, v): 863 | """Accepts a plain text password and returns a hashed password.""" 864 | if not isinstance(v, str): 865 | raise TypeError("string required") 866 | 867 | hashed_password = get_password_hash(v) 868 | # you could also return a string here which would mean model.password 869 | # would be a string, pydantic won't care but you could end up with some 870 | # confusion since the value's type won't match the type annotation 871 | # exactly 872 | return cls(hashed_password) 873 | 874 | ``` 875 | 876 | E agora editaremos o arquivo `pamps/models/user.py` 877 | 878 | No topo na linha 7 879 | 880 | ```python 881 | from pamps.security import HashedPassword 882 | ``` 883 | 884 | E no model mudamos o campo `password` na linha 18 para 885 | 886 | ```python 887 | password: HashedPassword 888 | ``` 889 | 890 | ## Adicionando usuários pelo cli 891 | 892 | Agora sim podemos criar usuários via CLI, edite `pamps/cli.py` 893 | 894 | No final adicione 895 | ```python 896 | @main.command() 897 | def create_user(email: str, username: str, password: str): 898 | """Create user""" 899 | with Session(engine) as session: 900 | user = User(email=email, username=username, password=password) 901 | session.add(user) 902 | session.commit() 903 | session.refresh(user) 904 | typer.echo(f"created {username} user") 905 | return user 906 | 907 | ``` 908 | 909 | E no terminal do container execute 910 | 911 | ```console 912 | $ pamps create-user --help 913 | 914 | Usage: pamps create-user [OPTIONS] EMAIL USERNAME PASSWORD 915 | 916 | Create user 917 | 918 | ╭─ Arguments ────────────────────────────────────────────────────╮ 919 | │ * email TEXT [default: None] [required] │ 920 | │ * username TEXT [default: None] [required] │ 921 | │ * password TEXT [default: None] [required] │ 922 | ╰────────────────────────────────────────────────────────────────╯ 923 | ``` 924 | 925 | E então 926 | 927 | ```console 928 | $ pamps create-user admin@admin.com admin 1234 929 | created admin user 930 | 931 | $ pamps user-list 932 | Pamps users 933 | ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ 934 | ┃ username ┃ email ┃ 935 | ┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ 936 | │ admin │ admin@admin.com │ 937 | └──────────┴─────────────────┘ 938 | ``` 939 | 940 | Agora vamos para a API 941 | 942 | ## Adicionando rotas de usuários 943 | 944 | 945 | Agora vamos criar endpoints na API para efetuar as operações que fizemos 946 | através da CLI, teremos as seguintes rotas: 947 | 948 | - `GET /user/` - Lista todos os usuários 949 | - `POST /user/` - Cadastro de novo usuário 950 | - `GET /user/{username}/` - Detalhe de um usuário 951 | 952 | > **TODO:** A exclusão de usuários por enquanto não será permitida 953 | > mas no futuro você pode implementar um comando no CLI para fazer isso 954 | > e também um endpoint privado para um admin fazer isso. 955 | 956 | ### Serializers 957 | 958 | A primeira coisa que precisamos é definir serializers, que são models 959 | intermediários usados para serializar e de-serializar dados de entrada e saída 960 | da API e eles são necessários pois não queremos export o model do 961 | banco de dados diretamente na API. 962 | 963 | Em `pamps/models/user.py` 964 | 965 | No topo na linha 4 966 | 967 | ```python 968 | from pydantic import BaseModel 969 | ``` 970 | 971 | No final após a linha 20 972 | 973 | ```python 974 | class UserResponse(BaseModel): 975 | """Serializer for User Response""" 976 | 977 | username: str 978 | avatar: Optional[str] = None 979 | bio: Optional[str] = None 980 | 981 | 982 | class UserRequest(BaseModel): 983 | """Serializer for User request payload""" 984 | 985 | email: str 986 | username: str 987 | password: str 988 | avatar: Optional[str] = None 989 | bio: Optional[str] = None 990 | ``` 991 | 992 | 993 | E agora criaremos as URLS para expor esses serializers com os usuários 994 | edite `pamps/routes/user.py` 995 | 996 | ```python 997 | from typing import List 998 | 999 | from fastapi import APIRouter 1000 | from fastapi.exceptions import HTTPException 1001 | from sqlmodel import Session, select 1002 | 1003 | from pamps.db import ActiveSession 1004 | from pamps.models.user import User, UserRequest, UserResponse 1005 | 1006 | router = APIRouter() 1007 | 1008 | 1009 | @router.get("/", response_model=List[UserResponse]) 1010 | async def list_users(*, session: Session = ActiveSession): 1011 | """List all users.""" 1012 | users = session.exec(select(User)).all() 1013 | return users 1014 | 1015 | 1016 | @router.get("/{username}/", response_model=UserResponse) 1017 | async def get_user_by_username( 1018 | *, session: Session = ActiveSession, username: str 1019 | ): 1020 | """Get user by username""" 1021 | query = select(User).where(User.username == username) 1022 | user = session.exec(query).first() 1023 | if not user: 1024 | raise HTTPException(status_code=404, detail="User not found") 1025 | return user 1026 | 1027 | 1028 | @router.post("/", response_model=UserResponse, status_code=201) 1029 | async def create_user(*, session: Session = ActiveSession, user: UserRequest): 1030 | """Creates new user""" 1031 | db_user = User.from_orm(user) # transform UserRequest in User 1032 | session.add(db_user) 1033 | session.commit() 1034 | session.refresh(db_user) 1035 | return db_user 1036 | ``` 1037 | 1038 | Agora repare que estamos importando `ActiveSession` mas este objeto não existe 1039 | em `pamps/db.py` então vamos criar 1040 | 1041 | 1042 | No topo de `pamps/db.py` nas linhas 2 e 3 1043 | ```python 1044 | from fastapi import Depends 1045 | from sqlmodel import Session, create_engine 1046 | ``` 1047 | 1048 | No final de `pamps/db.py` após a linha 13 1049 | 1050 | ```python 1051 | def get_session(): 1052 | with Session(engine) as session: 1053 | yield session 1054 | 1055 | 1056 | ActiveSession = Depends(get_session) 1057 | ``` 1058 | 1059 | O objeto que `ActiveSession` é uma dependência para rotas do FastAPI 1060 | quando usarmos este objeto como parâmetro de uma view o FastAPI 1061 | vai executar de forma **lazy** este objeto e passar o retorno da função 1062 | atrelada a ele como argumento da nossa view. 1063 | 1064 | Neste caso teremos sempre uma conexão com o banco de dados dentro de cada 1065 | view que marcarmos com `session: Session = ActiveSession`. 1066 | 1067 | Agora podemos mapear as rotas na aplicação principal primeiro criamos um 1068 | router principal que serve para agregar todas as rotas: 1069 | 1070 | em `pamps/router/__init__.py` 1071 | 1072 | ```python 1073 | from fastapi import APIRouter 1074 | 1075 | from .user import router as user_router 1076 | 1077 | main_router = APIRouter() 1078 | 1079 | main_router.include_router(user_router, prefix="/user", tags=["user"]) 1080 | ``` 1081 | 1082 | E agora em `pamps/app.py` 1083 | 1084 | NO topo na linha 4 1085 | ```python 1086 | from .routes import main_router 1087 | ``` 1088 | 1089 | Logo depois de `app = FastAPI(...` após a linha 11 1090 | ```python 1091 | app.include_router(main_router) 1092 | ``` 1093 | 1094 | E agora sim pode acessar a API e verá as novas rotas prontas para serem usadas, 1095 | http://0.0.0.0:8000/docs/ 1096 | 1097 | ![user routes](user_routes1.png) 1098 | 1099 | Pode tentar pela web interface ou via curl 1100 | 1101 | Criar um usuário 1102 | 1103 | ```bash 1104 | curl -X 'POST' \ 1105 | 'http://0.0.0.0:8000/user/' \ 1106 | -H 'accept: application/json' \ 1107 | -H 'Content-Type: application/json' \ 1108 | -d '{ 1109 | "email": "rochacbruno@gmail.com", 1110 | "username": "rochacbruno", 1111 | "password": "lalala", 1112 | "avatar": "https://github.com/rochacbruno.png", 1113 | "bio": "Programador" 1114 | }' 1115 | ``` 1116 | 1117 | Pegar um usuário pelo ID 1118 | ```bash 1119 | curl -X 'GET' \ 1120 | 'http://0.0.0.0:8000/user/rochacbruno/' \ 1121 | -H 'accept: application/json' 1122 | ``` 1123 | ```json 1124 | { 1125 | "username": "rochacbruno", 1126 | "avatar": "https://github.com/rochacbruno.png", 1127 | "bio": "Programador" 1128 | } 1129 | ``` 1130 | 1131 | Listar todos 1132 | 1133 | ```bash 1134 | curl -X 'GET' \ 1135 | 'http://0.0.0.0:8000/user/' \ 1136 | -H 'accept: application/json' 1137 | ``` 1138 | ```json 1139 | [ 1140 | { 1141 | "username": "admin", 1142 | "avatar": null, 1143 | "bio": null 1144 | }, 1145 | { 1146 | "username": "rochacbruno", 1147 | "avatar": "https://github.com/rochacbruno.png", 1148 | "bio": "Programador" 1149 | } 1150 | ] 1151 | ``` 1152 | 1153 | ## Autenticação 1154 | 1155 | Agora que já podemos criar usuários é importante conseguirmos autenticar 1156 | os usuários pois desta forma podemos começar a criar postagens via API 1157 | 1158 | Esse será arquivo com a maior quantidade de código **boilerplate**. 1159 | 1160 | No arquivo `pamps/auth.py` vamos criar as classes e funções necessárias 1161 | para a implementação de JWT que é a autenticação baseada em token e vamos 1162 | usar o algoritmo selecionado no arquivo de configuração. 1163 | 1164 | `pamps/auth.py` 1165 | ```python 1166 | """Token absed auth""" 1167 | from datetime import datetime, timedelta 1168 | from typing import Callable, Optional, Union 1169 | 1170 | from fastapi import Depends, HTTPException, Request, status 1171 | from fastapi.security import OAuth2PasswordBearer 1172 | from jose import JWTError, jwt 1173 | from pydantic import BaseModel 1174 | from sqlmodel import Session, select 1175 | 1176 | from pamps.config import settings 1177 | from pamps.db import engine 1178 | from pamps.models.user import User 1179 | from pamps.security import verify_password 1180 | 1181 | SECRET_KEY = settings.security.secret_key 1182 | ALGORITHM = settings.security.algorithm 1183 | 1184 | 1185 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 1186 | 1187 | 1188 | class Token(BaseModel): 1189 | access_token: str 1190 | refresh_token: str 1191 | token_type: str 1192 | 1193 | 1194 | class RefreshToken(BaseModel): 1195 | refresh_token: str 1196 | 1197 | 1198 | class TokenData(BaseModel): 1199 | username: Optional[str] = None 1200 | 1201 | 1202 | def create_access_token( 1203 | data: dict, expires_delta: Optional[timedelta] = None 1204 | ) -> str: 1205 | """Creates a JWT Token from user data""" 1206 | to_encode = data.copy() 1207 | if expires_delta: 1208 | expire = datetime.utcnow() + expires_delta 1209 | else: 1210 | expire = datetime.utcnow() + timedelta(minutes=15) 1211 | to_encode.update({"exp": expire, "scope": "access_token"}) 1212 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 1213 | return encoded_jwt 1214 | 1215 | 1216 | def create_refresh_token( 1217 | data: dict, expires_delta: Optional[timedelta] = None 1218 | ) -> str: 1219 | """Refresh an expired token""" 1220 | to_encode = data.copy() 1221 | if expires_delta: 1222 | expire = datetime.utcnow() + expires_delta 1223 | else: 1224 | expire = datetime.utcnow() + timedelta(minutes=15) 1225 | to_encode.update({"exp": expire, "scope": "refresh_token"}) 1226 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 1227 | return encoded_jwt 1228 | 1229 | 1230 | def authenticate_user( 1231 | get_user: Callable, username: str, password: str 1232 | ) -> Union[User, bool]: 1233 | """Authenticate the user""" 1234 | user = get_user(username) 1235 | if not user: 1236 | return False 1237 | if not verify_password(password, user.password): 1238 | return False 1239 | return user 1240 | 1241 | 1242 | def get_user(username) -> Optional[User]: 1243 | """Get user from database""" 1244 | query = select(User).where(User.username == username) 1245 | with Session(engine) as session: 1246 | return session.exec(query).first() 1247 | 1248 | 1249 | def get_current_user( 1250 | token: str = Depends(oauth2_scheme), request: Request = None, fresh=False 1251 | ) -> User: 1252 | """Get current user authenticated""" 1253 | credentials_exception = HTTPException( 1254 | status_code=status.HTTP_401_UNAUTHORIZED, 1255 | detail="Could not validate credentials", 1256 | headers={"WWW-Authenticate": "Bearer"}, 1257 | ) 1258 | 1259 | if request: 1260 | if authorization := request.headers.get("authorization"): 1261 | try: 1262 | token = authorization.split(" ")[1] 1263 | except IndexError: 1264 | raise credentials_exception 1265 | 1266 | try: 1267 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 1268 | username: str = payload.get("sub") 1269 | 1270 | if username is None: 1271 | raise credentials_exception 1272 | token_data = TokenData(username=username) 1273 | except JWTError: 1274 | raise credentials_exception 1275 | user = get_user(username=token_data.username) 1276 | if user is None: 1277 | raise credentials_exception 1278 | if fresh and (not payload["fresh"] and not user.superuser): 1279 | raise credentials_exception 1280 | 1281 | return user 1282 | 1283 | 1284 | async def get_current_active_user( 1285 | current_user: User = Depends(get_current_user), 1286 | ) -> User: 1287 | """Wraps the sync get_active_user for sync calls""" 1288 | return current_user 1289 | 1290 | 1291 | AuthenticatedUser = Depends(get_current_active_user) 1292 | 1293 | 1294 | async def validate_token(token: str = Depends(oauth2_scheme)) -> User: 1295 | """Validates user token""" 1296 | user = get_current_user(token=token) 1297 | return user 1298 | ``` 1299 | 1300 | > **NOTA**: O objeto `AuthenticatedUser` é uma dependência do FastAPI e é 1301 | > através dele que iremos garantir que nossas rotas estejas protegidas 1302 | > com token. 1303 | 1304 | A simples presença das urls `/token` e `/refresh_token` fará o FastAPI 1305 | incluir autenticação na API portanto vamos definir essas urls: 1306 | 1307 | `pamps/routes/auth.py` 1308 | ```python 1309 | from datetime import timedelta 1310 | 1311 | from fastapi import APIRouter, Depends, HTTPException, status 1312 | from fastapi.security import OAuth2PasswordRequestForm 1313 | 1314 | from pamps.auth import ( 1315 | RefreshToken, 1316 | Token, 1317 | User, 1318 | authenticate_user, 1319 | create_access_token, 1320 | create_refresh_token, 1321 | get_user, 1322 | validate_token, 1323 | ) 1324 | from pamps.config import settings 1325 | 1326 | ACCESS_TOKEN_EXPIRE_MINUTES = settings.security.access_token_expire_minutes 1327 | REFRESH_TOKEN_EXPIRE_MINUTES = settings.security.refresh_token_expire_minutes 1328 | 1329 | router = APIRouter() 1330 | 1331 | 1332 | @router.post("/token", response_model=Token) 1333 | async def login_for_access_token( 1334 | form_data: OAuth2PasswordRequestForm = Depends(), 1335 | ): 1336 | user = authenticate_user(get_user, form_data.username, form_data.password) 1337 | if not user or not isinstance(user, User): 1338 | raise HTTPException( 1339 | status_code=status.HTTP_401_UNAUTHORIZED, 1340 | detail="Incorrect username or password", 1341 | headers={"WWW-Authenticate": "Bearer"}, 1342 | ) 1343 | 1344 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 1345 | access_token = create_access_token( 1346 | data={"sub": user.username, "fresh": True}, 1347 | expires_delta=access_token_expires, 1348 | ) 1349 | 1350 | refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) 1351 | refresh_token = create_refresh_token( 1352 | data={"sub": user.username}, expires_delta=refresh_token_expires 1353 | ) 1354 | 1355 | return { 1356 | "access_token": access_token, 1357 | "refresh_token": refresh_token, 1358 | "token_type": "bearer", 1359 | } 1360 | 1361 | 1362 | @router.post("/refresh_token", response_model=Token) 1363 | async def refresh_token(form_data: RefreshToken): 1364 | user = await validate_token(token=form_data.refresh_token) 1365 | 1366 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 1367 | access_token = create_access_token( 1368 | data={"sub": user.username, "fresh": False}, 1369 | expires_delta=access_token_expires, 1370 | ) 1371 | 1372 | refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) 1373 | refresh_token = create_refresh_token( 1374 | data={"sub": user.username}, expires_delta=refresh_token_expires 1375 | ) 1376 | 1377 | return { 1378 | "access_token": access_token, 1379 | "refresh_token": refresh_token, 1380 | "token_type": "bearer", 1381 | } 1382 | ``` 1383 | 1384 | E agora vamos adicionar essas URLS ao router principal 1385 | 1386 | `pamps/routes/__init__.py` 1387 | 1388 | No topo na linha 3 1389 | 1390 | ```python 1391 | from .auth import router as auth_router 1392 | ``` 1393 | 1394 | E depois na linha 9 1395 | 1396 | ```python 1397 | main_router.include_router(auth_router, tags=["auth"]) 1398 | ``` 1399 | 1400 | Vamos testar a aquisição de um token via curl ou através da UI. 1401 | 1402 | ```bash 1403 | curl -X 'POST' \ 1404 | 'http://0.0.0.0:8000/token' \ 1405 | -H 'accept: application/json' \ 1406 | -H 'Content-Type: application/x-www-form-urlencoded' \ 1407 | -d 'grant_type=&username=admin&password=1234&scope=&client_id=&client_secret=' 1408 | ``` 1409 | ``` 1410 | { 1411 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImZyZXNoIjp0cnVlLCJleHAiOjE2Njg2Mjg0NjgsInNjb3BlIjoiYWNjZXNzX3Rva2VuIn0.P-F3onD2vFFIld_ls1irE9rOgLNk17SNDASls31lgkU", 1412 | "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY2ODY2MjY2OCwic2NvcGUiOiJyZWZyZXNoX3Rva2VuIn0.AWV8QtySYmcukxTgTa9GedLK00o6wrbyMt9opW42eyQ", 1413 | "token_type": "bearer" 1414 | } 1415 | ``` 1416 | 1417 | ## Adicionando Models de conteúdo 1418 | 1419 | Vamos definir a tabela e serializers para posts. 1420 | 1421 | `pamps/models/post.py` 1422 | ```python 1423 | """Post related data models""" 1424 | 1425 | from datetime import datetime 1426 | from typing import TYPE_CHECKING, Optional 1427 | 1428 | from pydantic import BaseModel, Extra 1429 | from sqlmodel import Field, Relationship, SQLModel 1430 | 1431 | if TYPE_CHECKING: 1432 | from pamps.models.user import User 1433 | 1434 | 1435 | class Post(SQLModel, table=True): 1436 | """Represents the Post Model""" 1437 | 1438 | id: Optional[int] = Field(default=None, primary_key=True) 1439 | text: str 1440 | date: datetime = Field(default_factory=datetime.utcnow, nullable=False) 1441 | 1442 | user_id: Optional[int] = Field(foreign_key="user.id") 1443 | parent_id: Optional[int] = Field(foreign_key="post.id") 1444 | 1445 | # It populates a `.posts` attribute to the `User` model. 1446 | user: Optional["User"] = Relationship(back_populates="posts") 1447 | 1448 | # It populates `.replies` on this model 1449 | parent: Optional["Post"] = Relationship( 1450 | back_populates="replies", 1451 | sa_relationship_kwargs=dict(remote_side="Post.id"), 1452 | ) 1453 | # This lists all children to this post 1454 | replies: list["Post"] = Relationship(back_populates="parent") 1455 | 1456 | def __lt__(self, other): 1457 | """This enables post.replies.sort() to sort by date""" 1458 | return self.date < other.date 1459 | 1460 | 1461 | class PostResponse(BaseModel): 1462 | """Serializer for Post Response""" 1463 | 1464 | id: int 1465 | text: str 1466 | date: datetime 1467 | user_id: int 1468 | parent_id: Optional[int] 1469 | 1470 | 1471 | class PostResponseWithReplies(PostResponse): 1472 | replies: Optional[list["PostResponse"]] = None 1473 | 1474 | class Config: 1475 | orm_mode = True 1476 | 1477 | 1478 | class PostRequest(BaseModel): 1479 | """Serializer for Post request payload""" 1480 | 1481 | parent_id: Optional[int] 1482 | text: str 1483 | 1484 | class Config: 1485 | extra = Extra.allow 1486 | arbitrary_types_allowed = True 1487 | 1488 | ``` 1489 | 1490 | Vamos adicionar uma back-reference em `User` para ser mais fácil obter todos 1491 | os seus posts. 1492 | 1493 | `pamps/models/user.py` 1494 | ```python 1495 | 1496 | # No topo do arquivo 1497 | from typing import TYPE_CHECKING 1498 | 1499 | if TYPE_CHECKING: 1500 | from pamps.models.post import Post 1501 | 1502 | 1503 | class User... 1504 | ... 1505 | # it populates the .user attribute on the Content Model 1506 | posts: List["Post"] = Relationship(back_populates="user") 1507 | ``` 1508 | 1509 | E agora vamos colocar o model Post na raiz do módulo models. 1510 | 1511 | `pamps/models/__init__.py` 1512 | ```python 1513 | from sqlmodel import SQLModel 1514 | 1515 | from .post import Post 1516 | from .user import User 1517 | 1518 | __all__ = ["User", "SQLModel", "Post"] 1519 | ``` 1520 | 1521 | E para facilitar a vida vamos adicionar também ao `cli.py` dentro do comando 1522 | shell no dict `_vars` adicione o model `Post`. 1523 | 1524 | `pamps/cli.py` 1525 | ```python 1526 | from .models import Post, User 1527 | ... 1528 | _vars = { 1529 | ... 1530 | "Post": Post, 1531 | } 1532 | ``` 1533 | 1534 | ## Database Migration 1535 | 1536 | Agora precisamos chamar o **alembic** para gerar a database migration relativa 1537 | a nova tabela `post`. 1538 | 1539 | Dentro do container shell 1540 | 1541 | ```console 1542 | $ alembic revision --autogenerate -m "post" 1543 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 1544 | INFO [alembic.runtime.migration] Will assume transactional DDL. 1545 | INFO [alembic.autogenerate.compare] Detected added table 'post' 1546 | INFO [alembic.ddl.postgresql] Detected sequence named 'user_id_seq' as owned by integer column 'user(id)', assuming SERIAL and omitting 1547 | Generating /home/app/api/migrations/versions/f9b269f8d5f8_post.py ... done 1548 | ``` 1549 | e aplicamos com 1550 | 1551 | ```console 1552 | $ alembic upgrade head 1553 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 1554 | INFO [alembic.runtime.migration] Will assume transactional DDL. 1555 | INFO [alembic.runtime.migration] Running upgrade 4634e842ac70 -> f9b269f8d5f8, post 1556 | ``` 1557 | 1558 | ## Pode testar no cli dentro do container 1559 | 1560 | ```console 1561 | $ pamps shell 1562 | Auto imports: ['settings', 'engine', 'select', 'session', 'User', 'Post'] 1563 | 1564 | In [1]: session.exec(select(Post)).all() 1565 | Out[1]: [] 1566 | ``` 1567 | 1568 | ## Adicionando rotas de conteúdo 1569 | 1570 | Agora os endpoints para listar e adicionar posts 1571 | 1572 | - `GET /post/` lista todos os posts 1573 | - `POST /post/` cria um novo post (exige auth) 1574 | - `GET /post/{id}` pega um post pelo ID com suas respostas 1575 | - `GET /post/user/{username}` Lista posts de um usuário especifico 1576 | 1577 | `pamps/routes/post.py` 1578 | 1579 | ```python 1580 | from typing import List 1581 | 1582 | from fastapi import APIRouter 1583 | from fastapi.exceptions import HTTPException 1584 | from sqlmodel import Session, select 1585 | 1586 | from pamps.auth import AuthenticatedUser 1587 | from pamps.db import ActiveSession 1588 | from pamps.models.post import ( 1589 | Post, 1590 | PostRequest, 1591 | PostResponse, 1592 | PostResponseWithReplies, 1593 | ) 1594 | from pamps.models.user import User 1595 | 1596 | router = APIRouter() 1597 | 1598 | 1599 | @router.get("/", response_model=List[PostResponse]) 1600 | async def list_posts(*, session: Session = ActiveSession): 1601 | """List all posts without replies""" 1602 | query = select(Post).where(Post.parent == None) 1603 | posts = session.exec(query).all() 1604 | return posts 1605 | 1606 | 1607 | @router.get("/{post_id}/", response_model=PostResponseWithReplies) 1608 | async def get_post_by_post_id( 1609 | *, 1610 | session: Session = ActiveSession, 1611 | post_id: int, 1612 | ): 1613 | """Get post by post_id""" 1614 | query = select(Post).where(Post.id == post_id) 1615 | post = session.exec(query).first() 1616 | if not post: 1617 | raise HTTPException(status_code=404, detail="Post not found") 1618 | return post 1619 | 1620 | 1621 | @router.get("/user/{username}/", response_model=List[PostResponse]) 1622 | async def get_posts_by_username( 1623 | *, 1624 | session: Session = ActiveSession, 1625 | username: str, 1626 | include_replies: bool = False, 1627 | ): 1628 | """Get posts by username""" 1629 | filters = [User.username == username] 1630 | if not include_replies: 1631 | filters.append(Post.parent == None) 1632 | query = select(Post).join(User).where(*filters) 1633 | posts = session.exec(query).all() 1634 | return posts 1635 | 1636 | 1637 | @router.post("/", response_model=PostResponse, status_code=201) 1638 | async def create_post( 1639 | *, 1640 | session: Session = ActiveSession, 1641 | user: User = AuthenticatedUser, 1642 | post: PostRequest, 1643 | ): 1644 | """Creates new post""" 1645 | 1646 | post.user_id = user.id 1647 | 1648 | db_post = Post.from_orm(post) # transform PostRequest in Post 1649 | session.add(db_post) 1650 | session.commit() 1651 | session.refresh(db_post) 1652 | return db_post 1653 | ``` 1654 | 1655 | Adicionamos as rotas de `post` em nosso router principal. 1656 | 1657 | `pamps/routes/__init__.py` 1658 | No topo linha 4 1659 | ```python 1660 | from .post import router as post_router 1661 | ``` 1662 | E no final na linha 11 1663 | ```python 1664 | main_router.include_router(post_router, prefix="/post", tags=["post"]) 1665 | ``` 1666 | 1667 | Agora temos uma API quase toda funcional e pode testar clicando 1668 | em `Authorize` usando as senhas criadas pelo CLI ou então crie um novo 1669 | user antes de postar. 1670 | 1671 | ![](auth.png) 1672 | 1673 | ![](auth2.png) 1674 | 1675 | A API final 1676 | 1677 | ![](api_user_post.png) 1678 | 1679 | 1680 | > **NOTA** Ainda está faltando adicionar models e rotas para seguir usuários e 1681 | > para dar like em post. 1682 | 1683 | ## Testando 1684 | 1685 | O Pipeline de testes será 1686 | 1687 | 0. Garantir que o ambiente está em execução com o docker compose 1688 | 1. Garantir que existe um banco de dados `pamps_test` e que este banco está 1689 | vazio. 1690 | 2. Executar as migrations com alembic e garantir que funcionou 1691 | 3. Executar os testes com Pytest 1692 | 4. Apagar o banco de dados de testes 1693 | 1694 | Vamos adicionar um comando `reset_db` no cli 1695 | 1696 | > **NOTA** muito cuidado com esse comando!!! 1697 | 1698 | edite `pamps/cli.py` e adicione ao final 1699 | ```python 1700 | @main.command() 1701 | def reset_db( 1702 | force: bool = typer.Option( 1703 | False, "--force", "-f", help="Run with no confirmation" 1704 | ) 1705 | ): 1706 | """Resets the database tables""" 1707 | force = force or typer.confirm("Are you sure?") 1708 | if force: 1709 | SQLModel.metadata.drop_all(engine) 1710 | 1711 | ``` 1712 | 1713 | Em um ambiente de CI geralmente usamos `Github Actions` ou `Jenkins` para executar 1714 | esses passos, em nosso caso vamos criar um script em bash para executar essas tarefas. 1715 | 1716 | `test.sh` 1717 | ```bash 1718 | #!/usr/bin/bash 1719 | 1720 | # Start environment with docker compose 1721 | PAMPS_DB=pamps_test docker compose up -d 1722 | 1723 | # wait 5 seconds 1724 | sleep 5 1725 | 1726 | # Ensure database is clean 1727 | docker compose exec api pamps reset-db -f 1728 | docker compose exec api alembic stamp base 1729 | 1730 | # run migrations 1731 | docker compose exec api alembic upgrade head 1732 | 1733 | # run tests 1734 | docker compose exec api pytest -v -l --tb=short --maxfail=1 tests/ 1735 | 1736 | # Stop environment 1737 | docker compose down 1738 | ``` 1739 | 1740 | Para os tests vamos utilizar o Pytest para testar algumas rotas da API, 1741 | com o seguinte fluxo 1742 | 1743 | 1. Criar usuário1 1744 | 2. Obter um token para o usuário1 1745 | 3. Criar um post1 com o usuário1 1746 | 4. Criar usuario2 1747 | 5. Obter um token para o usuario2 1748 | 6. Responder o post1 com o usuario2 1749 | 7. Consultar `/post` e garantir que apareçam os posts 1750 | 8. COnsultar `/post/id` e garantir que apareça o post com a resposta 1751 | 9. Consultar `/post/user/usuario1` e garantir que os posts são listados 1752 | 1753 | 1754 | Começamos configurando o Pytest 1755 | 1756 | `tests/conftest.py` 1757 | ```python 1758 | import os 1759 | 1760 | import pytest 1761 | from fastapi.testclient import TestClient 1762 | from sqlalchemy.exc import IntegrityError 1763 | 1764 | from pamps.app import app 1765 | from pamps.cli import create_user 1766 | 1767 | os.environ["PAMPS_DB__uri"] = "postgresql://postgres:postgres@db:5432/pamps_test" 1768 | 1769 | 1770 | @pytest.fixture(scope="function") 1771 | def api_client(): 1772 | return TestClient(app) 1773 | 1774 | 1775 | def create_api_client_authenticated(username): 1776 | 1777 | try: 1778 | create_user(f"{username}@pamps.com", username, username) 1779 | except IntegrityError: 1780 | pass 1781 | 1782 | client = TestClient(app) 1783 | token = client.post( 1784 | "/token", 1785 | data={"username": username, "password": username}, 1786 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 1787 | ).json()["access_token"] 1788 | client.headers["Authorization"] = f"Bearer {token}" 1789 | return client 1790 | 1791 | 1792 | @pytest.fixture(scope="function") 1793 | def api_client_user1(): 1794 | return create_api_client_authenticated("user1") 1795 | 1796 | 1797 | @pytest.fixture(scope="function") 1798 | def api_client_user2(): 1799 | return create_api_client_authenticated("user2") 1800 | ``` 1801 | 1802 | E agora adicionamos os testes 1803 | 1804 | ```python 1805 | import pytest 1806 | 1807 | 1808 | @pytest.mark.order(1) 1809 | def test_post_create_user1(api_client_user1): 1810 | """Create 2 posts with user 1""" 1811 | for n in (1, 2): 1812 | response = api_client_user1.post( 1813 | "/post/", 1814 | json={ 1815 | "text": f"hello test {n}", 1816 | }, 1817 | ) 1818 | assert response.status_code == 201 1819 | result = response.json() 1820 | assert result["text"] == f"hello test {n}" 1821 | assert result["parent_id"] is None 1822 | 1823 | 1824 | @pytest.mark.order(2) 1825 | def test_reply_on_post_1(api_client, api_client_user1, api_client_user2): 1826 | """each user will add a reply to the first post""" 1827 | posts = api_client.get("/post/user/user1/").json() 1828 | first_post = posts[0] 1829 | for n, client in enumerate((api_client_user1, api_client_user2), 1): 1830 | response = client.post( 1831 | "/post/", 1832 | json={ 1833 | "text": f"reply from user{n}", 1834 | "parent_id": first_post["id"], 1835 | }, 1836 | ) 1837 | assert response.status_code == 201 1838 | result = response.json() 1839 | assert result["text"] == f"reply from user{n}" 1840 | assert result["parent_id"] == first_post["id"] 1841 | 1842 | 1843 | @pytest.mark.order(3) 1844 | def test_post_list_without_replies(api_client): 1845 | response = api_client.get("/post/") 1846 | assert response.status_code == 200 1847 | results = response.json() 1848 | assert len(results) == 2 1849 | for result in results: 1850 | assert result["parent_id"] is None 1851 | assert "hello test" in result["text"] 1852 | 1853 | 1854 | @pytest.mark.order(3) 1855 | def test_post1_detail(api_client): 1856 | posts = api_client.get("/post/user/user1/").json() 1857 | first_post = posts[0] 1858 | first_post_id = first_post["id"] 1859 | 1860 | response = api_client.get(f"/post/{first_post_id}/") 1861 | assert response.status_code == 200 1862 | result = response.json() 1863 | assert result["id"] == first_post_id 1864 | assert result["user_id"] == first_post["user_id"] 1865 | assert result["text"] == "hello test 1" 1866 | assert result["parent_id"] is None 1867 | replies = result["replies"] 1868 | assert len(replies) == 2 1869 | for reply in replies: 1870 | assert reply["parent_id"] == first_post_id 1871 | assert "reply from user" in reply["text"] 1872 | 1873 | 1874 | @pytest.mark.order(3) 1875 | def test_all_posts_from_user1(api_client): 1876 | response = api_client.get("/post/user/user1/") 1877 | assert response.status_code == 200 1878 | results = response.json() 1879 | assert len(results) == 2 1880 | for result in results: 1881 | assert result["parent_id"] is None 1882 | assert "hello test" in result["text"] 1883 | 1884 | 1885 | @pytest.mark.order(3) 1886 | def test_all_posts_from_user1_with_replies(api_client): 1887 | response = api_client.get( 1888 | "/post/user/user1/", params={"include_replies": True} 1889 | ) 1890 | assert response.status_code == 200 1891 | results = response.json() 1892 | assert len(results) == 3 1893 | ``` 1894 | 1895 | E para executar os tests podemos ir na raiz do projeto **FORA DO CONTAINER** 1896 | 1897 | ```console 1898 | $ chmod +x test.sh 1899 | ``` 1900 | e 1901 | ```console 1902 | $ ./test.sh 1903 | [+] Running 3/3 1904 | ⠿ Network fastapi-workshop_default Created 0.0s 1905 | ⠿ Container fastapi-workshop-db-1 Started 0.5s 1906 | ⠿ Container fastapi-workshop-api-1 Started 1.4s 1907 | 1908 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 1909 | INFO [alembic.runtime.migration] Will assume transactional DDL. 1910 | INFO [alembic.runtime.migration] Running stamp_revision f432efb19d1a -> 1911 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl. 1912 | INFO [alembic.runtime.migration] Will assume transactional DDL. 1913 | INFO [alembic.runtime.migration] Running upgrade -> ee59b23815d3, initial 1914 | INFO [alembic.runtime.migration] Running upgrade 4634e842ac70 -> f9b269f8d5f8, post 1915 | 1916 | ========================= test session starts ========================= 1917 | platform linux -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0 -- /usr/local/bin/python 1918 | cachedir: .pytest_cache 1919 | rootdir: /home/app/api 1920 | plugins: order-1.0.1, anyio-3.6.2 1921 | collected 6 items 1922 | 1923 | tests/test_api.py::test_post_create_user1 PASSED [ 16%] 1924 | tests/test_api.py::test_reply_on_post_1 PASSED [ 33%] 1925 | tests/test_api.py::test_post_list_without_replies PASSED [ 50%] 1926 | tests/test_api.py::test_post1_detail PASSED [ 66%] 1927 | tests/test_api.py::test_all_posts_from_user1 PASSED [ 83%] 1928 | tests/test_api.py::test_all_posts_from_user1_with_replies PASSED [100%] 1929 | 1930 | ========================== 6 passed in 1.58s ========================== 1931 | 1932 | [+] Running 3/3 1933 | ⠿ Container fastapi-workshop-api-1 Removed 0.8s 1934 | ⠿ Container fastapi-workshop-db-1 Removed 0.6s 1935 | ⠿ Network fastapi-workshop_default Removed 0.5s 1936 | ``` 1937 | 1938 | 1939 | ## Desafios finais 1940 | 1941 | Lembra-se do nosso database? 1942 | 1943 | ![](database.png) 1944 | 1945 | Em nosso projeto está faltando adicionar os models para `Social` e `Like` 1946 | 1947 | ### Social 1948 | 1949 | O objetivo é que um usuário possa seguir outro usuário, 1950 | para isso o usuário precisará estar autenticado e fazer um `post` request em 1951 | `POST /user/follow/{id}` e sua tarefa é implementar esse endpoint armazenando 1952 | o resultado na tabela `Social`. 1953 | 1954 | - Passo 1 1955 | Edite `pamps/models/user.py` e adicione a tabela `Social` com toda a 1956 | especificação e relacionamentos necessários. (adicione esse model ao `__init__.py` 1957 | - Passo 2 1958 | Execute dentro do shell do container `alembic revision --autogenerate -m 'social'` 1959 | para criar a migração 1960 | - Passo 3 1961 | Aplique as migrations de tabela com `alembic upgrade head` 1962 | - Passo 4 1963 | Crie o Endpoint em `pamps/routes/user.py` com a lógica necessária e adicione ao 1964 | router `__init__.py` 1965 | - Passo 5 1966 | Escreva um teste em `tests_user.py` para testar a funcionalidade de um usuário 1967 | seguir outro usuário 1968 | - Passo 6 1969 | Em `pamps/routes/user.py` cria uma rota `/timeline` que ao acessar `/user/timeline` 1970 | irá listar todos os posts de todos os usuários que o user autenticado segue. 1971 | 1972 | ### Like 1973 | 1974 | O objetivo é que um usuário possa enviar um like em um post e para isso 1975 | precisará estar autenticado e fazer um `post` em `/post/{post_id}/like/` 1976 | e a sua tarefa é implementar esse endpoint salvando o resultado na tabela `Like`. 1977 | 1978 | - Passo1 1979 | Edite `pamps/model/post.py` e adicione a tabela `Like` com toda a especificação 1980 | necessária com relacionamentos e adicione ao model `__init__.py` 1981 | - Passo 2 1982 | Execute dentro do shell do container `alembic revision --autogenerate -m 'like'` 1983 | para criar a migração 1984 | - Passo 3 1985 | Aplique as migrations de tabela com `alembic upgrade head` 1986 | - Passo 4 1987 | Crie o endpoint em `pamps/routes/post.py` com a lógica necessária e adicione ao 1988 | routes `__init__.py` 1989 | - Passo 5 1990 | Escreva um teste onde um user pode deixar um like em um post 1991 | - Passo 6 1992 | Em `pamps/routes/post.py` crie uma rota `/likes/{username}/` que retorne 1993 | todos os posts que um user curtiu. 1994 | 1995 | ### Desafio extra opcional 1996 | 1997 | Use React ou VueJS para criar um front-end para esta aplicação :) 1998 | -------------------------------------------------------------------------------- /docs/api_first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/api_first.png -------------------------------------------------------------------------------- /docs/api_second.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/api_second.png -------------------------------------------------------------------------------- /docs/api_user_post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/api_user_post.png -------------------------------------------------------------------------------- /docs/auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/auth.png -------------------------------------------------------------------------------- /docs/auth2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/auth2.png -------------------------------------------------------------------------------- /docs/database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/database.png -------------------------------------------------------------------------------- /docs/fastapi_workshop_linuxtips.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/fastapi_workshop_linuxtips.pdf -------------------------------------------------------------------------------- /docs/pamps_postgres_create.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "public.user" ( 2 | "id" serial NOT NULL, 3 | "email" varchar(255) NOT NULL UNIQUE, 4 | "username" varchar(255) NOT NULL UNIQUE, 5 | "avatar" varchar(255), 6 | "bio" TEXT, 7 | "password" TEXT NOT NULL, 8 | CONSTRAINT "user_pk" PRIMARY KEY ("id") 9 | ) WITH ( 10 | OIDS=FALSE 11 | ); 12 | 13 | 14 | 15 | CREATE TABLE "public.social" ( 16 | "id" serial NOT NULL, 17 | "from" bigint NOT NULL, 18 | "to" bigint NOT NULL, 19 | "date" TIMESTAMP NOT NULL, 20 | CONSTRAINT "social_pk" PRIMARY KEY ("id") 21 | ) WITH ( 22 | OIDS=FALSE 23 | ); 24 | 25 | 26 | 27 | CREATE TABLE "public.post" ( 28 | "id" serial NOT NULL, 29 | "user" bigint NOT NULL, 30 | "text" varchar(255) NOT NULL, 31 | "date" TIMESTAMP NOT NULL, 32 | "parent" bigint, 33 | CONSTRAINT "post_pk" PRIMARY KEY ("id") 34 | ) WITH ( 35 | OIDS=FALSE 36 | ); 37 | 38 | 39 | 40 | CREATE TABLE "public.like" ( 41 | "id" serial NOT NULL, 42 | "user" bigint NOT NULL, 43 | "post" bigint NOT NULL, 44 | CONSTRAINT "like_pk" PRIMARY KEY ("id") 45 | ) WITH ( 46 | OIDS=FALSE 47 | ); 48 | 49 | 50 | 51 | 52 | ALTER TABLE "social" ADD CONSTRAINT "social_fk0" FOREIGN KEY ("from") REFERENCES "user"("id"); 53 | ALTER TABLE "social" ADD CONSTRAINT "social_fk1" FOREIGN KEY ("to") REFERENCES "user"("id"); 54 | 55 | ALTER TABLE "post" ADD CONSTRAINT "post_fk0" FOREIGN KEY ("user") REFERENCES "user"("id"); 56 | ALTER TABLE "post" ADD CONSTRAINT "post_fk1" FOREIGN KEY ("parent") REFERENCES "post"("id"); 57 | 58 | ALTER TABLE "like" ADD CONSTRAINT "like_fk0" FOREIGN KEY ("user") REFERENCES "user"("id"); 59 | ALTER TABLE "like" ADD CONSTRAINT "like_fk1" FOREIGN KEY ("post") REFERENCES "post"("id"); 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/user_routes1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/docs/user_routes1.png -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from pamps import models 2 | from pamps.db import engine 3 | from pamps.config import settings 4 | 5 | from logging.config import fileConfig 6 | 7 | from sqlalchemy import engine_from_config 8 | from sqlalchemy import pool 9 | 10 | from alembic import context 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata = models.SQLModel.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline() -> None: 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = settings.db.uri 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online() -> None: 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | connectable = engine 65 | 66 | with connectable.connect() as connection: 67 | context.configure( 68 | connection=connection, target_metadata=target_metadata 69 | ) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | if context.is_offline_mode(): 76 | run_migrations_offline() 77 | else: 78 | run_migrations_online() 79 | -------------------------------------------------------------------------------- /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 alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade() -> None: 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade() -> None: 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /migrations/versions/d989fa6bfe59_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: d989fa6bfe59 4 | Revises: 5 | Create Date: 2022-11-18 15:30:28.066221 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'd989fa6bfe59' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('user', 23 | sa.Column('id', sa.Integer(), nullable=False), 24 | sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 26 | sa.Column('avatar', sqlmodel.sql.sqltypes.AutoString(), nullable=True), 27 | sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True), 28 | sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 29 | sa.PrimaryKeyConstraint('id'), 30 | sa.UniqueConstraint('email'), 31 | sa.UniqueConstraint('username') 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade() -> None: 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('user') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/e71d88346f62_posts.py: -------------------------------------------------------------------------------- 1 | """posts 2 | 3 | Revision ID: e71d88346f62 4 | Revises: d989fa6bfe59 5 | Create Date: 2022-11-18 17:00:59.073861 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'e71d88346f62' 15 | down_revision = 'd989fa6bfe59' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('post', 23 | sa.Column('id', sa.Integer(), nullable=False), 24 | sa.Column('text', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column('date', sa.DateTime(), nullable=False), 26 | sa.Column('user_id', sa.Integer(), nullable=True), 27 | sa.Column('parent_id', sa.Integer(), nullable=True), 28 | sa.ForeignKeyConstraint(['parent_id'], ['post.id'], ), 29 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('post') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /pamps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/pamps/__init__.py -------------------------------------------------------------------------------- /pamps/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .routes import main_router 4 | 5 | app = FastAPI( 6 | title="Pamps", 7 | version="0.1.0", 8 | description="Pamps is a posting app to clone twitter", 9 | ) 10 | 11 | app.include_router(main_router) 12 | -------------------------------------------------------------------------------- /pamps/auth.py: -------------------------------------------------------------------------------- 1 | """Token absed auth""" 2 | from datetime import datetime, timedelta 3 | from typing import Callable, Optional, Union 4 | 5 | from fastapi import Depends, HTTPException, Request, status 6 | from fastapi.security import OAuth2PasswordBearer 7 | from jose import JWTError, jwt 8 | from pydantic import BaseModel 9 | from sqlmodel import Session, select 10 | 11 | from pamps.config import settings 12 | from pamps.db import engine 13 | from pamps.models.user import User 14 | from pamps.security import verify_password 15 | 16 | SECRET_KEY = settings.security.secret_key 17 | ALGORITHM = settings.security.algorithm 18 | 19 | 20 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 21 | 22 | 23 | class Token(BaseModel): 24 | access_token: str 25 | refresh_token: str 26 | token_type: str 27 | 28 | 29 | class RefreshToken(BaseModel): 30 | refresh_token: str 31 | 32 | 33 | class TokenData(BaseModel): 34 | username: Optional[str] = None 35 | 36 | 37 | def create_access_token( 38 | data: dict, expires_delta: Optional[timedelta] = None 39 | ) -> str: 40 | """Creates a JWT Token from user data""" 41 | to_encode = data.copy() 42 | if expires_delta: 43 | expire = datetime.utcnow() + expires_delta 44 | else: 45 | expire = datetime.utcnow() + timedelta(minutes=15) 46 | to_encode.update({"exp": expire, "scope": "access_token"}) 47 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 48 | return encoded_jwt 49 | 50 | 51 | def create_refresh_token( 52 | data: dict, expires_delta: Optional[timedelta] = None 53 | ) -> str: 54 | """Refresh an expired token""" 55 | to_encode = data.copy() 56 | if expires_delta: 57 | expire = datetime.utcnow() + expires_delta 58 | else: 59 | expire = datetime.utcnow() + timedelta(minutes=15) 60 | to_encode.update({"exp": expire, "scope": "refresh_token"}) 61 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 62 | return encoded_jwt 63 | 64 | 65 | def authenticate_user( 66 | get_user: Callable, username: str, password: str 67 | ) -> Union[User, bool]: 68 | """Authenticate the user""" 69 | user = get_user(username) 70 | if not user: 71 | return False 72 | if not verify_password(password, user.password): 73 | return False 74 | return user 75 | 76 | 77 | def get_user(username) -> Optional[User]: 78 | """Get user from database""" 79 | query = select(User).where(User.username == username) 80 | with Session(engine) as session: 81 | return session.exec(query).first() 82 | 83 | 84 | def get_current_user( 85 | token: str = Depends(oauth2_scheme), request: Request = None, fresh=False 86 | ) -> User: 87 | """Get current user authenticated""" 88 | credentials_exception = HTTPException( 89 | status_code=status.HTTP_401_UNAUTHORIZED, 90 | detail="Could not validate credentials", 91 | headers={"WWW-Authenticate": "Bearer"}, 92 | ) 93 | 94 | if request: 95 | if authorization := request.headers.get("authorization"): 96 | try: 97 | token = authorization.split(" ")[1] 98 | except IndexError: 99 | raise credentials_exception 100 | 101 | try: 102 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 103 | username: str = payload.get("sub") 104 | 105 | if username is None: 106 | raise credentials_exception 107 | token_data = TokenData(username=username) 108 | except JWTError: 109 | raise credentials_exception 110 | user = get_user(username=token_data.username) 111 | if user is None: 112 | raise credentials_exception 113 | if fresh and (not payload["fresh"] and not user.superuser): 114 | raise credentials_exception 115 | 116 | return user 117 | 118 | 119 | async def get_current_active_user( 120 | current_user: User = Depends(get_current_user), 121 | ) -> User: 122 | """Wraps the sync get_active_user for sync calls""" 123 | return current_user 124 | 125 | 126 | AuthenticatedUser = Depends(get_current_active_user) 127 | 128 | 129 | async def validate_token(token: str = Depends(oauth2_scheme)) -> User: 130 | """Validates user token""" 131 | user = get_current_user(token=token) 132 | return user 133 | -------------------------------------------------------------------------------- /pamps/cli.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich.console import Console 3 | from rich.table import Table 4 | from sqlmodel import Session, select 5 | 6 | from .config import settings 7 | from .db import engine 8 | from .models import Post, SQLModel, User 9 | 10 | main = typer.Typer(name="Pamps CLI") 11 | 12 | 13 | @main.command() 14 | def shell(): 15 | """Opens interactive shell""" 16 | _vars = { 17 | "settings": settings, 18 | "engine": engine, 19 | "select": select, 20 | "session": Session(engine), 21 | "User": User, 22 | "Post": Post, 23 | } 24 | typer.echo(f"Auto imports: {list(_vars.keys())}") 25 | try: 26 | from IPython import start_ipython 27 | 28 | start_ipython( 29 | argv=["--ipython-dir=/tmp", "--no-banner"], user_ns=_vars 30 | ) 31 | except ImportError: 32 | import code 33 | 34 | code.InteractiveConsole(_vars).interact() 35 | 36 | 37 | @main.command() 38 | def user_list(): 39 | """Lists all users""" 40 | table = Table(title="Pamps users") 41 | fields = ["username", "email"] 42 | for header in fields: 43 | table.add_column(header, style="magenta") 44 | 45 | with Session(engine) as session: 46 | users = session.exec(select(User)) 47 | for user in users: 48 | table.add_row(user.username, user.email) 49 | 50 | Console().print(table) 51 | 52 | 53 | @main.command() 54 | def create_user(email: str, username: str, password: str): 55 | """Create user""" 56 | with Session(engine) as session: 57 | user = User(email=email, username=username, password=password) 58 | session.add(user) 59 | session.commit() 60 | session.refresh(user) 61 | typer.echo(f"created {username} user") 62 | return user 63 | 64 | 65 | @main.command() 66 | def reset_db( 67 | force: bool = typer.Option( 68 | False, "--force", "-f", help="Run with no confirmation" 69 | ) 70 | ): 71 | """Resets the database tables""" 72 | force = force or typer.confirm("Are you sure?") 73 | if force: 74 | SQLModel.metadata.drop_all(engine) 75 | -------------------------------------------------------------------------------- /pamps/config.py: -------------------------------------------------------------------------------- 1 | """Settings module""" 2 | import os 3 | 4 | from dynaconf import Dynaconf 5 | 6 | HERE = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | settings = Dynaconf( 9 | envvar_prefix="pamps", 10 | preload=[os.path.join(HERE, "default.toml")], 11 | settings_files=["settings.toml", ".secrets.toml"], 12 | environments=["development", "production", "testing"], 13 | env_switcher="pamps_env", 14 | load_dotenv=False, 15 | ) 16 | -------------------------------------------------------------------------------- /pamps/db.py: -------------------------------------------------------------------------------- 1 | """Database connection""" 2 | from fastapi import Depends 3 | from sqlmodel import Session, create_engine 4 | 5 | from .config import settings 6 | 7 | engine = create_engine( 8 | settings.db.uri, 9 | echo=settings.db.echo, 10 | connect_args=settings.db.connect_args, 11 | ) 12 | 13 | 14 | def get_session(): 15 | with Session(engine) as session: 16 | yield session 17 | 18 | 19 | ActiveSession = Depends(get_session) 20 | -------------------------------------------------------------------------------- /pamps/default.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | 3 | [default.db] 4 | uri = "" 5 | connect_args = {check_same_thread=false} 6 | echo = false 7 | 8 | 9 | [default.security] 10 | # Set secret key in .secrets.toml 11 | SECRET_KEY = "troque_por_favor_no_secrets_toml" 12 | ALGORITHM = "HS256" 13 | ACCESS_TOKEN_EXPIRE_MINUTES = 30 14 | REFRESH_TOKEN_EXPIRE_MINUTES = 600 15 | -------------------------------------------------------------------------------- /pamps/models/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel 2 | 3 | from .post import Post 4 | from .user import User 5 | 6 | __all__ = ["SQLModel", "User", "Post"] 7 | -------------------------------------------------------------------------------- /pamps/models/post.py: -------------------------------------------------------------------------------- 1 | """Post related data models""" 2 | 3 | from datetime import datetime 4 | from typing import TYPE_CHECKING, Optional 5 | 6 | from pydantic import BaseModel, Extra 7 | from sqlmodel import Field, Relationship, SQLModel 8 | 9 | if TYPE_CHECKING: 10 | from pamps.models.user import User 11 | 12 | 13 | class Post(SQLModel, table=True): 14 | """Represents the Post Model""" 15 | 16 | id: Optional[int] = Field(default=None, primary_key=True) 17 | text: str 18 | date: datetime = Field(default_factory=datetime.utcnow, nullable=False) 19 | 20 | user_id: Optional[int] = Field(foreign_key="user.id") 21 | parent_id: Optional[int] = Field(foreign_key="post.id") 22 | 23 | # It populates a `.posts` attribute to the `User` model. 24 | user: Optional["User"] = Relationship(back_populates="posts") 25 | 26 | # It populates `.replies` on this model 27 | parent: Optional["Post"] = Relationship( 28 | back_populates="replies", 29 | sa_relationship_kwargs=dict(remote_side="Post.id"), 30 | ) 31 | # This lists all children to this post 32 | replies: list["Post"] = Relationship(back_populates="parent") 33 | 34 | def __lt__(self, other): 35 | """This enables post.replies.sort() to sort by date""" 36 | return self.date < other.date 37 | 38 | 39 | class PostResponse(BaseModel): 40 | """Serializer for Post Response""" 41 | 42 | id: int 43 | text: str 44 | date: datetime 45 | user_id: int 46 | parent_id: Optional[int] 47 | 48 | 49 | class PostResponseWithReplies(PostResponse): 50 | replies: Optional[list["PostResponse"]] = None 51 | 52 | class Config: 53 | orm_mode = True 54 | 55 | 56 | class PostRequest(BaseModel): 57 | """Serializer for Post request payload""" 58 | 59 | parent_id: Optional[int] 60 | text: str 61 | 62 | class Config: 63 | extra = Extra.allow 64 | arbitrary_types_allowed = True 65 | -------------------------------------------------------------------------------- /pamps/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional 2 | 3 | from pydantic import BaseModel 4 | from sqlmodel import Field, Relationship, SQLModel 5 | 6 | from pamps.security import HashedPassword 7 | 8 | if TYPE_CHECKING: 9 | from pamps.models.post import Post 10 | 11 | 12 | class User(SQLModel, table=True): 13 | """Represents the User Model""" 14 | 15 | id: Optional[int] = Field(default=None, primary_key=True) 16 | email: str = Field(unique=True, nullable=False) 17 | username: str = Field(unique=True, nullable=False) 18 | avatar: Optional[str] = None 19 | bio: Optional[str] = None 20 | password: HashedPassword 21 | 22 | # it populates the .user attribute on the Post Model 23 | posts: List["Post"] = Relationship(back_populates="user") 24 | 25 | 26 | class UserResponse(BaseModel): 27 | """Serializer for User Response""" 28 | 29 | username: str 30 | avatar: Optional[str] = None 31 | bio: Optional[str] = None 32 | 33 | 34 | class UserRequest(BaseModel): 35 | """Serializer for User request payload""" 36 | 37 | email: str 38 | username: str 39 | password: str 40 | avatar: Optional[str] = None 41 | bio: Optional[str] = None 42 | -------------------------------------------------------------------------------- /pamps/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .auth import router as auth_router 4 | from .post import router as post_router 5 | from .user import router as user_router 6 | 7 | main_router = APIRouter() 8 | 9 | main_router.include_router(auth_router, tags=["auth"]) 10 | main_router.include_router(user_router, prefix="/user", tags=["user"]) 11 | main_router.include_router(post_router, prefix="/post", tags=["post"]) 12 | -------------------------------------------------------------------------------- /pamps/routes/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordRequestForm 5 | 6 | from pamps.auth import ( 7 | RefreshToken, 8 | Token, 9 | User, 10 | authenticate_user, 11 | create_access_token, 12 | create_refresh_token, 13 | get_user, 14 | validate_token, 15 | ) 16 | from pamps.config import settings 17 | 18 | ACCESS_TOKEN_EXPIRE_MINUTES = settings.security.access_token_expire_minutes 19 | REFRESH_TOKEN_EXPIRE_MINUTES = settings.security.refresh_token_expire_minutes 20 | 21 | router = APIRouter() 22 | 23 | 24 | @router.post("/token", response_model=Token) 25 | async def login_for_access_token( 26 | form_data: OAuth2PasswordRequestForm = Depends(), 27 | ): 28 | user = authenticate_user(get_user, form_data.username, form_data.password) 29 | if not user or not isinstance(user, User): 30 | raise HTTPException( 31 | status_code=status.HTTP_401_UNAUTHORIZED, 32 | detail="Incorrect username or password", 33 | headers={"WWW-Authenticate": "Bearer"}, 34 | ) 35 | 36 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 37 | access_token = create_access_token( 38 | data={"sub": user.username, "fresh": True}, 39 | expires_delta=access_token_expires, 40 | ) 41 | 42 | refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) 43 | refresh_token = create_refresh_token( 44 | data={"sub": user.username}, expires_delta=refresh_token_expires 45 | ) 46 | 47 | return { 48 | "access_token": access_token, 49 | "refresh_token": refresh_token, 50 | "token_type": "bearer", 51 | } 52 | 53 | 54 | @router.post("/refresh_token", response_model=Token) 55 | async def refresh_token(form_data: RefreshToken): 56 | user = await validate_token(token=form_data.refresh_token) 57 | 58 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 59 | access_token = create_access_token( 60 | data={"sub": user.username, "fresh": False}, 61 | expires_delta=access_token_expires, 62 | ) 63 | 64 | refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) 65 | refresh_token = create_refresh_token( 66 | data={"sub": user.username}, expires_delta=refresh_token_expires 67 | ) 68 | 69 | return { 70 | "access_token": access_token, 71 | "refresh_token": refresh_token, 72 | "token_type": "bearer", 73 | } 74 | -------------------------------------------------------------------------------- /pamps/routes/post.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter 4 | from fastapi.exceptions import HTTPException 5 | from sqlmodel import Session, select 6 | 7 | from pamps.auth import AuthenticatedUser 8 | from pamps.db import ActiveSession 9 | from pamps.models.post import ( 10 | Post, 11 | PostRequest, 12 | PostResponse, 13 | PostResponseWithReplies, 14 | ) 15 | from pamps.models.user import User 16 | 17 | router = APIRouter() 18 | 19 | 20 | @router.get("/", response_model=List[PostResponse]) 21 | async def list_posts(*, session: Session = ActiveSession): 22 | """List all posts without replies""" 23 | query = select(Post).where(Post.parent == None) 24 | posts = session.exec(query).all() 25 | return posts 26 | 27 | 28 | @router.get("/{post_id}/", response_model=PostResponseWithReplies) 29 | async def get_post_by_post_id( 30 | *, 31 | session: Session = ActiveSession, 32 | post_id: int, 33 | ): 34 | """Get post by post_id""" 35 | query = select(Post).where(Post.id == post_id) 36 | post = session.exec(query).first() 37 | if not post: 38 | raise HTTPException(status_code=404, detail="Post not found") 39 | return post 40 | 41 | 42 | @router.get("/user/{username}/", response_model=List[PostResponse]) 43 | async def get_posts_by_username( 44 | *, 45 | session: Session = ActiveSession, 46 | username: str, 47 | include_replies: bool = False, 48 | ): 49 | """Get posts by username""" 50 | filters = [User.username == username] 51 | if not include_replies: 52 | filters.append(Post.parent == None) 53 | query = select(Post).join(User).where(*filters) 54 | posts = session.exec(query).all() 55 | return posts 56 | 57 | 58 | @router.post("/", response_model=PostResponse, status_code=201) 59 | async def create_post( 60 | *, 61 | session: Session = ActiveSession, 62 | user: User = AuthenticatedUser, 63 | post: PostRequest, 64 | ): 65 | """Creates new post""" 66 | 67 | post.user_id = user.id 68 | 69 | db_post = Post.from_orm(post) # transform PostRequest in Post 70 | session.add(db_post) 71 | session.commit() 72 | session.refresh(db_post) 73 | return db_post 74 | -------------------------------------------------------------------------------- /pamps/routes/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter 4 | from fastapi.exceptions import HTTPException 5 | from sqlmodel import Session, select 6 | 7 | from pamps.db import ActiveSession 8 | from pamps.models.user import User, UserRequest, UserResponse 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get("/", response_model=List[UserResponse]) 14 | async def list_users(*, session: Session = ActiveSession): 15 | """List all users.""" 16 | users = session.exec(select(User)).all() 17 | return users 18 | 19 | 20 | @router.get("/{username}/", response_model=UserResponse) 21 | async def get_user_by_username( 22 | *, session: Session = ActiveSession, username: str 23 | ): 24 | """Get user by username""" 25 | query = select(User).where(User.username == username) 26 | user = session.exec(query).first() 27 | if not user: 28 | raise HTTPException(status_code=404, detail="User not found") 29 | return user 30 | 31 | 32 | @router.post("/", response_model=UserResponse, status_code=201) 33 | async def create_user(*, session: Session = ActiveSession, user: UserRequest): 34 | """Creates new user""" 35 | db_user = User.from_orm(user) # transform UserRequest in User 36 | session.add(db_user) 37 | session.commit() 38 | session.refresh(db_user) 39 | return db_user 40 | -------------------------------------------------------------------------------- /pamps/security.py: -------------------------------------------------------------------------------- 1 | """Security utilities""" 2 | from passlib.context import CryptContext 3 | 4 | from pamps.config import settings 5 | 6 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 7 | 8 | 9 | SECRET_KEY = settings.security.secret_key 10 | ALGORITHM = settings.security.algorithm 11 | 12 | 13 | def verify_password(plain_password, hashed_password) -> bool: 14 | """Verifies a hash against a password""" 15 | return pwd_context.verify(plain_password, hashed_password) 16 | 17 | 18 | def get_password_hash(password) -> str: 19 | """Generates a hash from plain text""" 20 | return pwd_context.hash(password) 21 | 22 | 23 | class HashedPassword(str): 24 | """Takes a plain text password and hashes it. 25 | use this as a field in your SQLModel 26 | class User(SQLModel, table=True): 27 | username: str 28 | password: HashedPassword 29 | """ 30 | 31 | @classmethod 32 | def __get_validators__(cls): 33 | # one or more validators may be yielded which will be called in the 34 | # order to validate the input, each validator will receive as an input 35 | # the value returned from the previous validator 36 | yield cls.validate 37 | 38 | @classmethod 39 | def validate(cls, v): 40 | """Accepts a plain text password and returns a hashed password.""" 41 | if not isinstance(v, str): 42 | raise TypeError("string required") 43 | 44 | hashed_password = get_password_hash(v) 45 | # you could also return a string here which would mean model.password 46 | # would be a string, pydantic won't care but you could end up with some 47 | # confusion since the value's type won't match the type annotation 48 | # exactly 49 | return cls(hashed_password) 50 | -------------------------------------------------------------------------------- /postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:alpine3.14 2 | # Uncomment to use Mac M1 3 | # FROM --platform=linux/amd64 postgres:alpine3.14 4 | COPY create-databases.sh /docker-entrypoint-initdb.d/ 5 | -------------------------------------------------------------------------------- /postgres/create-databases.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -u 5 | 6 | function create_user_and_database() { 7 | local database=$1 8 | echo "Creating user and database '$database'" 9 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 10 | CREATE USER $database PASSWORD '$database'; 11 | CREATE DATABASE $database; 12 | GRANT ALL PRIVILEGES ON DATABASE $database TO $database; 13 | EOSQL 14 | } 15 | 16 | if [ -n "$POSTGRES_DBS" ]; then 17 | echo "Creating DB(s): $POSTGRES_DBS" 18 | for db in $(echo $POSTGRES_DBS | tr ',' ' '); do 19 | create_user_and_database $db 20 | done 21 | echo "Multiple databases created" 22 | fi 23 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | ipython # terminal 2 | ipdb # debugger 3 | sdb # debugger remoto 4 | pip-tools # lock de dependencias 5 | pytest # execução de testes 6 | pytest-order # ordenação de testes 7 | httpx # requests async para testes 8 | black # auto formatação 9 | flake8 # linter 10 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn 3 | sqlmodel 4 | typer 5 | dynaconf 6 | jinja2 7 | python-jose[cryptography] 8 | passlib[bcrypt] 9 | python-multipart 10 | psycopg2-binary 11 | alembic 12 | rich 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | alembic==1.8.1 8 | # via -r requirements.in 9 | anyio==3.6.2 10 | # via starlette 11 | bcrypt==4.0.1 12 | # via passlib 13 | cffi==1.15.1 14 | # via cryptography 15 | click==8.1.3 16 | # via 17 | # typer 18 | # uvicorn 19 | commonmark==0.9.1 20 | # via rich 21 | cryptography==38.0.3 22 | # via python-jose 23 | dynaconf==3.1.11 24 | # via -r requirements.in 25 | ecdsa==0.18.0 26 | # via python-jose 27 | fastapi==0.87.0 28 | # via -r requirements.in 29 | greenlet==2.0.1 30 | # via sqlalchemy 31 | h11==0.14.0 32 | # via uvicorn 33 | idna==3.4 34 | # via anyio 35 | jinja2==3.1.2 36 | # via -r requirements.in 37 | mako==1.2.4 38 | # via alembic 39 | markupsafe==2.1.1 40 | # via 41 | # jinja2 42 | # mako 43 | passlib[bcrypt]==1.7.4 44 | # via -r requirements.in 45 | psycopg2-binary==2.9.5 46 | # via -r requirements.in 47 | pyasn1==0.4.8 48 | # via 49 | # python-jose 50 | # rsa 51 | pycparser==2.21 52 | # via cffi 53 | pydantic==1.10.2 54 | # via 55 | # fastapi 56 | # sqlmodel 57 | pygments==2.13.0 58 | # via rich 59 | python-jose[cryptography]==3.3.0 60 | # via -r requirements.in 61 | python-multipart==0.0.5 62 | # via -r requirements.in 63 | rich==12.6.0 64 | # via -r requirements.in 65 | rsa==4.9 66 | # via python-jose 67 | six==1.16.0 68 | # via 69 | # ecdsa 70 | # python-multipart 71 | sniffio==1.3.0 72 | # via anyio 73 | sqlalchemy==1.4.41 74 | # via 75 | # alembic 76 | # sqlmodel 77 | sqlalchemy2-stubs==0.0.2a29 78 | # via sqlmodel 79 | sqlmodel==0.0.8 80 | # via -r requirements.in 81 | starlette==0.21.0 82 | # via fastapi 83 | typer==0.7.0 84 | # via -r requirements.in 85 | typing-extensions==4.4.0 86 | # via 87 | # pydantic 88 | # sqlalchemy2-stubs 89 | uvicorn==0.19.0 90 | # via -r requirements.in 91 | -------------------------------------------------------------------------------- /settings.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/settings.toml -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def read(*paths, **kwargs): 7 | content = "" 8 | with io.open( 9 | os.path.join(os.path.dirname(__file__), *paths), 10 | encoding=kwargs.get("encoding", "utf8"), 11 | ) as open_file: 12 | content = open_file.read().strip() 13 | return content 14 | 15 | 16 | def read_requirements(path): 17 | return [ 18 | line.strip() 19 | for line in read(path).split("\n") 20 | if not line.startswith(('"', "#", "-", "git+")) 21 | ] 22 | 23 | 24 | setup( 25 | name="pamps", 26 | version="0.1.0", 27 | description="Pamps is a social posting app", 28 | url="pamps.io", 29 | python_requires=">=3.8", 30 | long_description="Pamps is a social posting app", 31 | long_description_content_type="text/markdown", 32 | author="Melon Husky", 33 | packages=find_packages(exclude=["tests"]), 34 | include_package_data=True, 35 | install_requires=read_requirements("requirements.txt"), 36 | entry_points={ 37 | "console_scripts": ["pamps = pamps.cli:main"] 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # Start environment with docker compose 4 | PAMPS_DB=pamps_test docker compose up -d 5 | 6 | # wait 5 seconds 7 | sleep 5 8 | 9 | # Ensure database is clean 10 | docker compose exec api pamps reset-db -f 11 | docker compose exec api alembic stamp base 12 | 13 | # run migrations 14 | docker compose exec api alembic upgrade head 15 | 16 | # run tests 17 | docker compose exec api pytest -v -l --tb=short --maxfail=1 tests/ 18 | 19 | # Stop environment 20 | docker compose down 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rochacbruno/fastapi-workshop/354d77ecba7424d9a5ac5f944799bf0e1b4c6ac2/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy.exc import IntegrityError 6 | 7 | from pamps.app import app 8 | from pamps.cli import create_user 9 | 10 | os.environ["PAMPS_DB__uri"] = "postgresql://postgres:postgres@db:5432/pamps_test" 11 | 12 | 13 | @pytest.fixture(scope="function") 14 | def api_client(): 15 | return TestClient(app) 16 | 17 | 18 | def create_api_client_authenticated(username): 19 | 20 | try: 21 | create_user(f"{username}@pamps.com", username, username) 22 | except IntegrityError: 23 | pass 24 | 25 | client = TestClient(app) 26 | token = client.post( 27 | "/token", 28 | data={"username": username, "password": username}, 29 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 30 | ).json()["access_token"] 31 | client.headers["Authorization"] = f"Bearer {token}" 32 | return client 33 | 34 | 35 | @pytest.fixture(scope="function") 36 | def api_client_user1(): 37 | return create_api_client_authenticated("user1") 38 | 39 | 40 | @pytest.fixture(scope="function") 41 | def api_client_user2(): 42 | return create_api_client_authenticated("user2") 43 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.order(1) 5 | def test_post_create_user1(api_client_user1): 6 | """Create 2 posts with user 1""" 7 | for n in (1, 2): 8 | response = api_client_user1.post( 9 | "/post/", 10 | json={ 11 | "text": f"hello test {n}", 12 | }, 13 | ) 14 | assert response.status_code == 201 15 | result = response.json() 16 | assert result["text"] == f"hello test {n}" 17 | assert result["parent_id"] is None 18 | 19 | 20 | @pytest.mark.order(2) 21 | def test_reply_on_post_1(api_client, api_client_user1, api_client_user2): 22 | """each user will add a reply to the first post""" 23 | posts = api_client.get("/post/user/user1/").json() 24 | first_post = posts[0] 25 | for n, client in enumerate((api_client_user1, api_client_user2), 1): 26 | response = client.post( 27 | "/post/", 28 | json={ 29 | "text": f"reply from user{n}", 30 | "parent_id": first_post["id"], 31 | }, 32 | ) 33 | assert response.status_code == 201 34 | result = response.json() 35 | assert result["text"] == f"reply from user{n}" 36 | assert result["parent_id"] == first_post["id"] 37 | 38 | 39 | @pytest.mark.order(3) 40 | def test_post_list_without_replies(api_client): 41 | response = api_client.get("/post/") 42 | assert response.status_code == 200 43 | results = response.json() 44 | assert len(results) == 2 45 | for result in results: 46 | assert result["parent_id"] is None 47 | assert "hello test" in result["text"] 48 | 49 | 50 | @pytest.mark.order(3) 51 | def test_post1_detail(api_client): 52 | posts = api_client.get("/post/user/user1/").json() 53 | first_post = posts[0] 54 | first_post_id = first_post["id"] 55 | 56 | response = api_client.get(f"/post/{first_post_id}/") 57 | assert response.status_code == 200 58 | result = response.json() 59 | assert result["id"] == first_post_id 60 | assert result["user_id"] == first_post["user_id"] 61 | assert result["text"] == "hello test 1" 62 | assert result["parent_id"] is None 63 | replies = result["replies"] 64 | assert len(replies) == 2 65 | for reply in replies: 66 | assert reply["parent_id"] == first_post_id 67 | assert "reply from user" in reply["text"] 68 | 69 | 70 | @pytest.mark.order(3) 71 | def test_all_posts_from_user1(api_client): 72 | response = api_client.get("/post/user/user1/") 73 | assert response.status_code == 200 74 | results = response.json() 75 | assert len(results) == 2 76 | for result in results: 77 | assert result["parent_id"] is None 78 | assert "hello test" in result["text"] 79 | 80 | 81 | @pytest.mark.order(3) 82 | def test_all_posts_from_user1_with_replies(api_client): 83 | response = api_client.get( 84 | "/post/user/user1/", params={"include_replies": True} 85 | ) 86 | assert response.status_code == 200 87 | results = response.json() 88 | assert len(results) == 3 89 | --------------------------------------------------------------------------------