├── .env ├── .gitignore ├── Makefile ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 15770e820938_created_users_table.py │ ├── 1c7984990e1d_created_posts_table.py │ ├── 39256113e8e5_added_verification_code.py │ └── 4917da928a79_added_post_table.py ├── app ├── __init__.py ├── config.py ├── database.py ├── email.py ├── main.py ├── models.py ├── oauth2.py ├── routers │ ├── auth.py │ ├── post.py │ └── user.py ├── schemas.py ├── templates │ ├── _styles.html │ ├── base.html │ └── verification.html └── utils.py ├── docker-compose.yml ├── readMe.md └── requirements.txt /.env: -------------------------------------------------------------------------------- 1 | DATABASE_PORT=6500 2 | POSTGRES_PASSWORD=password123 3 | POSTGRES_USER=postgres 4 | POSTGRES_DB=fastapi 5 | POSTGRES_HOST=postgres 6 | POSTGRES_HOSTNAME=127.0.0.1 7 | 8 | ACCESS_TOKEN_EXPIRES_IN=15 9 | REFRESH_TOKEN_EXPIRES_IN=60 10 | JWT_ALGORITHM=RS256 11 | 12 | CLIENT_ORIGIN=http://localhost:3000 13 | 14 | VERIFICATION_SECRET=my-email-verification-secret 15 | 16 | EMAIL_HOST=smtp.mailtrap.io 17 | EMAIL_PORT=587 18 | EMAIL_USERNAME=af7917ee09173f 19 | EMAIL_PASSWORD=dbd2433183aa32 20 | EMAIL_FROM=admin@admin.com 21 | 22 | JWT_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBSSs3QnZUS0FWdHVQYzEzbEFkVk94TlVmcWxzMm1SVmlQWlJyVFpjd3l4RVhVRGpNaFZuCi9KVHRsd3h2a281T0pBQ1k3dVE0T09wODdiM3NOU3ZNd2xNQ0F3RUFBUUpBYm5LaENOQ0dOSFZGaHJPQ0RCU0IKdmZ2ckRWUzVpZXAwd2h2SGlBUEdjeWV6bjd0U2RweUZ0NEU0QTNXT3VQOXhqenNjTFZyb1pzRmVMUWlqT1JhUwp3UUloQU84MWl2b21iVGhjRkltTFZPbU16Vk52TGxWTW02WE5iS3B4bGh4TlpUTmhBaUVBbWRISlpGM3haWFE0Cm15QnNCeEhLQ3JqOTF6bVFxU0E4bHUvT1ZNTDNSak1DSVFEbDJxOUdtN0lMbS85b0EyaCtXdnZabGxZUlJPR3oKT21lV2lEclR5MUxaUVFJZ2ZGYUlaUWxMU0tkWjJvdXF4MHdwOWVEejBEWklLVzVWaSt6czdMZHRDdUVDSUVGYwo3d21VZ3pPblpzbnU1clBsTDJjZldLTGhFbWwrUVFzOCtkMFBGdXlnCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t 23 | JWT_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBSSs3QnZUS0FWdHVQYzEzbEFkVk94TlVmcWxzMm1SVgppUFpSclRaY3d5eEVYVURqTWhWbi9KVHRsd3h2a281T0pBQ1k3dVE0T09wODdiM3NOU3ZNd2xNQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ== -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv/ 3 | # .env -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | docker-compose up -d 3 | 4 | dev-down: 5 | docker-compose down 6 | 7 | push-migration: 8 | alembic upgrade head 9 | 10 | start-server: 11 | uvicorn app.main:app --reload 12 | 13 | install-modules: 14 | pip install fastapi[all] fastapi-mail==1.2.2 fastapi-jwt-auth[asymmetric] passlib[bcrypt] alembic SQLAlchemy psycopg2 -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 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 alembic/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:alembic/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 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | from app.config import settings 8 | from app.models import Base 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | config.set_main_option( 14 | "sqlalchemy.url", f"postgresql+psycopg2://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOSTNAME}:{settings.DATABASE_PORT}/{settings.POSTGRES_DB}") 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 = Base.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 = config.get_main_option("sqlalchemy.url") 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_from_config( 65 | config.get_section(config.config_ini_section), 66 | prefix="sqlalchemy.", 67 | poolclass=pool.NullPool, 68 | ) 69 | 70 | with connectable.connect() as connection: 71 | context.configure( 72 | connection=connection, target_metadata=target_metadata 73 | ) 74 | 75 | with context.begin_transaction(): 76 | context.run_migrations() 77 | 78 | 79 | if context.is_offline_mode(): 80 | run_migrations_offline() 81 | else: 82 | run_migrations_online() 83 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/15770e820938_created_users_table.py: -------------------------------------------------------------------------------- 1 | """created_users_table 2 | 3 | Revision ID: 15770e820938 4 | Revises: 5 | Create Date: 2022-07-06 15:11:26.439123 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '15770e820938' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), 23 | sa.Column('name', sa.String(), nullable=False), 24 | sa.Column('email', sa.String(), nullable=False), 25 | sa.Column('password', sa.String(), nullable=False), 26 | sa.Column('photo', sa.String(), nullable=True), 27 | sa.Column('verified', sa.Boolean(), server_default='False', nullable=False), 28 | sa.Column('role', sa.String(), server_default='user', nullable=False), 29 | sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 30 | sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 31 | sa.PrimaryKeyConstraint('id'), 32 | sa.UniqueConstraint('email') 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade() -> None: 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_table('users') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /alembic/versions/1c7984990e1d_created_posts_table.py: -------------------------------------------------------------------------------- 1 | """created_posts_table 2 | 3 | Revision ID: 1c7984990e1d 4 | Revises: 15770e820938 5 | Create Date: 2022-07-06 23:15:42.761079 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '1c7984990e1d' 14 | down_revision = '15770e820938' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('posts', 22 | sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), 23 | sa.Column('user_id', postgresql.UUID(), nullable=False), 24 | sa.Column('title', sa.String(), nullable=False), 25 | sa.Column('content', sa.String(), nullable=False), 26 | sa.Column('category', sa.String(), nullable=False), 27 | sa.Column('image', sa.String(), nullable=False), 28 | sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 29 | sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 30 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade() -> None: 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('posts') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /alembic/versions/39256113e8e5_added_verification_code.py: -------------------------------------------------------------------------------- 1 | """added verification code 2 | 3 | Revision ID: 39256113e8e5 4 | Revises: 1c7984990e1d 5 | Create Date: 2022-07-14 08:03:57.507140 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '39256113e8e5' 14 | down_revision = '1c7984990e1d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_table('posts') 22 | op.add_column('users', sa.Column('verification_code', sa.String(), nullable=True)) 23 | op.create_unique_constraint(None, 'users', ['verification_code']) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade() -> None: 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_constraint(None, 'users', type_='unique') 30 | op.drop_column('users', 'verification_code') 31 | op.create_table('posts', 32 | sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), autoincrement=False, nullable=False), 33 | sa.Column('user_id', postgresql.UUID(), autoincrement=False, nullable=False), 34 | sa.Column('title', sa.VARCHAR(), autoincrement=False, nullable=False), 35 | sa.Column('content', sa.VARCHAR(), autoincrement=False, nullable=False), 36 | sa.Column('category', sa.VARCHAR(), autoincrement=False, nullable=False), 37 | sa.Column('image', sa.VARCHAR(), autoincrement=False, nullable=False), 38 | sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), 39 | sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), 40 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='posts_user_id_fkey', ondelete='CASCADE'), 41 | sa.PrimaryKeyConstraint('id', name='posts_pkey') 42 | ) 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /alembic/versions/4917da928a79_added_post_table.py: -------------------------------------------------------------------------------- 1 | """added post table 2 | 3 | Revision ID: 4917da928a79 4 | Revises: 39256113e8e5 5 | Create Date: 2022-07-14 09:05:17.444518 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '4917da928a79' 14 | down_revision = '39256113e8e5' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('posts', 22 | sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), 23 | sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False), 24 | sa.Column('title', sa.String(), nullable=False), 25 | sa.Column('content', sa.String(), nullable=False), 26 | sa.Column('category', sa.String(), nullable=False), 27 | sa.Column('image', sa.String(), nullable=False), 28 | sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 29 | sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), 30 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade() -> None: 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('posts') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/python_fastapi/6fd0c0b5ed25fd83e4a97d84afb6f4f95d8a3390/app/__init__.py -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings, EmailStr 2 | 3 | 4 | class Settings(BaseSettings): 5 | DATABASE_PORT: int 6 | POSTGRES_PASSWORD: str 7 | POSTGRES_USER: str 8 | POSTGRES_DB: str 9 | POSTGRES_HOST: str 10 | POSTGRES_HOSTNAME: str 11 | 12 | JWT_PUBLIC_KEY: str 13 | JWT_PRIVATE_KEY: str 14 | REFRESH_TOKEN_EXPIRES_IN: int 15 | ACCESS_TOKEN_EXPIRES_IN: int 16 | JWT_ALGORITHM: str 17 | 18 | CLIENT_ORIGIN: str 19 | 20 | VERIFICATION_SECRET: str 21 | 22 | EMAIL_HOST: str 23 | EMAIL_PORT: int 24 | EMAIL_USERNAME: str 25 | EMAIL_PASSWORD: str 26 | EMAIL_FROM: EmailStr 27 | 28 | class Config: 29 | env_file = './.env' 30 | 31 | 32 | settings = Settings() 33 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from .config import settings 5 | 6 | SQLALCHEMY_DATABASE_URL = f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOSTNAME}:{settings.DATABASE_PORT}/{settings.POSTGRES_DB}" 7 | 8 | engine = create_engine( 9 | SQLALCHEMY_DATABASE_URL 10 | ) 11 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 12 | 13 | Base = declarative_base() 14 | 15 | 16 | def get_db(): 17 | db = SessionLocal() 18 | try: 19 | yield db 20 | finally: 21 | db.close() 22 | -------------------------------------------------------------------------------- /app/email.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from fastapi_mail import FastMail, MessageSchema, ConnectionConfig 3 | from pydantic import EmailStr, BaseModel 4 | from . import models 5 | from .config import settings 6 | from jinja2 import Environment, select_autoescape, PackageLoader 7 | 8 | 9 | env = Environment( 10 | loader=PackageLoader('app', 'templates'), 11 | autoescape=select_autoescape(['html', 'xml']) 12 | ) 13 | 14 | 15 | class EmailSchema(BaseModel): 16 | email: List[EmailStr] 17 | 18 | 19 | class Email: 20 | def __init__(self, user: models.User, url: str, email: List[EmailStr]): 21 | self.name = user.name 22 | self.sender = 'Codevo ' 23 | self.email = email 24 | self.url = url 25 | pass 26 | 27 | async def sendMail(self, subject, template): 28 | # Define the config 29 | conf = ConnectionConfig( 30 | MAIL_USERNAME=settings.EMAIL_USERNAME, 31 | MAIL_PASSWORD=settings.EMAIL_PASSWORD, 32 | MAIL_FROM=settings.EMAIL_FROM, 33 | MAIL_PORT=settings.EMAIL_PORT, 34 | MAIL_SERVER=settings.EMAIL_HOST, 35 | MAIL_STARTTLS=False, 36 | MAIL_SSL_TLS=False, 37 | USE_CREDENTIALS=True, 38 | VALIDATE_CERTS=True 39 | ) 40 | # Generate the HTML template base on the template name 41 | template = env.get_template(f'{template}.html') 42 | 43 | html = template.render( 44 | url=self.url, 45 | first_name=self.name, 46 | subject=subject 47 | ) 48 | 49 | # Define the message options 50 | message = MessageSchema( 51 | subject=subject, 52 | recipients=self.email, 53 | body=html, 54 | subtype="html" 55 | ) 56 | 57 | # Send the email 58 | fm = FastMail(conf) 59 | await fm.send_message(message) 60 | 61 | async def sendVerificationCode(self): 62 | await self.sendMail('Your verification code (Valid for 10min)', 'verification') 63 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from app.config import settings 4 | from app.routers import user, auth, post 5 | 6 | app = FastAPI() 7 | 8 | origins = [ 9 | settings.CLIENT_ORIGIN, 10 | ] 11 | 12 | app.add_middleware( 13 | CORSMiddleware, 14 | allow_origins=origins, 15 | allow_credentials=True, 16 | allow_methods=["*"], 17 | allow_headers=["*"], 18 | ) 19 | 20 | 21 | app.include_router(auth.router, tags=['Auth'], prefix='/api/auth') 22 | app.include_router(user.router, tags=['Users'], prefix='/api/users') 23 | app.include_router(post.router, tags=['Posts'], prefix='/api/posts') 24 | 25 | 26 | @app.get('/api/healthchecker') 27 | def root(): 28 | return {'message': 'Hello World'} 29 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .database import Base 3 | from sqlalchemy import TIMESTAMP, Column, ForeignKey, String, Boolean, text 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy.orm import relationship 6 | 7 | 8 | class User(Base): 9 | __tablename__ = 'users' 10 | id = Column(UUID(as_uuid=True), primary_key=True, nullable=False, 11 | default=uuid.uuid4) 12 | name = Column(String, nullable=False) 13 | email = Column(String, unique=True, nullable=False) 14 | password = Column(String, nullable=False) 15 | photo = Column(String, nullable=True) 16 | verified = Column(Boolean, nullable=False, server_default='False') 17 | verification_code = Column(String, nullable=True, unique=True) 18 | role = Column(String, server_default='user', nullable=False) 19 | created_at = Column(TIMESTAMP(timezone=True), 20 | nullable=False, server_default=text("now()")) 21 | updated_at = Column(TIMESTAMP(timezone=True), 22 | nullable=False, server_default=text("now()")) 23 | 24 | 25 | class Post(Base): 26 | __tablename__ = 'posts' 27 | id = Column(UUID(as_uuid=True), primary_key=True, nullable=False, 28 | default=uuid.uuid4) 29 | user_id = Column(UUID(as_uuid=True), ForeignKey( 30 | 'users.id', ondelete='CASCADE'), nullable=False) 31 | title = Column(String, nullable=False) 32 | content = Column(String, nullable=False) 33 | category = Column(String, nullable=False) 34 | image = Column(String, nullable=False) 35 | created_at = Column(TIMESTAMP(timezone=True), 36 | nullable=False, server_default=text("now()")) 37 | updated_at = Column(TIMESTAMP(timezone=True), 38 | nullable=False, server_default=text("now()")) 39 | user = relationship('User') 40 | -------------------------------------------------------------------------------- /app/oauth2.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from typing import List 3 | from fastapi import Depends, HTTPException, status 4 | from fastapi_jwt_auth import AuthJWT 5 | from pydantic import BaseModel 6 | 7 | from . import models 8 | from .database import get_db 9 | from sqlalchemy.orm import Session 10 | from .config import settings 11 | 12 | 13 | class Settings(BaseModel): 14 | authjwt_algorithm: str = settings.JWT_ALGORITHM 15 | authjwt_decode_algorithms: List[str] = [settings.JWT_ALGORITHM] 16 | authjwt_token_location: set = {'cookies', 'headers'} 17 | authjwt_access_cookie_key: str = 'access_token' 18 | authjwt_refresh_cookie_key: str = 'refresh_token' 19 | authjwt_cookie_csrf_protect: bool = False 20 | authjwt_public_key: str = base64.b64decode( 21 | settings.JWT_PUBLIC_KEY).decode('utf-8') 22 | authjwt_private_key: str = base64.b64decode( 23 | settings.JWT_PRIVATE_KEY).decode('utf-8') 24 | 25 | 26 | @AuthJWT.load_config 27 | def get_config(): 28 | return Settings() 29 | 30 | 31 | class NotVerified(Exception): 32 | pass 33 | 34 | 35 | class UserNotFound(Exception): 36 | pass 37 | 38 | 39 | def require_user(db: Session = Depends(get_db), Authorize: AuthJWT = Depends()): 40 | try: 41 | Authorize.jwt_required() 42 | user_id = Authorize.get_jwt_subject() 43 | user = db.query(models.User).filter(models.User.id == user_id).first() 44 | 45 | if not user: 46 | raise UserNotFound('User no longer exist') 47 | 48 | if not user.verified: 49 | raise NotVerified('You are not verified') 50 | 51 | except Exception as e: 52 | error = e.__class__.__name__ 53 | print(error) 54 | if error == 'MissingTokenError': 55 | raise HTTPException( 56 | status_code=status.HTTP_401_UNAUTHORIZED, detail='You are not logged in') 57 | if error == 'UserNotFound': 58 | raise HTTPException( 59 | status_code=status.HTTP_401_UNAUTHORIZED, detail='User no longer exist') 60 | if error == 'NotVerified': 61 | raise HTTPException( 62 | status_code=status.HTTP_401_UNAUTHORIZED, detail='Please verify your account') 63 | raise HTTPException( 64 | status_code=status.HTTP_401_UNAUTHORIZED, detail='Token is invalid or has expired') 65 | return user_id 66 | -------------------------------------------------------------------------------- /app/routers/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import hashlib 3 | from random import randbytes 4 | from fastapi import APIRouter, Request, Response, status, Depends, HTTPException 5 | from pydantic import EmailStr 6 | 7 | from app import oauth2 8 | from .. import schemas, models, utils 9 | from sqlalchemy.orm import Session 10 | from ..database import get_db 11 | from app.oauth2 import AuthJWT 12 | from ..config import settings 13 | from ..email import Email 14 | 15 | 16 | router = APIRouter() 17 | ACCESS_TOKEN_EXPIRES_IN = settings.ACCESS_TOKEN_EXPIRES_IN 18 | REFRESH_TOKEN_EXPIRES_IN = settings.REFRESH_TOKEN_EXPIRES_IN 19 | 20 | 21 | @router.post('/register', status_code=status.HTTP_201_CREATED) 22 | async def create_user(payload: schemas.CreateUserSchema, request: Request, db: Session = Depends(get_db)): 23 | # Check if user already exist 24 | user_query = db.query(models.User).filter( 25 | models.User.email == EmailStr(payload.email.lower())) 26 | user = user_query.first() 27 | if user: 28 | raise HTTPException(status_code=status.HTTP_409_CONFLICT, 29 | detail='Account already exist') 30 | # Compare password and passwordConfirm 31 | if payload.password != payload.passwordConfirm: 32 | raise HTTPException( 33 | status_code=status.HTTP_400_BAD_REQUEST, detail='Passwords do not match') 34 | # Hash the password 35 | payload.password = utils.hash_password(payload.password) 36 | del payload.passwordConfirm 37 | payload.role = 'user' 38 | payload.verified = False 39 | payload.email = EmailStr(payload.email.lower()) 40 | new_user = models.User(**payload.dict()) 41 | db.add(new_user) 42 | db.commit() 43 | db.refresh(new_user) 44 | 45 | try: 46 | # Send Verification Email 47 | token = randbytes(10) 48 | hashedCode = hashlib.sha256() 49 | hashedCode.update(token) 50 | verification_code = hashedCode.hexdigest() 51 | user_query.update( 52 | {'verification_code': verification_code}, synchronize_session=False) 53 | db.commit() 54 | url = f"{request.url.scheme}://{request.client.host}:{request.url.port}/api/auth/verifyemail/{token.hex()}" 55 | await Email(new_user, url, [payload.email]).sendVerificationCode() 56 | except Exception as error: 57 | print('Error', error) 58 | user_query.update( 59 | {'verification_code': None}, synchronize_session=False) 60 | db.commit() 61 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 62 | detail='There was an error sending email') 63 | return {'status': 'success', 'message': 'Verification token successfully sent to your email'} 64 | 65 | 66 | @router.post('/login') 67 | def login(payload: schemas.LoginUserSchema, response: Response, db: Session = Depends(get_db), Authorize: AuthJWT = Depends()): 68 | # Check if the user exist 69 | user = db.query(models.User).filter( 70 | models.User.email == EmailStr(payload.email.lower())).first() 71 | if not user: 72 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 73 | detail='Incorrect Email or Password') 74 | 75 | # Check if user verified his email 76 | if not user.verified: 77 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 78 | detail='Please verify your email address') 79 | 80 | # Check if the password is valid 81 | if not utils.verify_password(payload.password, user.password): 82 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 83 | detail='Incorrect Email or Password') 84 | 85 | # Create access token 86 | access_token = Authorize.create_access_token( 87 | subject=str(user.id), expires_time=timedelta(minutes=ACCESS_TOKEN_EXPIRES_IN)) 88 | 89 | # Create refresh token 90 | refresh_token = Authorize.create_refresh_token( 91 | subject=str(user.id), expires_time=timedelta(minutes=REFRESH_TOKEN_EXPIRES_IN)) 92 | 93 | # Store refresh and access tokens in cookie 94 | response.set_cookie('access_token', access_token, ACCESS_TOKEN_EXPIRES_IN * 60, 95 | ACCESS_TOKEN_EXPIRES_IN * 60, '/', None, False, True, 'lax') 96 | response.set_cookie('refresh_token', refresh_token, 97 | REFRESH_TOKEN_EXPIRES_IN * 60, REFRESH_TOKEN_EXPIRES_IN * 60, '/', None, False, True, 'lax') 98 | response.set_cookie('logged_in', 'True', ACCESS_TOKEN_EXPIRES_IN * 60, 99 | ACCESS_TOKEN_EXPIRES_IN * 60, '/', None, False, False, 'lax') 100 | 101 | # Send both access 102 | return {'status': 'success', 'access_token': access_token} 103 | 104 | 105 | @router.get('/refresh') 106 | def refresh_token(response: Response, request: Request, Authorize: AuthJWT = Depends(), db: Session = Depends(get_db)): 107 | try: 108 | Authorize.jwt_refresh_token_required() 109 | 110 | user_id = Authorize.get_jwt_subject() 111 | if not user_id: 112 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 113 | detail='Could not refresh access token') 114 | user = db.query(models.User).filter(models.User.id == user_id).first() 115 | if not user: 116 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 117 | detail='The user belonging to this token no logger exist') 118 | access_token = Authorize.create_access_token( 119 | subject=str(user.id), expires_time=timedelta(minutes=ACCESS_TOKEN_EXPIRES_IN)) 120 | except Exception as e: 121 | error = e.__class__.__name__ 122 | if error == 'MissingTokenError': 123 | raise HTTPException( 124 | status_code=status.HTTP_400_BAD_REQUEST, detail='Please provide refresh token') 125 | raise HTTPException( 126 | status_code=status.HTTP_400_BAD_REQUEST, detail=error) 127 | 128 | response.set_cookie('access_token', access_token, ACCESS_TOKEN_EXPIRES_IN * 60, 129 | ACCESS_TOKEN_EXPIRES_IN * 60, '/', None, False, True, 'lax') 130 | response.set_cookie('logged_in', 'True', ACCESS_TOKEN_EXPIRES_IN * 60, 131 | ACCESS_TOKEN_EXPIRES_IN * 60, '/', None, False, False, 'lax') 132 | return {'access_token': access_token} 133 | 134 | 135 | @router.get('/logout', status_code=status.HTTP_200_OK) 136 | def logout(response: Response, Authorize: AuthJWT = Depends(), user_id: str = Depends(oauth2.require_user)): 137 | Authorize.unset_jwt_cookies() 138 | response.set_cookie('logged_in', '', -1) 139 | 140 | return {'status': 'success'} 141 | 142 | 143 | @router.get('/verifyemail/{token}') 144 | def verify_me(token: str, db: Session = Depends(get_db)): 145 | hashedCode = hashlib.sha256() 146 | hashedCode.update(bytes.fromhex(token)) 147 | verification_code = hashedCode.hexdigest() 148 | user_query = db.query(models.User).filter( 149 | models.User.verification_code == verification_code) 150 | db.commit() 151 | user = user_query.first() 152 | if not user: 153 | raise HTTPException( 154 | status_code=status.HTTP_403_FORBIDDEN, detail='Email can only be verified once') 155 | user_query.update( 156 | {'verified': True, 'verification_code': None}, synchronize_session=False) 157 | db.commit() 158 | return { 159 | "status": "success", 160 | "message": "Account verified successfully" 161 | } 162 | -------------------------------------------------------------------------------- /app/routers/post.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from .. import schemas, models 3 | from sqlalchemy.orm import Session 4 | from fastapi import Depends, HTTPException, status, APIRouter, Response 5 | from ..database import get_db 6 | from app.oauth2 import require_user 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get('/', response_model=schemas.ListPostResponse) 12 | def get_posts(db: Session = Depends(get_db), limit: int = 10, page: int = 1, search: str = '', user_id: str = Depends(require_user)): 13 | skip = (page - 1) * limit 14 | 15 | posts = db.query(models.Post).group_by(models.Post.id).filter( 16 | models.Post.title.contains(search)).limit(limit).offset(skip).all() 17 | return {'status': 'success', 'results': len(posts), 'posts': posts} 18 | 19 | 20 | @router.post('/', status_code=status.HTTP_201_CREATED, response_model=schemas.PostResponse) 21 | def create_post(post: schemas.CreatePostSchema, db: Session = Depends(get_db), owner_id: str = Depends(require_user)): 22 | post.user_id = uuid.UUID(owner_id) 23 | new_post = models.Post(**post.dict()) 24 | db.add(new_post) 25 | db.commit() 26 | db.refresh(new_post) 27 | return new_post 28 | 29 | 30 | @router.put('/{id}', response_model=schemas.PostResponse) 31 | def update_post(id: str, post: schemas.UpdatePostSchema, db: Session = Depends(get_db), user_id: str = Depends(require_user)): 32 | post_query = db.query(models.Post).filter(models.Post.id == id) 33 | updated_post = post_query.first() 34 | 35 | if not updated_post: 36 | raise HTTPException(status_code=status.HTTP_200_OK, 37 | detail=f'No post with this id: {id} found') 38 | if updated_post.user_id != uuid.UUID(user_id): 39 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 40 | detail='You are not allowed to perform this action') 41 | post.user_id = user_id 42 | post_query.update(post.dict(exclude_unset=True), synchronize_session=False) 43 | db.commit() 44 | return updated_post 45 | 46 | 47 | @router.get('/{id}', response_model=schemas.PostResponse) 48 | def get_post(id: str, db: Session = Depends(get_db), user_id: str = Depends(require_user)): 49 | post = db.query(models.Post).filter(models.Post.id == id).first() 50 | if not post: 51 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 52 | detail=f"No post with this id: {id} found") 53 | return post 54 | 55 | 56 | @router.delete('/{id}') 57 | def delete_post(id: str, db: Session = Depends(get_db), user_id: str = Depends(require_user)): 58 | post_query = db.query(models.Post).filter(models.Post.id == id) 59 | post = post_query.first() 60 | if not post: 61 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 62 | detail=f'No post with this id: {id} found') 63 | 64 | if str(post.user_id) != user_id: 65 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 66 | detail='You are not allowed to perform this action') 67 | post_query.delete(synchronize_session=False) 68 | db.commit() 69 | return Response(status_code=status.HTTP_204_NO_CONTENT) 70 | -------------------------------------------------------------------------------- /app/routers/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from ..database import get_db 3 | from sqlalchemy.orm import Session 4 | from .. import models, schemas, oauth2 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.get('/me', response_model=schemas.UserResponse) 10 | def get_me(db: Session = Depends(get_db), user_id: str = Depends(oauth2.require_user)): 11 | user = db.query(models.User).filter(models.User.id == user_id).first() 12 | return user 13 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | import uuid 4 | from pydantic import BaseModel, EmailStr, constr 5 | 6 | 7 | class UserBaseSchema(BaseModel): 8 | name: str 9 | email: EmailStr 10 | photo: str 11 | 12 | class Config: 13 | orm_mode = True 14 | 15 | 16 | class CreateUserSchema(UserBaseSchema): 17 | password: constr(min_length=8) 18 | passwordConfirm: str 19 | role: str = 'user' 20 | verified: bool = False 21 | 22 | 23 | class LoginUserSchema(BaseModel): 24 | email: EmailStr 25 | password: constr(min_length=8) 26 | 27 | 28 | class UserResponse(UserBaseSchema): 29 | id: uuid.UUID 30 | created_at: datetime 31 | updated_at: datetime 32 | 33 | 34 | class FilteredUserResponse(UserBaseSchema): 35 | id: uuid.UUID 36 | 37 | 38 | class PostBaseSchema(BaseModel): 39 | title: str 40 | content: str 41 | category: str 42 | image: str 43 | user_id: uuid.UUID | None = None 44 | 45 | class Config: 46 | orm_mode = True 47 | 48 | 49 | class CreatePostSchema(PostBaseSchema): 50 | pass 51 | 52 | 53 | class PostResponse(PostBaseSchema): 54 | id: uuid.UUID 55 | user: FilteredUserResponse 56 | created_at: datetime 57 | updated_at: datetime 58 | 59 | 60 | class UpdatePostSchema(BaseModel): 61 | title: str | None = None 62 | content: str | None = None 63 | category: str | None = None 64 | image: str | None = None 65 | user_id: uuid.UUID | None = None 66 | 67 | class Config: 68 | orm_mode = True 69 | 70 | 71 | class ListPostResponse(BaseModel): 72 | status: str 73 | results: int 74 | posts: List[PostResponse] 75 | -------------------------------------------------------------------------------- /app/templates/_styles.html: -------------------------------------------------------------------------------- 1 | 330 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{subject}} 7 | {% include '_styles.html' %} 8 | 9 | 10 | 17 | 18 | 19 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/templates/verification.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block content %} 2 | 3 |

Hi {{first_name}},

4 |

5 | Thanks for creating an account with us. Please verify your email address by 6 | clicking the button below. 7 |

8 | 15 | 16 | 17 | 28 | 29 | 30 | 31 |

Good luck! Hope it works.

32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def hash_password(password: str): 7 | return pwd_context.hash(password) 8 | 9 | 10 | def verify_password(password: str, hashed_password: str): 11 | return pwd_context.verify(password, hashed_password) 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres 5 | container_name: postgres 6 | ports: 7 | - '6500:5432' 8 | restart: always 9 | env_file: 10 | - ./.env 11 | volumes: 12 | - postgres-db:/var/lib/postgresql/data 13 | volumes: 14 | postgres-db: 15 | -------------------------------------------------------------------------------- /readMe.md: -------------------------------------------------------------------------------- 1 | # RESTful API with Python, FastAPI, Pydantic, SQLAlchemy and Docker 2 | 3 | ## 1. RESTful API with Python,SQLAlchemy, & FastAPI: Access and Refresh Tokens 4 | 5 | In this article, you'll learn how to secure a FastAPI app by implementing access and refresh token functionalities using JSON Web Tokens (JWTs). We'll use the FastAPI JWT Auth package to sign, encode and decode the access and refresh JWT tokens. 6 | 7 | ![RESTful API with Python,SQLAlchemy, & FastAPI: Access and Refresh Tokens](https://codevoweb.com/wp-content/uploads/2022/07/RESTful-API-with-Python-FastAPI-Access-and-Refresh-Tokens.webp) 8 | 9 | ### Topics Covered 10 | 11 | - Python FastAPI JWT Authentication Overview 12 | - How to Setup FastAPI with PostgreSQL 13 | - Setup FastAPI 14 | - Initialize a Simple FastAPI Server 15 | - Setting up Environment Variables in FastAPI 16 | - Connect to the PostgreSQL Docker Container 17 | - Installing the UUID OSSP PostgreSQL Extension 18 | - Create Database Models with SQLAlchemy in FastAPI 19 | - Creating Schemas with Pydantic in FastAPI 20 | - Password Management with Bcrypt 21 | - Configure the FastAPI JWT Auth Extension 22 | - Creating the Authentication Controllers 23 | - User Registration Controller 24 | - User Sign-in Controller 25 | - Refresh Access Token Controller 26 | - Logout User Controller 27 | - How to add Protected Routes 28 | - Create a User Controller 29 | - Adding the Routes to FastAPI Middleware Pipeline 30 | - Database Migration with Alembic 31 | - Testing the FastAPI JSON Web Token API 32 | 33 | Read the entire article here: [https://codevoweb.com/restful-api-with-python-fastapi-access-and-refresh-tokens](https://codevoweb.com/restful-api-with-python-fastapi-access-and-refresh-tokens) 34 | 35 | ## 2. RESTful API with Python, SQLAlchemy, & FastAPI: Send HTML Emails 36 | 37 | In this article, you'll learn how to send HTML emails with Python, FastAPI, SQLAlchemy, PostgreSQL, Jinja2, and Docker-compose. Also, you'll learn how to dynamically generate HTML templates with the Jinja2 package. 38 | 39 | ![RESTful API with Python, SQLAlchemy, & FastAPI: Send HTML Emails](https://codevoweb.com/wp-content/uploads/2022/07/RESTful-API-with-Python-FastAPI-Send-HTML-Emails.webp) 40 | 41 | ### Topics Covered 42 | 43 | - Send HTML Email with jinja2 and FastAPI Overview 44 | - Creating an SMTP Provider Account 45 | - Validating the Environment Variables with Pydantic 46 | - Create a Database Model with Sqlalchemy 47 | - Creating the HTML Email Templates with Jinja2 48 | - Set up SMTP Email Sender 49 | - How to Send the HTML Email 50 | - Update the SignUp Path Operation Function 51 | - Create a Controller to Verify the Code 52 | 53 | Read the entire article here: [https://codevoweb.com/restful-api-with-python-fastapi-send-html-emails](https://codevoweb.com/restful-api-with-python-fastapi-send-html-emails) 54 | 55 | ## 3. CRUD RESTful API Server with Python, SQLAlchemy, FastAPI, and PostgreSQL 56 | 57 | This article will teach you how to create a CRUD RESTful API with Python, FastAPI, SQLAlchemy ORM, Pydantic, Alembic, PostgreSQL, and Docker-compose to perform the basic Create/Read/Update/Delete operations against a database. 58 | 59 | ![CRUD RESTful API Server with Python, SQLAlchemy, FastAPI, and PostgreSQL](https://codevoweb.com/wp-content/uploads/2022/07/CRUD-RESTful-API-Server-with-Python-FastAPI-and-PostgreSQL.webp) 60 | 61 | ### Topics Covered 62 | 63 | - Python, FastAPI, PostgreSQL, SQLAlchemy CRUD API Overview 64 | - Setting up FastAPI and PostgreSQL 65 | - Building the FastAPI Server 66 | - Starting the FastAPI Server 67 | - Setting up Environment Variables in FastAPI 68 | - Connecting to the PostgreSQL Server 69 | - Installing the UUID OSSP PostgreSQL Plugin 70 | - How to Create Database Models with SQLAlchemy 71 | - Creating Validation Schemas with Pydantic 72 | - Creating the FastAPI Route Handlers 73 | - Fetch All Posts Handler 74 | - Create New Post Handler 75 | - Update Post Handler 76 | - Get a Single Post Handler 77 | - Remove Post Handler 78 | - Add the Routes to the FastAPI Middleware Stack 79 | 80 | Read the entire article here: [https://codevoweb.com/crud-restful-api-server-with-python-fastapi-and-postgresql](https://codevoweb.com/crud-restful-api-server-with-python-fastapi-and-postgresql) 81 | 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosmtplib==1.1.7 2 | alembic==1.9.0 3 | anyio==3.6.2 4 | bcrypt==4.0.1 5 | blinker==1.5 6 | certifi==2022.12.7 7 | cffi==1.15.1 8 | click==8.1.3 9 | colorama==0.4.6 10 | cryptography==3.4.8 11 | dnspython==2.2.1 12 | email-validator==1.3.0 13 | fastapi==0.87.0 14 | fastapi-jwt-auth==0.5.0 15 | fastapi-mail==1.2.2 16 | greenlet==2.0.1 17 | h11==0.14.0 18 | httpcore==0.16.3 19 | httptools==0.5.0 20 | httpx==0.23.1 21 | idna==3.4 22 | itsdangerous==2.1.2 23 | Jinja2==3.1.2 24 | Mako==1.2.4 25 | MarkupSafe==2.1.1 26 | orjson==3.8.3 27 | passlib==1.7.4 28 | psycopg2==2.9.5 29 | pycparser==2.21 30 | pydantic==1.10.2 31 | PyJWT==1.7.1 32 | python-dotenv==0.21.0 33 | python-multipart==0.0.5 34 | PyYAML==6.0 35 | rfc3986==1.5.0 36 | six==1.16.0 37 | sniffio==1.3.0 38 | SQLAlchemy==1.4.45 39 | starlette==0.21.0 40 | typing_extensions==4.4.0 41 | ujson==5.6.0 42 | uvicorn==0.20.0 43 | watchfiles==0.18.1 44 | websockets==10.4 45 | --------------------------------------------------------------------------------