├── tests ├── __init__.py ├── test_profile.py ├── test_content_api.py ├── test_cli.py ├── test_app.py ├── conftest.py └── test_user_api.py ├── fastapi_broilerplate_saas ├── VERSION ├── models │ ├── __init__.py │ └── content.py ├── __init__.py ├── __main__.py ├── routes │ ├── profile.py │ ├── __init__.py │ ├── security.py │ ├── content.py │ └── user.py ├── default.toml ├── db.py ├── config.py ├── app.py ├── cli.py └── security.py ├── mkdocs.yml ├── docs ├── api.png └── index.md ├── postgres ├── Dockerfile └── create-databases.sh ├── MANIFEST.in ├── .github ├── release_message.sh ├── FUNDING.yml ├── rename_project.sh ├── workflows │ ├── release.yml │ ├── rename_project.yml │ └── main.yml └── init.sh ├── .coveragerc ├── requirements-test.txt ├── HISTORY.md ├── .secrets.toml ├── requirements.txt ├── settings.toml ├── docker-compose-dev.yaml ├── Dockerfile.dev ├── LICENSE ├── setup.py ├── .gitignore ├── CONTRIBUTING.md ├── Makefile ├── ABOUT_THIS_TEMPLATE.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/VERSION: -------------------------------------------------------------------------------- 1 | 0.1.0 2 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: fastapi_broilerplate_saas 2 | theme: readthedocs 3 | -------------------------------------------------------------------------------- /docs/api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/fastapi-broilerplate-saas/main/docs/api.png -------------------------------------------------------------------------------- /postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:alpine3.14 2 | COPY create-databases.sh /docker-entrypoint-initdb.d/ -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include HISTORY.md 3 | include Containerfile 4 | graft tests 5 | graft fastapi_broilerplate_saas 6 | -------------------------------------------------------------------------------- /.github/release_message.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | previous_tag=$(git tag --sort=-creatordate | sed -n 2p) 3 | git shortlog "${previous_tag}.." | sed 's/^./ &/' 4 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import app 2 | from .config import settings 3 | from .db import engine 4 | 5 | __all__ = ["app", "cli", "engine", "settings"] 6 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/__main__.py: -------------------------------------------------------------------------------- 1 | # pragma: no cover 2 | from .cli import cli 3 | 4 | main = cli 5 | 6 | if __name__ == "__main__": # pragma: no cover 7 | main() 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = fastapi_broilerplate_saas 3 | 4 | [report] 5 | omit = 6 | */python?.?/* 7 | */site-packages/nose/* 8 | fastapi_broilerplate_saas/__main__.py 9 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # This requirements are for development and testing only, not for production. 2 | pytest 3 | coverage 4 | flake8 5 | black 6 | isort 7 | pytest-cov 8 | codecov 9 | mypy 10 | gitchangelog 11 | mkdocs 12 | pytest-picked 13 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | 0.1.2 (2021-08-14) 6 | ------------------ 7 | - Fix release, README and windows CI. [Bruno Rocha] 8 | - Release: version 0.1.0. [Bruno Rocha] 9 | 10 | 11 | 0.1.0 (2021-08-14) 12 | ------------------ 13 | - Add release command. [Bruno Rocha] 14 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/routes/profile.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from ..security import AuthenticatedUser, User, UserResponse 4 | 5 | router = APIRouter() 6 | 7 | 8 | @router.get("/profile", response_model=UserResponse) 9 | async def my_profile(current_user: User = AuthenticatedUser): 10 | return current_user 11 | -------------------------------------------------------------------------------- /tests/test_profile.py: -------------------------------------------------------------------------------- 1 | def test_profile(api_client_authenticated): 2 | response = api_client_authenticated.get("/profile") 3 | assert response.status_code == 200 4 | result = response.json() 5 | assert "admin" in result["username"] 6 | 7 | 8 | def test_profile_no_auth(api_client): 9 | response = api_client.get("/profile") 10 | assert response.status_code == 401 11 | -------------------------------------------------------------------------------- /.secrets.toml: -------------------------------------------------------------------------------- 1 | [development] 2 | dynaconf_merge = true 3 | 4 | [development.security] 5 | # openssl rand -hex 32 6 | SECRET_KEY = "ONLYFORDEVELOPMENT" 7 | 8 | [production] 9 | dynaconf_merge = true 10 | 11 | [production.security] 12 | SECRET_KEY = "@vault path/to/vault/secret" 13 | 14 | [testing] 15 | dynaconf_merge = true 16 | 17 | [testing.security] 18 | SECRET_KEY = "ONLYFORTESTING" 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This template is a low-dependency template. 2 | # By default there is no requirements added here. 3 | # Add the requirements you need to this file. 4 | # or run `make init` to create this file automatically based on the template. 5 | # You can also run `make switch-to-poetry` to use the poetry package manager. 6 | fastapi 7 | uvicorn 8 | sqlmodel 9 | typer 10 | dynaconf 11 | jinja2 12 | python-jose[cryptography] 13 | passlib[bcrypt] 14 | python-multipart 15 | psycopg2-binary 16 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/default.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | 3 | [default.security] 4 | # Set secret key in .secrets.toml 5 | # SECRET_KEY = "" 6 | ALGORITHM = "HS256" 7 | ACCESS_TOKEN_EXPIRE_MINUTES = 30 8 | REFRESH_TOKEN_EXPIRE_MINUTES = 600 9 | 10 | [default.server] 11 | port = 8000 12 | host = "127.0.0.1" 13 | log_level = "info" 14 | reload = false 15 | 16 | [default.db] 17 | uri = "@jinja sqlite:///{{ this.current_env | lower }}.db" 18 | connect_args = {check_same_thread=false} 19 | echo = false 20 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/db.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from sqlmodel import Session, SQLModel, create_engine 3 | 4 | from .config import settings 5 | 6 | engine = create_engine( 7 | settings.db.uri, 8 | echo=settings.db.echo, 9 | connect_args=settings.db.connect_args, 10 | ) 11 | 12 | 13 | def create_db_and_tables(engine): 14 | SQLModel.metadata.create_all(engine) 15 | 16 | 17 | def get_session(): 18 | with Session(engine) as session: 19 | yield session 20 | 21 | 22 | ActiveSession = Depends(get_session) 23 | -------------------------------------------------------------------------------- /settings.toml: -------------------------------------------------------------------------------- 1 | [development] 2 | dynaconf_merge = true 3 | 4 | [development.db] 5 | echo = true 6 | 7 | [development.server] 8 | log_level = "debug" 9 | reload = true 10 | cors_origins = ["http://localhost:3000", "http://localhost:4200"] 11 | 12 | [production] 13 | dynaconf_merge = true 14 | 15 | [production.db] 16 | echo = false 17 | 18 | [production.server] 19 | log_level = "error" 20 | reload = false 21 | 22 | [testing] 23 | dynaconf_merge = true 24 | 25 | [testing.server] 26 | cors_origins = ["http://localhost:3000", "http://localhost:4200"] 27 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to MkDocs 2 | 3 | For full documentation visit [mkdocs.org](https://www.mkdocs.org). 4 | 5 | ## Commands 6 | 7 | * `mkdocs new [dir-name]` - Create a new project. 8 | * `mkdocs serve` - Start the live-reloading docs server. 9 | * `mkdocs build` - Build the documentation site. 10 | * `mkdocs -h` - Print help message and exit. 11 | 12 | ## Project layout 13 | 14 | mkdocs.yml # The configuration file. 15 | docs/ 16 | index.md # The documentation homepage. 17 | ... # Other markdown pages, images and other files. 18 | -------------------------------------------------------------------------------- /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 | 14 | EOSQL 15 | } 16 | 17 | if [ -n "$POSTGRES_DBS" ]; then 18 | echo "Creating DB(s): $POSTGRES_DBS" 19 | for db in $(echo $POSTGRES_DBS | tr ',' ' '); do 20 | create_user_and_database $db 21 | done 22 | echo "Multiple databases created" 23 | fi 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [rochacbruno] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .content import router as content_router 4 | from .profile import router as profile_router 5 | from .security import router as security_router 6 | from .user import router as user_router 7 | 8 | main_router = APIRouter() 9 | 10 | main_router.include_router(content_router, prefix="/content", tags=["content"]) 11 | main_router.include_router(profile_router, tags=["user"]) 12 | main_router.include_router(security_router, tags=["security"]) 13 | main_router.include_router(user_router, prefix="/user", tags=["user"]) 14 | 15 | 16 | @main_router.get("/") 17 | async def index(): 18 | return {"message": "Hello World!"} 19 | -------------------------------------------------------------------------------- /tests/test_content_api.py: -------------------------------------------------------------------------------- 1 | def test_content_create(api_client_authenticated): 2 | response = api_client_authenticated.post( 3 | "/content/", 4 | json={ 5 | "title": "hello test", 6 | "text": "this is just a test", 7 | "published": True, 8 | "tags": ["test", "hello"], 9 | }, 10 | ) 11 | assert response.status_code == 200 12 | result = response.json() 13 | assert result["slug"] == "hello-test" 14 | 15 | 16 | def test_content_list(api_client_authenticated): 17 | response = api_client_authenticated.get("/content/") 18 | assert response.status_code == 200 19 | result = response.json() 20 | assert result[0]["slug"] == "hello-test" 21 | -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | ports: 9 | - "8000:8000" 10 | environment: 11 | - fastapi_broilerplate_saas_DB__uri=postgresql://postgres:postgres@db:5432/fastapi_broilerplate_saas 12 | - fastapi_broilerplate_saas_DB__connect_args={} 13 | volumes: 14 | - .:/home/app/web 15 | depends_on: 16 | - db 17 | db: 18 | build: postgres 19 | image: fastapi_broilerplate_saas_postgres-13-alpine-multi-user 20 | volumes: 21 | - $HOME/.postgres/fastapi_broilerplate_saas_db/data/postgresql:/var/lib/postgresql/data 22 | ports: 23 | - 5435:5432 24 | environment: 25 | - POSTGRES_DBS=fastapi_broilerplate_saas, fastapi_broilerplate_saas_test 26 | - POSTGRES_USER=postgres 27 | - POSTGRES_PASSWORD=postgres 28 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | 2 | # Base Image for builder 3 | FROM python:3.9 as builder 4 | 5 | # Install Requirements 6 | COPY requirements.txt / 7 | RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt 8 | 9 | 10 | # Build the app image 11 | FROM python:3.9 12 | 13 | # Create directory for the app user 14 | RUN mkdir -p /home/app 15 | 16 | # Create the app user 17 | RUN groupadd app && useradd -g app app 18 | 19 | # Create the home directory 20 | ENV HOME=/home/app 21 | ENV APP_HOME=/home/app/web 22 | RUN mkdir $APP_HOME 23 | WORKDIR $APP_HOME 24 | 25 | # Install Requirements 26 | COPY --from=builder /wheels /wheels 27 | COPY --from=builder requirements.txt . 28 | RUN pip install --no-cache /wheels/* 29 | 30 | COPY . $APP_HOME 31 | 32 | RUN chown -R app:app $APP_HOME 33 | 34 | USER app 35 | 36 | CMD ["uvicorn", "fastapi_broilerplate_saas.app:app", "--host=0.0.0.0","--port=8000","--reload"] 37 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | given = pytest.mark.parametrize 4 | 5 | 6 | def test_help(cli_client, cli): 7 | result = cli_client.invoke(cli, ["--help"]) 8 | assert result.exit_code == 0 9 | assert "create-user" in result.stdout 10 | 11 | 12 | @given( 13 | "cmd,args,msg", 14 | [ 15 | ("run", ["--help"], "--port"), 16 | ("create-user", ["--help"], "create-user"), 17 | ], 18 | ) 19 | def test_cmds_help(cli_client, cli, cmd, args, msg): 20 | result = cli_client.invoke(cli, [cmd, *args]) 21 | assert result.exit_code == 0 22 | assert msg in result.stdout 23 | 24 | 25 | @given( 26 | "cmd,args,msg", 27 | [ 28 | ( 29 | "create-user", 30 | ["admin2", "admin2"], 31 | "created admin2 user", 32 | ), 33 | ], 34 | ) 35 | def test_cmds(cli_client, cli, cmd, args, msg): 36 | result = cli_client.invoke(cli, [cmd, *args]) 37 | assert result.exit_code == 0 38 | assert msg in result.stdout 39 | -------------------------------------------------------------------------------- /.github/rename_project.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | while getopts a:n:u:d: flag 3 | do 4 | case "${flag}" in 5 | a) author=${OPTARG};; 6 | n) name=${OPTARG};; 7 | u) urlname=${OPTARG};; 8 | d) description=${OPTARG};; 9 | esac 10 | done 11 | 12 | echo "Author: $author"; 13 | echo "Project Name: $name"; 14 | echo "Project URL name: $urlname"; 15 | echo "Description: $description"; 16 | 17 | echo "Renaming project..." 18 | 19 | original_author="todo" 20 | original_name="fastapi_broilerplate_saas" 21 | original_urlname="fastapi-broilerplate-saas" 22 | original_description="Awesome fastapi_broilerplate_saas created by todo" 23 | # for filename in $(find . -name "*.*") 24 | for filename in $(git ls-files) 25 | do 26 | sed -i "s/$original_author/$author/gI" $filename 27 | sed -i "s/$original_name/$name/gI" $filename 28 | sed -i "s/$original_urlname/$urlname/gI" $filename 29 | sed -i "s/$original_description/$description/gI" $filename 30 | echo "Renamed $filename" 31 | done 32 | 33 | mv fastapi_broilerplate_saas $name 34 | 35 | # This command runs only once on GHA! 36 | rm -rf .github/template.yml 37 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | def test_using_testing_db(settings): 2 | assert settings.db.uri == "sqlite:///testing.db" 3 | 4 | 5 | def test_index(api_client): 6 | response = api_client.get("/") 7 | assert response.status_code == 200 8 | result = response.json() 9 | assert result["message"] == "Hello World!" 10 | 11 | 12 | def test_cors_header(api_client): 13 | valid_origin = ["http://localhost:3000", "http://localhost:4200"] 14 | invalid_origin = ["http://localhost:3200", "http://localhost:4000"] 15 | 16 | valid_responses = [ 17 | api_client.options( 18 | "/", 19 | headers={ 20 | "Origin": f"{url}", 21 | }, 22 | ) 23 | for url in valid_origin 24 | ] 25 | 26 | for res, url in zip(valid_responses, valid_origin): 27 | assert res.headers.get("access-control-allow-origin") == url 28 | 29 | invalid_responses = [ 30 | api_client.options( 31 | "/", 32 | headers={ 33 | "Origin": f"{url}", 34 | }, 35 | ) 36 | for url in invalid_origin 37 | ] 38 | 39 | for res in invalid_responses: 40 | assert res.headers.get("access-control-allow-origin") is None 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | release: 14 | name: Create Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | # by default, it uses a depth of 1 20 | # this fetches all history so that we can read each commit 21 | fetch-depth: 0 22 | - name: Generate Changelog 23 | run: .github/release_message.sh > release_message.md 24 | - name: Release 25 | uses: softprops/action-gh-release@v1 26 | with: 27 | body_path: release_message.md 28 | 29 | deploy: 30 | needs: release 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v1 34 | - name: Set up Python 35 | uses: actions/setup-python@v1 36 | with: 37 | python-version: '3.x' 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install setuptools wheel twine 42 | - name: Build and publish 43 | env: 44 | TWINE_USERNAME: __token__ 45 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 46 | run: | 47 | python setup.py sdist bdist_wheel 48 | twine upload dist/* 49 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dynaconf import Dynaconf 4 | 5 | HERE = os.path.dirname(os.path.abspath(__file__)) 6 | 7 | settings = Dynaconf( 8 | envvar_prefix="fastapi_broilerplate_saas", 9 | preload=[os.path.join(HERE, "default.toml")], 10 | settings_files=["settings.toml", ".secrets.toml"], 11 | environments=["development", "production", "testing"], 12 | env_switcher="fastapi_broilerplate_saas_env", 13 | load_dotenv=False, 14 | ) 15 | 16 | 17 | """ 18 | # How to use this application settings 19 | 20 | ``` 21 | from fastapi_broilerplate_saas.config import settings 22 | ``` 23 | 24 | ## Acessing variables 25 | 26 | ``` 27 | settings.get("SECRET_KEY", default="sdnfjbnfsdf") 28 | settings["SECRET_KEY"] 29 | settings.SECRET_KEY 30 | settings.db.uri 31 | settings["db"]["uri"] 32 | settings["db.uri"] 33 | settings.DB__uri 34 | ``` 35 | 36 | ## Modifying variables 37 | 38 | ### On files 39 | 40 | settings.toml 41 | ``` 42 | [development] 43 | KEY=value 44 | ``` 45 | 46 | ### As environment variables 47 | ``` 48 | export fastapi_broilerplate_saas_KEY=value 49 | export fastapi_broilerplate_saas_KEY="@int 42" 50 | export fastapi_broilerplate_saas_KEY="@jinja {{ this.db.uri }}" 51 | export fastapi_broilerplate_saas_DB__uri="@jinja {{ this.db.uri | replace('db', 'data') }}" 52 | ``` 53 | 54 | ### Switching environments 55 | ``` 56 | fastapi_broilerplate_saas_ENV=production fastapi_broilerplate_saas run 57 | ``` 58 | 59 | Read more on https://dynaconf.com 60 | """ 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python setup.py for fastapi_broilerplate_saas package""" 2 | import io 3 | import os 4 | from setuptools import find_packages, setup 5 | 6 | 7 | def read(*paths, **kwargs): 8 | """Read the contents of a text file safely. 9 | >>> read("fastapi_broilerplate_saas", "VERSION") 10 | '0.1.0' 11 | >>> read("README.md") 12 | ... 13 | """ 14 | 15 | content = "" 16 | with io.open( 17 | os.path.join(os.path.dirname(__file__), *paths), 18 | encoding=kwargs.get("encoding", "utf8"), 19 | ) as open_file: 20 | content = open_file.read().strip() 21 | return content 22 | 23 | 24 | def read_requirements(path): 25 | return [ 26 | line.strip() 27 | for line in read(path).split("\n") 28 | if not line.startswith(('"', "#", "-", "git+")) 29 | ] 30 | 31 | 32 | setup( 33 | name="fastapi_broilerplate_saas", 34 | version=read("fastapi_broilerplate_saas", "VERSION"), 35 | description="Awesome fastapi_broilerplate_saas created by todo", 36 | url="https://github.com/todo/fastapi-broilerplate-saas/", 37 | long_description=read("README.md"), 38 | long_description_content_type="text/markdown", 39 | author="todo", 40 | packages=find_packages(exclude=["tests", ".github"]), 41 | install_requires=read_requirements("requirements.txt"), 42 | entry_points={ 43 | "console_scripts": ["fastapi_broilerplate_saas = fastapi_broilerplate_saas.__main__:main"] 44 | }, 45 | extras_require={"test": read_requirements("requirements-test.txt")}, 46 | ) 47 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/app.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from fastapi import FastAPI 5 | from starlette.middleware.cors import CORSMiddleware 6 | 7 | from .config import settings 8 | from .db import create_db_and_tables, engine 9 | from .routes import main_router 10 | 11 | 12 | def read(*paths, **kwargs): 13 | """Read the contents of a text file safely. 14 | >>> read("VERSION") 15 | """ 16 | content = "" 17 | with io.open( 18 | os.path.join(os.path.dirname(__file__), *paths), 19 | encoding=kwargs.get("encoding", "utf8"), 20 | ) as open_file: 21 | content = open_file.read().strip() 22 | return content 23 | 24 | 25 | description = """ 26 | fastapi_broilerplate_saas API helps you do awesome stuff. 🚀 27 | """ 28 | 29 | app = FastAPI( 30 | title="fastapi_broilerplate_saas", 31 | description=description, 32 | version=read("VERSION"), 33 | terms_of_service="http://fastapi_broilerplate_saas.com/terms/", 34 | contact={ 35 | "name": "todo", 36 | "url": "http://fastapi_broilerplate_saas.com/contact/", 37 | "email": "todo@fastapi_broilerplate_saas.com", 38 | }, 39 | license_info={ 40 | "name": "The Unlicense", 41 | "url": "https://unlicense.org", 42 | }, 43 | ) 44 | 45 | if settings.server and settings.server.get("cors_origins", None): 46 | app.add_middleware( 47 | CORSMiddleware, 48 | allow_origins=settings.server.cors_origins, 49 | allow_credentials=settings.get("server.cors_allow_credentials", True), 50 | allow_methods=settings.get("server.cors_allow_methods", ["*"]), 51 | allow_headers=settings.get("server.cors_allow_headers", ["*"]), 52 | ) 53 | 54 | app.include_router(main_router) 55 | 56 | 57 | @app.on_event("startup") 58 | def on_startup(): 59 | create_db_and_tables(engine) 60 | -------------------------------------------------------------------------------- /.github/workflows/rename_project.yml: -------------------------------------------------------------------------------- 1 | name: Rename the project from template 2 | 3 | on: [push] 4 | permissions: write-all 5 | jobs: 6 | rename-project: 7 | if: ${{ !contains(github.repository,'/fastapi-project-template') }} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | # by default, it uses a depth of 1 13 | # this fetches all history so that we can read each commit 14 | fetch-depth: 0 15 | ref: ${{ github.head_ref }} 16 | 17 | - run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}' | tr '-' '_' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 18 | shell: bash 19 | 20 | - run: echo "REPOSITORY_URLNAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV 21 | shell: bash 22 | 23 | - run: echo "REPOSITORY_OWNER=$(echo '${{ github.repository }}' | awk -F '/' '{print $1}')" >> $GITHUB_ENV 24 | shell: bash 25 | 26 | - name: Is this still a template 27 | id: is_template 28 | run: echo "::set-output name=is_template::$(ls .github/template.yml &> /dev/null && echo true || echo false)" 29 | 30 | - name: Rename the project 31 | if: steps.is_template.outputs.is_template == 'true' 32 | run: | 33 | echo "Renaming the project with -a(author) ${{ env.REPOSITORY_OWNER }} -n(name) ${{ env.REPOSITORY_NAME }} -u(urlname) ${{ env.REPOSITORY_URLNAME }}" 34 | .github/rename_project.sh -a ${{ env.REPOSITORY_OWNER }} -n ${{ env.REPOSITORY_NAME }} -u ${{ env.REPOSITORY_URLNAME }} -d "Awesome ${{ env.REPOSITORY_NAME }} created by ${{ env.REPOSITORY_OWNER }}" 35 | 36 | - uses: stefanzweifel/git-auto-commit-action@v4 37 | with: 38 | commit_message: "✅ Ready to clone and code." 39 | # commit_options: '--amend --no-edit' 40 | push_options: --force 41 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/cli.py: -------------------------------------------------------------------------------- 1 | import typer 2 | import uvicorn 3 | from sqlmodel import Session, select 4 | 5 | from .app import app 6 | from .config import settings 7 | from .db import create_db_and_tables, engine 8 | from .models.content import Content 9 | from .security import User 10 | 11 | cli = typer.Typer(name="fastapi_broilerplate_saas API") 12 | 13 | 14 | @cli.command() 15 | def run( 16 | port: int = settings.server.port, 17 | host: str = settings.server.host, 18 | log_level: str = settings.server.log_level, 19 | reload: bool = settings.server.reload, 20 | ): # pragma: no cover 21 | """Run the API server.""" 22 | uvicorn.run( 23 | "fastapi_broilerplate_saas.app:app", 24 | host=host, 25 | port=port, 26 | log_level=log_level, 27 | reload=reload, 28 | ) 29 | 30 | 31 | @cli.command() 32 | def create_user(username: str, password: str, superuser: bool = False): 33 | """Create user""" 34 | create_db_and_tables(engine) 35 | with Session(engine) as session: 36 | user = User(username=username, password=password, superuser=superuser) 37 | session.add(user) 38 | session.commit() 39 | session.refresh(user) 40 | typer.echo(f"created {username} user") 41 | return user 42 | 43 | 44 | @cli.command() 45 | def shell(): # pragma: no cover 46 | """Opens an interactive shell with objects auto imported""" 47 | _vars = { 48 | "app": app, 49 | "settings": settings, 50 | "User": User, 51 | "engine": engine, 52 | "cli": cli, 53 | "create_user": create_user, 54 | "select": select, 55 | "session": Session(engine), 56 | "Content": Content, 57 | } 58 | typer.echo(f"Auto imports: {list(_vars.keys())}") 59 | try: 60 | from IPython import start_ipython 61 | 62 | start_ipython(argv=[], user_ns=_vars) 63 | except ImportError: 64 | import code 65 | 66 | code.InteractiveConsole(_vars).interact() 67 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from typer.testing import CliRunner 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | # This next line ensures tests uses its own database and settings environment 9 | os.environ["FORCE_ENV_FOR_DYNACONF"] = "testing" # noqa 10 | # WARNING: Ensure imports from `fastapi_broilerplate_saas` comes after this line 11 | from fastapi_broilerplate_saas import app, settings, db # noqa 12 | from fastapi_broilerplate_saas.cli import create_user, cli # noqa 13 | 14 | 15 | # each test runs on cwd to its temp dir 16 | @pytest.fixture(autouse=True) 17 | def go_to_tmpdir(request): 18 | # Get the fixture dynamically by its name. 19 | tmpdir = request.getfixturevalue("tmpdir") 20 | # ensure local test created packages can be imported 21 | sys.path.insert(0, str(tmpdir)) 22 | # Chdir only for the duration of the test. 23 | with tmpdir.as_cwd(): 24 | yield 25 | 26 | 27 | @pytest.fixture(scope="function", name="app") 28 | def _app(): 29 | return app 30 | 31 | 32 | @pytest.fixture(scope="function", name="cli") 33 | def _cli(): 34 | return cli 35 | 36 | 37 | @pytest.fixture(scope="function", name="settings") 38 | def _settings(): 39 | return settings 40 | 41 | 42 | @pytest.fixture(scope="function") 43 | def api_client(): 44 | return TestClient(app) 45 | 46 | 47 | @pytest.fixture(scope="function") 48 | def api_client_authenticated(): 49 | 50 | try: 51 | create_user("admin", "admin", superuser=True) 52 | except IntegrityError: 53 | pass 54 | 55 | client = TestClient(app) 56 | token = client.post( 57 | "/token", 58 | data={"username": "admin", "password": "admin"}, 59 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 60 | ).json()["access_token"] 61 | client.headers["Authorization"] = f"Bearer {token}" 62 | return client 63 | 64 | 65 | @pytest.fixture(scope="function") 66 | def cli_client(): 67 | return CliRunner() 68 | 69 | 70 | def remove_db(): 71 | # Remove the database file 72 | try: 73 | os.remove("testing.db") 74 | except FileNotFoundError: 75 | pass 76 | 77 | 78 | @pytest.fixture(scope="session", autouse=True) 79 | def initialize_db(request): 80 | db.create_db_and_tables(db.engine) 81 | request.addfinalizer(remove_db) 82 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/models/content.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TYPE_CHECKING, List, Optional, Union 3 | 4 | from pydantic import BaseModel, Extra 5 | from sqlmodel import Field, Relationship, SQLModel 6 | 7 | if TYPE_CHECKING: 8 | from fastapi_broilerplate_saas.security import User 9 | 10 | 11 | class Content(SQLModel, table=True): 12 | """This is an example model for your application. 13 | 14 | Replace with the *things* you do in your application. 15 | """ 16 | 17 | id: Optional[int] = Field(default=None, primary_key=True) 18 | title: str 19 | slug: str = Field(default=None) 20 | text: str 21 | published: bool = False 22 | created_time: str = Field( 23 | default_factory=lambda: datetime.now().isoformat() 24 | ) 25 | tags: str = Field(default="") 26 | user_id: Optional[int] = Field(foreign_key="user.id") 27 | 28 | # It populates a `.contents` attribute to the `User` model. 29 | user: Optional["User"] = Relationship(back_populates="contents") 30 | 31 | 32 | class ContentResponse(BaseModel): 33 | """This the serializer exposed on the API""" 34 | 35 | id: int 36 | title: str 37 | slug: str 38 | text: str 39 | published: bool 40 | created_time: str 41 | tags: List[str] 42 | user_id: int 43 | 44 | def __init__(self, *args, **kwargs): 45 | # tags to model representation 46 | tags = kwargs.pop("tags", None) 47 | if tags and isinstance(tags, str): 48 | kwargs["tags"] = tags.split(",") 49 | super().__init__(*args, **kwargs) 50 | 51 | 52 | class ContentIncoming(BaseModel): 53 | """This is the serializer used for POST/PATCH requests""" 54 | 55 | title: Optional[str] 56 | text: Optional[str] 57 | published: Optional[bool] = False 58 | tags: Optional[Union[List[str], str]] 59 | 60 | class Config: 61 | extra = Extra.allow 62 | arbitrary_types_allowed = True 63 | 64 | def __init__(self, *args, **kwargs): 65 | # tags to database representation 66 | tags = kwargs.pop("tags", None) 67 | if tags and isinstance(tags, list): 68 | kwargs["tags"] = ",".join(tags) 69 | super().__init__(*args, **kwargs) 70 | self.generate_slug() 71 | 72 | def generate_slug(self): 73 | """Generate a slug from the title.""" 74 | if self.title: 75 | self.slug = self.title.lower().replace(" ", "-") 76 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/routes/security.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordRequestForm 5 | 6 | from ..config import settings 7 | from ..security import ( 8 | RefreshToken, 9 | Token, 10 | User, 11 | authenticate_user, 12 | create_access_token, 13 | create_refresh_token, 14 | get_user, 15 | validate_token, 16 | ) 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 | -------------------------------------------------------------------------------- /.github/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | overwrite_template_dir=0 3 | 4 | while getopts t:o flag 5 | do 6 | case "${flag}" in 7 | t) template=${OPTARG};; 8 | o) overwrite_template_dir=1;; 9 | esac 10 | done 11 | 12 | if [ -z "${template}" ]; then 13 | echo "Available templates: flask" 14 | read -p "Enter template name: " template 15 | fi 16 | 17 | repo_urlname=$(basename -s .git `git config --get remote.origin.url`) 18 | repo_name=$(basename -s .git `git config --get remote.origin.url` | tr '-' '_' | tr '[:upper:]' '[:lower:]') 19 | repo_owner=$(git config --get remote.origin.url | awk -F ':' '{print $2}' | awk -F '/' '{print $1}') 20 | echo "Repo name: ${repo_name}" 21 | echo "Repo owner: ${repo_owner}" 22 | echo "Repo urlname: ${repo_urlname}" 23 | 24 | if [ -f ".github/workflows/rename_project.yml" ]; then 25 | .github/rename_project.sh -a "${repo_owner}" -n "${repo_name}" -u "${repo_urlname}" -d "Awesome ${repo_name} created by ${repo_owner}" 26 | fi 27 | 28 | function download_template { 29 | rm -rf "${template_dir}" 30 | mkdir -p .github/templates 31 | git clone "${template_url}" "${template_dir}" 32 | } 33 | 34 | echo "Using template:${template}" 35 | template_url="https://github.com/rochacbruno/${template}-project-template" 36 | template_dir=".github/templates/${template}" 37 | if [ -d "${template_dir}" ]; then 38 | # Template directory already exists 39 | if [ "${overwrite_template_dir}" -eq 1 ]; then 40 | # user passed -o flag, delete and re-download 41 | echo "Overwriting ${template_dir}" 42 | download_template 43 | else 44 | # Ask user if they want to overwrite 45 | echo "Directory ${template_dir} already exists." 46 | read -p "Do you want to overwrite it? [y/N] " -n 1 -r 47 | echo 48 | if [[ $REPLY =~ ^[Yy]$ ]]; then 49 | echo "Overwriting ${template_dir}" 50 | download_template 51 | else 52 | # User decided not to overwrite 53 | echo "Using existing ${template_dir}" 54 | fi 55 | fi 56 | else 57 | # Template directory does not exist, download it 58 | echo "Downloading ${template_url}" 59 | download_template 60 | fi 61 | 62 | echo "Applying ${template} template to this project"} 63 | ./.github/templates/${template}/apply.sh -a "${repo_owner}" -n "${repo_name}" -u "${repo_urlname}" -d "Awesome ${repo_name} created by ${repo_owner}" 64 | 65 | # echo "Removing temporary template files" 66 | # rm -rf .github/templates/${template} 67 | 68 | echo "Done! review, commit and push the changes" 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # db files 59 | *.db 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # templates 135 | .github/templates/* 136 | .idea/ 137 | 138 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | jobs: 17 | linter: 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python-version: [3.9] 22 | os: [ubuntu-latest] 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install project 30 | run: make install 31 | - name: Run linter 32 | run: make lint 33 | 34 | tests_linux: 35 | needs: linter 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | python-version: [3.9] 40 | os: [ubuntu-latest] 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-python@v2 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install project 48 | run: make install 49 | - name: Run tests 50 | run: make test 51 | - name: "Upload coverage to Codecov" 52 | uses: codecov/codecov-action@v1 53 | # with: 54 | # fail_ci_if_error: true 55 | 56 | tests_mac: 57 | needs: linter 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | python-version: [3.9] 62 | os: [macos-latest] 63 | runs-on: ${{ matrix.os }} 64 | steps: 65 | - uses: actions/checkout@v2 66 | - uses: actions/setup-python@v2 67 | with: 68 | python-version: ${{ matrix.python-version }} 69 | - name: Install project 70 | run: make install 71 | - name: Run tests 72 | run: make test 73 | 74 | tests_win: 75 | needs: linter 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | python-version: [3.9] 80 | os: [windows-latest] 81 | runs-on: ${{ matrix.os }} 82 | steps: 83 | - uses: actions/checkout@v2 84 | - uses: actions/setup-python@v2 85 | with: 86 | python-version: ${{ matrix.python-version }} 87 | - name: Install Pip 88 | run: pip install --user --upgrade pip 89 | - name: Install project 90 | run: pip install -e .[test] 91 | - name: run tests 92 | run: pytest -s -vvvv -l --tb=long tests 93 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/routes/content.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from fastapi import APIRouter, Request 4 | from fastapi.exceptions import HTTPException 5 | from sqlmodel import Session, or_, select 6 | 7 | from ..db import ActiveSession 8 | from ..models.content import Content, ContentIncoming, ContentResponse 9 | from ..security import AuthenticatedUser, User, get_current_user 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/", response_model=List[ContentResponse]) 15 | async def list_contents(*, session: Session = ActiveSession): 16 | contents = session.exec(select(Content)).all() 17 | return contents 18 | 19 | 20 | @router.get("/{id_or_slug}/", response_model=ContentResponse) 21 | async def query_content( 22 | *, id_or_slug: Union[str, int], session: Session = ActiveSession 23 | ): 24 | content = session.query(Content).where( 25 | or_( 26 | Content.id == id_or_slug, 27 | Content.slug == id_or_slug, 28 | ) 29 | ) 30 | if not content: 31 | raise HTTPException(status_code=404, detail="Content not found") 32 | return content.first() 33 | 34 | 35 | @router.post( 36 | "/", response_model=ContentResponse, dependencies=[AuthenticatedUser] 37 | ) 38 | async def create_content( 39 | *, 40 | session: Session = ActiveSession, 41 | request: Request, 42 | content: ContentIncoming, 43 | ): 44 | # set the ownsership of the content to the current user 45 | db_content = Content.from_orm(content) 46 | user: User = get_current_user(request=request) 47 | db_content.user_id = user.id 48 | session.add(db_content) 49 | session.commit() 50 | session.refresh(db_content) 51 | return db_content 52 | 53 | 54 | @router.patch( 55 | "/{content_id}/", 56 | response_model=ContentResponse, 57 | dependencies=[AuthenticatedUser], 58 | ) 59 | async def update_content( 60 | *, 61 | content_id: int, 62 | session: Session = ActiveSession, 63 | request: Request, 64 | patch: ContentIncoming, 65 | ): 66 | # Query the content 67 | content = session.get(Content, content_id) 68 | if not content: 69 | raise HTTPException(status_code=404, detail="Content not found") 70 | 71 | # Check the user owns the content 72 | current_user: User = get_current_user(request=request) 73 | if content.user_id != current_user.id and not current_user.superuser: 74 | raise HTTPException( 75 | status_code=403, detail="You don't own this content" 76 | ) 77 | 78 | # Update the content 79 | patch_data = patch.dict(exclude_unset=True) 80 | for key, value in patch_data.items(): 81 | setattr(content, key, value) 82 | 83 | # Commit the session 84 | session.commit() 85 | session.refresh(content) 86 | return content 87 | 88 | 89 | @router.delete("/{content_id}/", dependencies=[AuthenticatedUser]) 90 | def delete_content( 91 | *, session: Session = ActiveSession, request: Request, content_id: int 92 | ): 93 | 94 | content = session.get(Content, content_id) 95 | if not content: 96 | raise HTTPException(status_code=404, detail="Content not found") 97 | # Check the user owns the content 98 | current_user = get_current_user(request=request) 99 | if content.user_id != current_user.id and not current_user.superuser: 100 | raise HTTPException( 101 | status_code=403, detail="You don't own this content" 102 | ) 103 | session.delete(content) 104 | session.commit() 105 | return {"ok": True} 106 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/routes/user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from fastapi import APIRouter, Request 4 | from fastapi.exceptions import HTTPException 5 | from sqlmodel import Session, or_, select 6 | 7 | from ..db import ActiveSession 8 | from ..security import ( 9 | AdminUser, 10 | AuthenticatedFreshUser, 11 | AuthenticatedUser, 12 | User, 13 | UserCreate, 14 | UserPasswordPatch, 15 | UserResponse, 16 | get_current_user, 17 | get_password_hash, 18 | ) 19 | 20 | router = APIRouter() 21 | 22 | 23 | @router.get("/", response_model=List[UserResponse], dependencies=[AdminUser]) 24 | async def list_users(*, session: Session = ActiveSession): 25 | users = session.exec(select(User)).all() 26 | return users 27 | 28 | 29 | @router.post("/", response_model=UserResponse, dependencies=[AdminUser]) 30 | async def create_user(*, session: Session = ActiveSession, user: UserCreate): 31 | 32 | # verify user with username doesn't already exist 33 | try: 34 | await query_user(session=session, user_id_or_username=user.username) 35 | except HTTPException: 36 | pass 37 | else: 38 | raise HTTPException(status_code=422, detail="Username already exists") 39 | 40 | db_user = User.from_orm(user) 41 | session.add(db_user) 42 | session.commit() 43 | session.refresh(db_user) 44 | return db_user 45 | 46 | 47 | @router.patch( 48 | "/{user_id}/password/", 49 | response_model=UserResponse, 50 | dependencies=[AuthenticatedFreshUser], 51 | ) 52 | async def update_user_password( 53 | *, 54 | user_id: int, 55 | session: Session = ActiveSession, 56 | request: Request, 57 | patch: UserPasswordPatch, 58 | ): 59 | # Query the content 60 | user = session.get(User, user_id) 61 | if not user: 62 | raise HTTPException(status_code=404, detail="User not found") 63 | 64 | # Check the user can update the password 65 | current_user: User = get_current_user(request=request) 66 | if user.id != current_user.id and not current_user.superuser: 67 | raise HTTPException( 68 | status_code=403, detail="You can't update this user password" 69 | ) 70 | 71 | if not patch.password == patch.password_confirm: 72 | raise HTTPException(status_code=400, detail="Passwords don't match") 73 | 74 | # Update the password 75 | user.password = get_password_hash(patch.password) 76 | 77 | # Commit the session 78 | session.commit() 79 | session.refresh(user) 80 | return user 81 | 82 | 83 | @router.get( 84 | "/{user_id_or_username}/", 85 | response_model=UserResponse, 86 | dependencies=[AuthenticatedUser], 87 | ) 88 | async def query_user( 89 | *, session: Session = ActiveSession, user_id_or_username: Union[str, int] 90 | ): 91 | user = session.query(User).where( 92 | or_( 93 | User.id == user_id_or_username, 94 | User.username == user_id_or_username, 95 | ) 96 | ) 97 | 98 | if not user.first(): 99 | raise HTTPException(status_code=404, detail="User not found") 100 | return user.first() 101 | 102 | 103 | @router.delete("/{user_id}/", dependencies=[AdminUser]) 104 | def delete_user( 105 | *, session: Session = ActiveSession, request: Request, user_id: int 106 | ): 107 | user = session.get(User, user_id) 108 | if not user: 109 | raise HTTPException(status_code=404, detail="Content not found") 110 | # Check the user is not deleting himself 111 | current_user = get_current_user(request=request) 112 | if user.id == current_user.id: 113 | raise HTTPException( 114 | status_code=403, detail="You can't delete yourself" 115 | ) 116 | session.delete(user) 117 | session.commit() 118 | return {"ok": True} 119 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to develop on this project 2 | 3 | fastapi_broilerplate_saas welcomes contributions from the community. 4 | 5 | **You need PYTHON3!** 6 | 7 | This instructions are for linux base systems. (Linux, MacOS, BSD, etc.) 8 | ## Setting up your own fork of this repo. 9 | 10 | - On github interface click on `Fork` button. 11 | - Clone your fork of this repo. `git clone git@github.com:YOUR_GIT_USERNAME/fastapi-broilerplate-saas.git` 12 | - Enter the directory `cd fastapi-broilerplate-saas` 13 | - Add upstream repo `git remote add upstream https://github.com/todo/fastapi-broilerplate-saas` 14 | 15 | ## Setting up your own virtual environment 16 | 17 | Run `make virtualenv` to create a virtual environment. 18 | then activate it with `source .venv/bin/activate`. 19 | 20 | ## Install the project in develop mode 21 | 22 | Run `make install` to install the project in develop mode. 23 | 24 | ## Run the tests to ensure everything is working 25 | 26 | Run `make test` to run the tests. 27 | 28 | ## Create a new branch to work on your contribution 29 | 30 | Run `git checkout -b my_contribution` 31 | 32 | ## Make your changes 33 | 34 | Edit the files using your preferred editor. (we recommend VIM or VSCode) 35 | 36 | ## Format the code 37 | 38 | Run `make fmt` to format the code. 39 | 40 | ## Run the linter 41 | 42 | Run `make lint` to run the linter. 43 | 44 | ## Test your changes 45 | 46 | Run `make test` to run the tests. 47 | 48 | Ensure code coverage report shows `100%` coverage, add tests to your PR. 49 | 50 | ## Build the docs locally 51 | 52 | Run `make docs` to build the docs. 53 | 54 | Ensure your new changes are documented. 55 | 56 | ## Commit your changes 57 | 58 | This project uses [conventional git commit messages](https://www.conventionalcommits.org/en/v1.0.0/). 59 | 60 | Example: `fix(package): update setup.py arguments 🎉` (emojis are fine too) 61 | 62 | ## Push your changes to your fork 63 | 64 | Run `git push origin my_contribution` 65 | 66 | ## Submit a pull request 67 | 68 | On github interface, click on `Pull Request` button. 69 | 70 | Wait CI to run and one of the developers will review your PR. 71 | ## Makefile utilities 72 | 73 | This project comes with a `Makefile` that contains a number of useful utility. 74 | 75 | ```bash 76 | ❯ make 77 | Usage: make 78 | 79 | Targets: 80 | help: ## Show the help. 81 | install: ## Install the project in dev mode. 82 | fmt: ## Format code using black & isort. 83 | lint: ## Run pep8, black, mypy linters. 84 | test: lint ## Run tests and generate coverage report. 85 | watch: ## Run tests on every change. 86 | clean: ## Clean unused files. 87 | virtualenv: ## Create a virtual environment. 88 | release: ## Create a new tag for release. 89 | docs: ## Build the documentation. 90 | switch-to-poetry: ## Switch to poetry package manager. 91 | init: ## Initialize the project based on an application template. 92 | ``` 93 | 94 | ## Making a new release 95 | 96 | This project uses [semantic versioning](https://semver.org/) and tags releases with `X.Y.Z` 97 | Every time a new tag is created and pushed to the remote repo, github actions will 98 | automatically create a new release on github and trigger a release on PyPI. 99 | 100 | For this to work you need to setup a secret called `PIPY_API_TOKEN` on the project settings>secrets, 101 | this token can be generated on [pypi.org](https://pypi.org/account/). 102 | 103 | To trigger a new release all you need to do is. 104 | 105 | 1. If you have changes to add to the repo 106 | * Make your changes following the steps described above. 107 | * Commit your changes following the [conventional git commit messages](https://www.conventionalcommits.org/en/v1.0.0/). 108 | 2. Run the tests to ensure everything is working. 109 | 4. Run `make release` to create a new tag and push it to the remote repo. 110 | 111 | the `make release` will ask you the version number to create the tag, ex: type `0.1.1` when you are asked. 112 | 113 | > **CAUTION**: The make release will change local changelog files and commit all the unstaged changes you have. 114 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | ENV_PREFIX=$(shell python -c "if __import__('pathlib').Path('.venv/bin/pip').exists(): print('.venv/bin/')") 3 | USING_POETRY=$(shell grep "tool.poetry" pyproject.toml && echo "yes") 4 | 5 | .PHONY: help 6 | help: ## Show the help. 7 | @echo "Usage: make " 8 | @echo "" 9 | @echo "Targets:" 10 | @fgrep "##" Makefile | fgrep -v fgrep 11 | 12 | 13 | .PHONY: show 14 | show: ## Show the current environment. 15 | @echo "Current environment:" 16 | @if [ "$(USING_POETRY)" ]; then poetry env info && exit; fi 17 | @echo "Running using $(ENV_PREFIX)" 18 | @$(ENV_PREFIX)python -V 19 | @$(ENV_PREFIX)python -m site 20 | 21 | .PHONY: install 22 | install: ## Install the project in dev mode. 23 | @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi 24 | @echo "Don't forget to run 'make virtualenv' if you got errors." 25 | $(ENV_PREFIX)pip install -e .[test] 26 | 27 | .PHONY: fmt 28 | fmt: ## Format code using black & isort. 29 | $(ENV_PREFIX)isort fastapi_broilerplate_saas/ 30 | $(ENV_PREFIX)black -l 79 fastapi_broilerplate_saas/ 31 | $(ENV_PREFIX)black -l 79 tests/ 32 | 33 | .PHONY: lint 34 | lint: ## Run pep8, black, mypy linters. 35 | $(ENV_PREFIX)flake8 fastapi_broilerplate_saas/ 36 | $(ENV_PREFIX)black -l 79 --check fastapi_broilerplate_saas/ 37 | $(ENV_PREFIX)black -l 79 --check tests/ 38 | $(ENV_PREFIX)mypy --ignore-missing-imports fastapi_broilerplate_saas/ 39 | 40 | .PHONY: test 41 | test: lint ## Run tests and generate coverage report. 42 | $(ENV_PREFIX)pytest -v --cov-config .coveragerc --cov=fastapi_broilerplate_saas -l --tb=short --maxfail=1 tests/ 43 | $(ENV_PREFIX)coverage xml 44 | $(ENV_PREFIX)coverage html 45 | 46 | .PHONY: watch 47 | watch: ## Run tests on every change. 48 | ls **/**.py | entr $(ENV_PREFIX)pytest --picked=first -s -vvv -l --tb=long --maxfail=1 tests/ 49 | 50 | .PHONY: clean 51 | clean: ## Clean unused files. 52 | @find ./ -name '*.pyc' -exec rm -f {} \; 53 | @find ./ -name '__pycache__' -exec rm -rf {} \; 54 | @find ./ -name 'Thumbs.db' -exec rm -f {} \; 55 | @find ./ -name '*~' -exec rm -f {} \; 56 | @rm -rf .cache 57 | @rm -rf .pytest_cache 58 | @rm -rf .mypy_cache 59 | @rm -rf build 60 | @rm -rf dist 61 | @rm -rf *.egg-info 62 | @rm -rf htmlcov 63 | @rm -rf .tox/ 64 | @rm -rf docs/_build 65 | 66 | .PHONY: virtualenv 67 | virtualenv: ## Create a virtual environment. 68 | @if [ "$(USING_POETRY)" ]; then poetry install && exit; fi 69 | @echo "creating virtualenv ..." 70 | @rm -rf .venv 71 | @python3 -m venv .venv 72 | @./.venv/bin/pip install -U pip 73 | @./.venv/bin/pip install -e .[test] 74 | @echo 75 | @echo "!!! Please run 'source .venv/bin/activate' to enable the environment !!!" 76 | 77 | .PHONY: release 78 | release: ## Create a new tag for release. 79 | @echo "WARNING: This operation will create s version tag and push to github" 80 | @read -p "Version? (provide the next x.y.z semver) : " TAG 81 | @echo "creating git tag : $${TAG}" 82 | @git tag $${TAG} 83 | @echo "$${TAG}" > fastapi_broilerplate_saas/VERSION 84 | @$(ENV_PREFIX)gitchangelog > HISTORY.md 85 | @git add fastapi_broilerplate_saas/VERSION HISTORY.md 86 | @git commit -m "release: version $${TAG} 🚀" 87 | @git push -u origin HEAD --tags 88 | @echo "Github Actions will detect the new tag and release the new version." 89 | 90 | .PHONY: docs 91 | docs: ## Build the documentation. 92 | @echo "building documentation ..." 93 | @$(ENV_PREFIX)mkdocs build 94 | URL="site/index.html"; xdg-open $$URL || sensible-browser $$URL || x-www-browser $$URL || gnome-open $$URL || open $$URL 95 | 96 | .PHONY: switch-to-poetry 97 | switch-to-poetry: ## Switch to poetry package manager. 98 | @echo "Switching to poetry ..." 99 | @if ! poetry --version > /dev/null; then echo 'poetry is required, install from https://python-poetry.org/'; exit 1; fi 100 | @rm -rf .venv 101 | @poetry init --no-interaction --name=a_flask_test --author=rochacbruno 102 | @echo "" >> pyproject.toml 103 | @echo "[tool.poetry.scripts]" >> pyproject.toml 104 | @echo "fastapi_broilerplate_saas = 'fastapi_broilerplate_saas.__main__:main'" >> pyproject.toml 105 | @cat requirements.txt | while read in; do poetry add --no-interaction "$${in}"; done 106 | @cat requirements-test.txt | while read in; do poetry add --no-interaction "$${in}" --dev; done 107 | @poetry install --no-interaction 108 | @mkdir -p .github/backup 109 | @mv requirements* .github/backup 110 | @mv setup.py .github/backup 111 | @echo "You have switched to https://python-poetry.org/ package manager." 112 | @echo "Please run 'poetry shell' or 'poetry run fastapi_broilerplate_saas'" 113 | 114 | .PHONY: init 115 | init: ## Initialize the project based on an application template. 116 | @./.github/init.sh 117 | 118 | .PHONY: shell 119 | shell: ## Open a shell in the project. 120 | @if [ "$(USING_POETRY)" ]; then poetry shell; exit; fi 121 | @./.venv/bin/ipython -c "from fastapi_broilerplate_saas import *" 122 | 123 | .PHONY: docker-build 124 | docker-build: ## Builder docker images 125 | @docker-compose -f docker-compose-dev.yaml -p fastapi_broilerplate_saas build 126 | 127 | .PHONY: docker-run 128 | docker-run: ## Run docker development images 129 | @docker-compose -f docker-compose-dev.yaml -p fastapi_broilerplate_saas up -d 130 | 131 | .PHONY: docker-stop 132 | docker-stop: ## Bring down docker dev environment 133 | @docker-compose -f docker-compose-dev.yaml -p fastapi_broilerplate_saas down 134 | 135 | .PHONY: docker-ps 136 | docker-ps: ## Bring down docker dev environment 137 | @docker-compose -f docker-compose-dev.yaml -p fastapi_broilerplate_saas ps 138 | 139 | .PHONY: docker-log 140 | docker-logs: ## Bring down docker dev environment 141 | @docker-compose -f docker-compose-dev.yaml -p fastapi_broilerplate_saas logs -f app 142 | 143 | # This project has been generated from rochacbruno/fastapi-project-template 144 | # __author__ = 'rochacbruno' 145 | # __repo__ = https://github.com/rochacbruno/fastapi-project-template 146 | # __sponsor__ = https://github.com/sponsors/rochacbruno/ 147 | -------------------------------------------------------------------------------- /fastapi_broilerplate_saas/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Callable, List, Optional, Union 3 | 4 | from fastapi import Depends, HTTPException, Request, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from jose import JWTError, jwt 7 | from passlib.context import CryptContext 8 | from pydantic import BaseModel 9 | from sqlmodel import Field, Relationship, Session, SQLModel 10 | 11 | from fastapi_broilerplate_saas.models.content import Content, ContentResponse 12 | 13 | from .config import settings 14 | from .db import engine 15 | 16 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 17 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 18 | 19 | SECRET_KEY = settings.security.secret_key 20 | ALGORITHM = settings.security.algorithm 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 | class HashedPassword(str): 38 | """Takes a plain text password and hashes it. 39 | 40 | use this as a field in your SQLModel 41 | 42 | class User(SQLModel, table=True): 43 | username: str 44 | password: HashedPassword 45 | 46 | """ 47 | 48 | @classmethod 49 | def __get_validators__(cls): 50 | # one or more validators may be yielded which will be called in the 51 | # order to validate the input, each validator will receive as an input 52 | # the value returned from the previous validator 53 | yield cls.validate 54 | 55 | @classmethod 56 | def validate(cls, v): 57 | """Accepts a plain text password and returns a hashed password.""" 58 | if not isinstance(v, str): 59 | raise TypeError("string required") 60 | 61 | hashed_password = get_password_hash(v) 62 | # you could also return a string here which would mean model.password 63 | # would be a string, pydantic won't care but you could end up with some 64 | # confusion since the value's type won't match the type annotation 65 | # exactly 66 | return cls(hashed_password) 67 | 68 | 69 | class User(SQLModel, table=True): 70 | id: Optional[int] = Field(default=None, primary_key=True) 71 | username: str = Field(sa_column_kwargs={"unique": True}) 72 | password: HashedPassword 73 | superuser: bool = False 74 | disabled: bool = False 75 | 76 | # it populates the .user attribute on the Content Model 77 | contents: List["Content"] = Relationship(back_populates="user") 78 | 79 | 80 | class UserResponse(BaseModel): 81 | """This is the User model to be used as a response_model 82 | it doesn't include the password. 83 | """ 84 | 85 | id: int 86 | username: str 87 | disabled: bool 88 | superuser: bool 89 | contents: Optional[List[ContentResponse]] = Field(default_factory=list) 90 | 91 | 92 | class UserCreate(BaseModel): 93 | """This is the User model to be used when creating a new user.""" 94 | 95 | username: str 96 | password: str 97 | superuser: bool = False 98 | disabled: bool = False 99 | 100 | 101 | class UserPasswordPatch(SQLModel): 102 | """This is to accept password for changing""" 103 | 104 | password: str 105 | password_confirm: str 106 | 107 | 108 | def verify_password(plain_password, hashed_password) -> bool: 109 | return pwd_context.verify(plain_password, hashed_password) 110 | 111 | 112 | def get_password_hash(password) -> str: 113 | return pwd_context.hash(password) 114 | 115 | 116 | def create_access_token( 117 | data: dict, expires_delta: Optional[timedelta] = None 118 | ) -> str: 119 | to_encode = data.copy() 120 | if expires_delta: 121 | expire = datetime.utcnow() + expires_delta 122 | else: 123 | expire = datetime.utcnow() + timedelta(minutes=15) 124 | to_encode.update({"exp": expire, "scope": "access_token"}) 125 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 126 | return encoded_jwt 127 | 128 | 129 | def create_refresh_token( 130 | data: dict, expires_delta: Optional[timedelta] = None 131 | ) -> str: 132 | to_encode = data.copy() 133 | if expires_delta: 134 | expire = datetime.utcnow() + expires_delta 135 | else: 136 | expire = datetime.utcnow() + timedelta(minutes=15) 137 | to_encode.update({"exp": expire, "scope": "refresh_token"}) 138 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 139 | return encoded_jwt 140 | 141 | 142 | def authenticate_user( 143 | get_user: Callable, username: str, password: str 144 | ) -> Union[User, bool]: 145 | user = get_user(username) 146 | if not user: 147 | return False 148 | if not verify_password(password, user.password): 149 | return False 150 | return user 151 | 152 | 153 | def get_user(username) -> Optional[User]: 154 | with Session(engine) as session: 155 | return session.query(User).where(User.username == username).first() 156 | 157 | 158 | def get_current_user( 159 | token: str = Depends(oauth2_scheme), request: Request = None, fresh=False 160 | ) -> User: 161 | credentials_exception = HTTPException( 162 | status_code=status.HTTP_401_UNAUTHORIZED, 163 | detail="Could not validate credentials", 164 | headers={"WWW-Authenticate": "Bearer"}, 165 | ) 166 | 167 | if request: 168 | if authorization := request.headers.get("authorization"): 169 | try: 170 | token = authorization.split(" ")[1] 171 | except IndexError: 172 | raise credentials_exception 173 | 174 | try: 175 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 176 | username: str = payload.get("sub") 177 | 178 | if username is None: 179 | raise credentials_exception 180 | token_data = TokenData(username=username) 181 | except JWTError: 182 | raise credentials_exception 183 | user = get_user(username=token_data.username) 184 | if user is None: 185 | raise credentials_exception 186 | if fresh and (not payload["fresh"] and not user.superuser): 187 | raise credentials_exception 188 | 189 | return user 190 | 191 | 192 | async def get_current_active_user( 193 | current_user: User = Depends(get_current_user), 194 | ) -> User: 195 | if current_user.disabled: 196 | raise HTTPException(status_code=400, detail="Inactive user") 197 | return current_user 198 | 199 | 200 | AuthenticatedUser = Depends(get_current_active_user) 201 | 202 | 203 | def get_current_fresh_user( 204 | token: str = Depends(oauth2_scheme), request: Request = None 205 | ) -> User: 206 | return get_current_user(token, request, True) 207 | 208 | 209 | AuthenticatedFreshUser = Depends(get_current_fresh_user) 210 | 211 | 212 | async def get_current_admin_user( 213 | current_user: User = Depends(get_current_user), 214 | ) -> User: 215 | if not current_user.superuser: 216 | raise HTTPException( 217 | status_code=status.HTTP_403_FORBIDDEN, detail="Not an admin user" 218 | ) 219 | return current_user 220 | 221 | 222 | AdminUser = Depends(get_current_admin_user) 223 | 224 | 225 | async def validate_token(token: str = Depends(oauth2_scheme)) -> User: 226 | 227 | user = get_current_user(token=token) 228 | return user 229 | -------------------------------------------------------------------------------- /tests/test_user_api.py: -------------------------------------------------------------------------------- 1 | def test_user_list(api_client_authenticated): 2 | response = api_client_authenticated.get("/user/") 3 | assert response.status_code == 200 4 | result = response.json() 5 | assert "admin" in result[0]["username"] 6 | 7 | 8 | def test_user_create(api_client_authenticated): 9 | response = api_client_authenticated.post( 10 | "/user/", 11 | json={ 12 | "username": "foo", 13 | "password": "bar", 14 | "superuser": False, 15 | "disabled": False, 16 | }, 17 | ) 18 | assert response.status_code == 200 19 | result = response.json() 20 | assert result["username"] == "foo" 21 | 22 | # verify a username can't be used for multiple accounts 23 | response = api_client_authenticated.post( 24 | "/user/", 25 | json={ 26 | "username": "foo", 27 | "password": "bar", 28 | "superuser": False, 29 | "disabled": False, 30 | }, 31 | ) 32 | assert response.status_code == 422 33 | 34 | 35 | def test_user_by_id(api_client_authenticated): 36 | response = api_client_authenticated.get("/user/1") 37 | assert response.status_code == 200 38 | result = response.json() 39 | assert "admin" in result["username"] 40 | 41 | 42 | def test_user_by_username(api_client_authenticated): 43 | response = api_client_authenticated.get("/user/admin") 44 | assert response.status_code == 200 45 | result = response.json() 46 | assert "admin" in result["username"] 47 | 48 | 49 | def test_user_by_bad_id(api_client_authenticated): 50 | response = api_client_authenticated.get("/user/42") 51 | result = response.json() 52 | assert response.status_code == 404 53 | 54 | 55 | def test_user_by_bad_username(api_client_authenticated): 56 | response = api_client_authenticated.get("/user/nouser") 57 | assert response.status_code == 404 58 | 59 | 60 | def test_user_change_password_no_auth(api_client): 61 | 62 | # user doesn't exist 63 | response = api_client.patch( 64 | "/user/1/password/", 65 | json={"password": "foobar!", "password_confirm": "foobar!"}, 66 | ) 67 | assert response.status_code == 401 68 | 69 | 70 | def test_user_change_password_insufficient_auth(api_client): 71 | 72 | # login as non-superuser 73 | token = api_client.post( 74 | "/token", 75 | data={"username": "foo", "password": "bar"}, 76 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 77 | ).json()["access_token"] 78 | api_client.headers["Authorization"] = f"Bearer {token}" 79 | 80 | # try to update admin user (id: 1) 81 | response = api_client.patch( 82 | "/user/1/password/", 83 | json={"password": "foobar!", "password_confirm": "foobar!"}, 84 | ) 85 | assert response.status_code == 403 86 | 87 | # log out 88 | del api_client.headers["Authorization"] 89 | 90 | 91 | def test_user_change_password(api_client_authenticated): 92 | 93 | # user doesn't exist 94 | response = api_client_authenticated.patch( 95 | "/user/42/password/", 96 | json={"password": "foobar!", "password_confirm": "foobar!"}, 97 | ) 98 | assert response.status_code == 404 99 | 100 | foo_user = api_client_authenticated.get("/user/foo").json() 101 | assert "id" in foo_user 102 | 103 | # passwords don't match 104 | response = api_client_authenticated.patch( 105 | f"/user/{foo_user['id']}/password/", 106 | json={"password": "foobar!", "password_confirm": "foobar"}, 107 | ) 108 | assert response.status_code == 400 109 | 110 | # passwords do match 111 | response = api_client_authenticated.patch( 112 | f"/user/{foo_user['id']}/password/", 113 | json={"password": "foobar!", "password_confirm": "foobar!"}, 114 | ) 115 | assert response.status_code == 200 116 | 117 | 118 | def test_user_delete_no_auth(api_client): 119 | 120 | # user doesn't exist 121 | response = api_client.delete("/user/42/") 122 | assert response.status_code == 401 123 | 124 | # valid delete request but not authorized 125 | response = api_client.delete(f"/user/1/") 126 | assert response.status_code == 401 127 | 128 | 129 | def test_user_delete(api_client_authenticated): 130 | 131 | # user doesn't exist 132 | response = api_client_authenticated.delete("/user/42/") 133 | assert response.status_code == 404 134 | 135 | # try to delete yourself 136 | me = api_client_authenticated.get("/profile").json() 137 | assert "id" in me 138 | 139 | response = api_client_authenticated.delete(f"/user/{me['id']}/") 140 | assert response.status_code == 403 141 | 142 | # try to delete "foo" user 143 | foo_user = api_client_authenticated.get("/user/foo").json() 144 | assert "id" in foo_user 145 | 146 | # valid delete request 147 | response = api_client_authenticated.delete(f"/user/{foo_user['id']}/") 148 | assert response.status_code == 200 149 | 150 | 151 | def test_bad_login(api_client): 152 | 153 | response = api_client.post( 154 | "/token", 155 | data={"username": "admin", "password": "admin1"}, 156 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 157 | ) 158 | assert response.status_code == 401 159 | 160 | 161 | def test_good_login(api_client): 162 | 163 | response = api_client.post( 164 | "/token", 165 | data={"username": "admin", "password": "admin"}, 166 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 167 | ) 168 | assert response.status_code == 200 169 | 170 | 171 | def test_refresh_token(api_client_authenticated): 172 | 173 | # create dummy account for test 174 | response = api_client_authenticated.post( 175 | "/user/", 176 | json={ 177 | "username": "foo", 178 | "password": "bar", 179 | "superuser": False, 180 | "disabled": False, 181 | }, 182 | ) 183 | 184 | assert response.status_code == 200 185 | result = response.json() 186 | assert result["id"] 187 | user_id = result["id"] 188 | 189 | # retrieve access_token and refresh_token from newly created user 190 | response = api_client_authenticated.post( 191 | "/token", 192 | data={"username": "foo", "password": "bar"}, 193 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 194 | ) 195 | 196 | assert response.status_code == 200 197 | result = response.json() 198 | assert result["access_token"] 199 | assert result["refresh_token"] 200 | 201 | # use refresh_token to update access_token and refresh_token 202 | response = api_client_authenticated.post( 203 | "/refresh_token", json={"refresh_token": result["refresh_token"]} 204 | ) 205 | 206 | assert response.status_code == 200 207 | result = response.json() 208 | assert result["access_token"] 209 | assert result["refresh_token"] 210 | 211 | # save refresh_token 212 | refresh_token = result["refresh_token"] 213 | 214 | # delete dummy account 215 | response = api_client_authenticated.delete(f"/user/{user_id}/") 216 | assert response.status_code == 200 217 | 218 | # confirm account was deleted 219 | response = api_client_authenticated.get(f"/user/{user_id}/") 220 | assert response.status_code == 404 221 | 222 | # try to refresh tokens 223 | response = api_client_authenticated.post( 224 | "/refresh_token", json={"refresh_token": refresh_token} 225 | ) 226 | 227 | result = response.json() 228 | assert response.status_code == 401 229 | 230 | 231 | def test_bad_refresh_token(api_client): 232 | 233 | BAD_TOKEN = "thisaintnovalidtoken" 234 | 235 | response = api_client.post( 236 | "/refresh_token", json={"refresh_token": BAD_TOKEN} 237 | ) 238 | 239 | assert response.status_code == 401 240 | 241 | 242 | # Need to add test for updating passwords with stale tokens 243 | def test_stale_token(api_client_authenticated): 244 | 245 | # create non-admin account 246 | response = api_client_authenticated.post( 247 | "/user/", 248 | json={ 249 | "username": "foo", 250 | "password": "bar", 251 | "superuser": False, 252 | "disabled": False, 253 | }, 254 | ) 255 | assert response.status_code == 200 256 | result = response.json() 257 | user_id = result["id"] 258 | 259 | # retrieve access_token and refresh_token from newly created user 260 | response = api_client_authenticated.post( 261 | "/token", 262 | data={"username": "foo", "password": "bar"}, 263 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 264 | ) 265 | 266 | assert response.status_code == 200 267 | result = response.json() 268 | 269 | # use refresh_token to update access_token and refresh_token 270 | response = api_client_authenticated.post( 271 | "/refresh_token", json={"refresh_token": result["refresh_token"]} 272 | ) 273 | 274 | assert response.status_code == 200 275 | result = response.json() 276 | 277 | # set stale access_token 278 | api_client_authenticated.headers[ 279 | "Authorization" 280 | ] = f"Bearer {result['access_token']}" 281 | 282 | response = api_client_authenticated.patch( 283 | f"/user/{user_id}/password/", 284 | json={"password": "foobar!", "password_confirm": "foobar!"}, 285 | ) 286 | assert response.status_code == 401 287 | 288 | del api_client_authenticated.headers["Authorization"] 289 | -------------------------------------------------------------------------------- /ABOUT_THIS_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # About this template 2 | 3 | Hi, I created this template to help you get started with a new project. 4 | 5 | I have created and maintained a number of python libraries, applications and 6 | frameworks and during those years I have learned a lot about how to create a 7 | project structure and how to structure a project to be as modular and simple 8 | as possible. 9 | 10 | Some decisions I have made while creating this template are: 11 | 12 | - Create a project structure that is as modular as possible. 13 | - Keep it simple and easy to maintain. 14 | - Allow for a lot of flexibility and customizability. 15 | - Low dependency (this template doesn't add dependencies) 16 | 17 | ## Structure 18 | 19 | Lets take a look at the structure of this template: 20 | 21 | ```text 22 | ├── Containerfile # The file to build a container using buildah or docker 23 | ├── CONTRIBUTING.md # Onboarding instructions for new contributors 24 | ├── docs # Documentation site (add more .md files here) 25 | │   └── index.md # The index page for the docs site 26 | ├── .github # Github metadata for repository 27 | │   ├── release_message.sh # A script to generate a release message 28 | │   └── workflows # The CI pipeline for Github Actions 29 | ├── .gitignore # A list of files to ignore when pushing to Github 30 | ├── HISTORY.md # Auto generated list of changes to the project 31 | ├── LICENSE # The license for the project 32 | ├── Makefile # A collection of utilities to manage the project 33 | ├── MANIFEST.in # A list of files to include in a package 34 | ├── mkdocs.yml # Configuration for documentation site 35 | ├── fastapi_broilerplate_saas # The main python package for the project 36 | │   ├── base.py # The base module for the project 37 | │   ├── __init__.py # This tells Python that this is a package 38 | │   ├── __main__.py # The entry point for the project 39 | │   └── VERSION # The version for the project is kept in a static file 40 | ├── README.md # The main readme for the project 41 | ├── setup.py # The setup.py file for installing and packaging the project 42 | ├── requirements.txt # An empty file to hold the requirements for the project 43 | ├── requirements-test.txt # List of requirements for testing and devlopment 44 | ├── setup.py # The setup.py file for installing and packaging the project 45 | └── tests # Unit tests for the project (add mote tests files here) 46 | ├── conftest.py # Configuration, hooks and fixtures for pytest 47 | ├── __init__.py # This tells Python that this is a test package 48 | └── test_base.py # The base test case for the project 49 | ``` 50 | 51 | ## FAQ 52 | 53 | Frequent asked questions. 54 | 55 | ### Why this template is not using [Poetry](https://python-poetry.org/) ? 56 | 57 | I really like Poetry and I think it is a great tool to manage your python projects, 58 | if you want to switch to poetry, you can run `make switch-to-poetry`. 59 | 60 | But for this template I wanted to keep it simple. 61 | 62 | Setuptools is the most simple and well supported way of packaging a Python project, 63 | it doesn't require extra dependencies and is the easiest way to install the project. 64 | 65 | Also, poetry doesn't have a good support for installing projects in development mode yet. 66 | 67 | ### Why the `requirements.txt` is empty ? 68 | 69 | This template is a low dependency project, so it doesn't have any extra dependencies. 70 | You can add new dependencies as you will or you can use the `make init` command to 71 | generate a `requirements.txt` file based on the template you choose `flask, fastapi, click etc`. 72 | 73 | ### Why there is a `requirements-test.txt` file ? 74 | 75 | This file lists all the requirements for testing and development, 76 | I think the development environment and testing environment should be as similar as possible. 77 | 78 | Except those tools that are up to the developer choice (like ipython, ipdb etc). 79 | 80 | ### Why the template doesn't have a `pyproject.toml` file ? 81 | 82 | It is possible to run `pip install https://github.com/name/repo/tarball/main` and 83 | have pip to download the package direcly from Git repo. 84 | 85 | For that to work you need to have a `setup.py` file, and `pyproject.toml` is not 86 | supported for that kind of installation. 87 | 88 | I think it is easier for example you want to install specific branch or tag you can 89 | do `pip install https://github.com/name/repo/tarball/{TAG|REVISON|COMMIT}` 90 | 91 | People automating CI for your project will be grateful for having a setup.py file 92 | 93 | ### Why isn't this template made as a cookiecutter template? 94 | 95 | I really like [cookiecutter](https://github.com/cookiecutter/cookiecutter) and it is a great way to create new projects, 96 | but for this template I wanted to use the Github `Use this template` button, 97 | to use this template doesn't require to install extra tooling such as cookiecutter. 98 | 99 | Just click on [Use this template](https://github.com/rochacbruno/fastapi-project-template/generate) and you are good to go. 100 | 101 | The substituions are done using github actions and a simple sed script. 102 | 103 | ### Why `VERSION` is kept in a static plain text file? 104 | 105 | I used to have my version inside my main module in a `__version__` variable, then 106 | I had to do some tricks to read that version variable inside the setuptools 107 | `setup.py` file because that would be available only after the installation. 108 | 109 | I decided to keep the version in a static file because it is easier to read from 110 | wherever I want without the need to install the package. 111 | 112 | e.g: `cat fastapi_broilerplate_saas/VERSION` will get the project version without harming 113 | with module imports or anything else, it is useful for CI, logs and debugging. 114 | 115 | ### Why to include `tests`, `history` and `Containerfile` as part of the release? 116 | 117 | The `MANIFEST.in` file is used to include the files in the release, once the 118 | project is released to PyPI all the files listed on MANIFEST.in will be included 119 | even if the files are static or not related to Python. 120 | 121 | Some build systems such as RPM, DEB, AUR for some Linux distributions, and also 122 | internal repackaging systems tends to run the tests before the packaging is performed. 123 | 124 | The Containerfile can be useful to provide a safer execution environment for 125 | the project when running on a testing environment. 126 | 127 | I added those files to make it easier for packaging in different formats. 128 | 129 | ### Why conftest includes a go_to_tmpdir fixture? 130 | 131 | When your project deals with file system operations, it is a good idea to use 132 | a fixture to create a temporary directory and then remove it after the test. 133 | 134 | Before executing each test pytest will create a temporary directory and will 135 | change the working directory to that path and run the test. 136 | 137 | So the test can create temporary artifacts isolated from other tests. 138 | 139 | After the execution Pytest will remove the temporary directory. 140 | 141 | ### Why this template is not using [pre-commit](https://pre-commit.com/) ? 142 | 143 | pre-commit is an excellent tool to automate checks and formatting on your code. 144 | 145 | However I figured out that pre-commit adds extra dependency and it an entry barrier 146 | for new contributors. 147 | 148 | Having the linting, checks and formatting as simple commands on the [Makefile](Makefile) 149 | makes it easier to undestand and change. 150 | 151 | Once the project is bigger and complex, having pre-commit as a dependency can be a good idea. 152 | 153 | ### Why the CLI is not using click? 154 | 155 | I wanted to provide a simple template for a CLI application on the project main entry point 156 | click and typer are great alternatives but are external dependencies and this template 157 | doesn't add dependencies besides those used for development. 158 | 159 | ### Why this doesn't provide a full example of application using Flask or Django? 160 | 161 | as I said before, I want it to be simple and multipurpose, so I decided to not include 162 | external dependencies and programming design decisions. 163 | 164 | It is up to you to decide if you want to use Flask or Django and to create your application 165 | the way you think is best. 166 | 167 | This template provides utilities in the Makefile to make it easier to you can run: 168 | 169 | ```bash 170 | $ make init 171 | Which template do you want to apply? [flask, fastapi, click, typer]? > flask 172 | Generating a new project with Flask ... 173 | ``` 174 | 175 | Then the above will download the Flask template and apply it to the project. 176 | 177 | ## The Makefile 178 | 179 | All the utilities for the template and project are on the Makefile 180 | 181 | ```bash 182 | ❯ make 183 | Usage: make 184 | 185 | Targets: 186 | help: ## Show the help. 187 | install: ## Install the project in dev mode. 188 | fmt: ## Format code using black & isort. 189 | lint: ## Run pep8, black, mypy linters. 190 | test: lint ## Run tests and generate coverage report. 191 | watch: ## Run tests on every change. 192 | clean: ## Clean unused files. 193 | virtualenv: ## Create a virtual environment. 194 | release: ## Create a new tag for release. 195 | docs: ## Build the documentation. 196 | switch-to-poetry: ## Switch to poetry package manager. 197 | init: ## Initialize the project based on an application template. 198 | ``` 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Project Template 2 | 3 | The base to start an openapi project featuring: SQLModel, Typer, FastAPI, JWT Token Auth, Interactive Shell, Management Commands. 4 | 5 | See also 6 | 7 | - [Python-Project-Template](https://github.com/rochacbruno/python-project-template/) for a lean, low dependency Python app. 8 | - [Flask-Project-Template](https://github.com/rochacbruno/flask-project-template/) for a full feature Flask project including database, API, admin interface, etc. 9 | 10 | 11 | ### HOW TO USE THIS TEMPLATE 12 | 13 | > **DO NOT FORK** this is meant to be used from **[Use this template](https://github.com/rochacbruno/fastapi-project-template/generate)** feature. 14 | 15 | 1. Click on **[Use this template](https://github.com/rochacbruno/fastapi-project-template/generate)** 16 | 3. Give a name to your project 17 | (e.g. `my_awesome_project` recommendation is to use all lowercase and underscores separation for repo names.) 18 | 3. Wait until the first run of CI finishes 19 | (Github Actions will process the template and commit to your new repo) 20 | 4. If you want [codecov](https://about.codecov.io/sign-up/) Reports and Automatic Release to [PyPI](https://pypi.org) 21 | On the new repository `settings->secrets` add your `PIPY_API_TOKEN` and `CODECOV_TOKEN` (get the tokens on respective websites) 22 | 4. Read the file [CONTRIBUTING.md](CONTRIBUTING.md) 23 | 5. Then clone your new project and happy coding! 24 | 25 | > **NOTE**: **WAIT** until first CI run on github actions before cloning your new project. 26 | 27 | ### What is included on this template? 28 | 29 | - 🖼️ The base to start an openapi project featuring: SQLModel, Typer, FastAPI, VueJS. 30 | - 📦 A basic [setup.py](setup.py) file to provide installation, packaging and distribution for your project. 31 | Template uses setuptools because it's the de-facto standard for Python packages, you can run `make switch-to-poetry` later if you want. 32 | - 🤖 A [Makefile](Makefile) with the most useful commands to install, test, lint, format and release your project. 33 | - 📃 Documentation structure using [mkdocs](http://www.mkdocs.org) 34 | - 💬 Auto generation of change log using **gitchangelog** to keep a HISTORY.md file automatically based on your commit history on every release. 35 | - 🐋 A simple [Containerfile](Containerfile) to build a container image for your project. 36 | `Containerfile` is a more open standard for building container images than Dockerfile, you can use buildah or docker with this file. 37 | - 🧪 Testing structure using [pytest](https://docs.pytest.org/en/latest/) 38 | - ✅ Code linting using [flake8](https://flake8.pycqa.org/en/latest/) 39 | - 📊 Code coverage reports using [codecov](https://about.codecov.io/sign-up/) 40 | - 🛳️ Automatic release to [PyPI](https://pypi.org) using [twine](https://twine.readthedocs.io/en/latest/) and github actions. 41 | - 🎯 Entry points to execute your program using `python -m ` or `$ fastapi_broilerplate_saas` with basic CLI argument parsing. 42 | - 🔄 Continuous integration using [Github Actions](.github/workflows/) with jobs to lint, test and release your project on Linux, Mac and Windows environments. 43 | 44 | > Curious about architectural decisions on this template? read [ABOUT_THIS_TEMPLATE.md](ABOUT_THIS_TEMPLATE.md) 45 | > If you want to contribute to this template please open an [issue](https://github.com/rochacbruno/fastapi-project-template/issues) or fork and send a PULL REQUEST. 46 | 47 | [❤️ Sponsor this project](https://github.com/sponsors/rochacbruno/) 48 | 49 | 50 | 51 | --- 52 | # fastapi_broilerplate_saas 53 | 54 | [![codecov](https://codecov.io/gh/todo/fastapi-broilerplate-saas/branch/main/graph/badge.svg?token=fastapi-broilerplate-saas_token_here)](https://codecov.io/gh/todo/fastapi-broilerplate-saas) 55 | [![CI](https://github.com/todo/fastapi-broilerplate-saas/actions/workflows/main.yml/badge.svg)](https://github.com/todo/fastapi-broilerplate-saas/actions/workflows/main.yml) 56 | 57 | Awesome fastapi_broilerplate_saas created by todo 58 | 59 | ## Install 60 | 61 | from source 62 | ```bash 63 | git clone https://github.com/todo/fastapi-broilerplate-saas fastapi_broilerplate_saas 64 | cd fastapi_broilerplate_saas 65 | make install 66 | ``` 67 | 68 | from pypi 69 | 70 | ```bash 71 | pip install fastapi_broilerplate_saas 72 | ``` 73 | 74 | ## Executing 75 | 76 | ```bash 77 | $ fastapi_broilerplate_saas run --port 8080 78 | ``` 79 | 80 | or 81 | 82 | ```bash 83 | python -m fastapi_broilerplate_saas run --port 8080 84 | ``` 85 | 86 | or 87 | 88 | ```bash 89 | $ uvicorn fastapi_broilerplate_saas:app 90 | ``` 91 | 92 | ## CLI 93 | 94 | ```bash 95 | ❯ fastapi_broilerplate_saas --help 96 | Usage: fastapi_broilerplate_saas [OPTIONS] COMMAND [ARGS]... 97 | 98 | Options: 99 | --install-completion [bash|zsh|fish|powershell|pwsh] 100 | Install completion for the specified shell. 101 | --show-completion [bash|zsh|fish|powershell|pwsh] 102 | Show completion for the specified shell, to 103 | copy it or customize the installation. 104 | --help Show this message and exit. 105 | 106 | Commands: 107 | create-user Create user 108 | run Run the API server. 109 | shell Opens an interactive shell with objects auto imported 110 | ``` 111 | 112 | ### Creating a user 113 | 114 | ```bash 115 | ❯ fastapi_broilerplate_saas create-user --help 116 | Usage: fastapi_broilerplate_saas create-user [OPTIONS] USERNAME PASSWORD 117 | 118 | Create user 119 | 120 | Arguments: 121 | USERNAME [required] 122 | PASSWORD [required] 123 | 124 | Options: 125 | --superuser / --no-superuser [default: no-superuser] 126 | --help 127 | ``` 128 | 129 | **IMPORTANT** To create an admin user on the first run: 130 | 131 | ```bash 132 | fastapi_broilerplate_saas create-user admin admin --superuser 133 | ``` 134 | 135 | ### The Shell 136 | 137 | You can enter an interactive shell with all the objects imported. 138 | 139 | ```bash 140 | ❯ fastapi_broilerplate_saas shell 141 | Auto imports: ['app', 'settings', 'User', 'engine', 'cli', 'create_user', 'select', 'session', 'Content'] 142 | 143 | In [1]: session.query(Content).all() 144 | Out[1]: [Content(text='string', title='string', created_time='2021-09-14T19:25:00.050441', user_id=1, slug='string', id=1, published=False, tags='string')] 145 | 146 | In [2]: user = session.get(User, 1) 147 | 148 | In [3]: user.contents 149 | Out[3]: [Content(text='string', title='string', created_time='2021-09-14T19:25:00.050441', user_id=1, slug='string', id=1, published=False, tags='string')] 150 | ``` 151 | 152 | ## API 153 | 154 | Run with `fastapi_broilerplate_saas run` and access http://127.0.0.1:8000/docs 155 | 156 | ![](https://raw.githubusercontent.com/rochacbruno/fastapi-project-template/master/docs/api.png) 157 | 158 | 159 | **For some api calls you must authenticate** using the user created with `fastapi_broilerplate_saas create-user`. 160 | 161 | ## Testing 162 | 163 | ``` bash 164 | ❯ make test 165 | Black All done! ✨ 🍰 ✨ 166 | 13 files would be left unchanged. 167 | Isort All done! ✨ 🍰 ✨ 168 | 6 files would be left unchanged. 169 | Success: no issues found in 13 source files 170 | ================================ test session starts =========================== 171 | platform linux -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- 172 | /fastapi-project-template/.venv/bin/python3 173 | cachedir: .pytest_cache 174 | rootdir: /fastapi-project-template 175 | plugins: cov-2.12.1 176 | collected 10 items 177 | 178 | tests/test_app.py::test_using_testing_db PASSED [ 10%] 179 | tests/test_app.py::test_index PASSED [ 20%] 180 | tests/test_cli.py::test_help PASSED [ 30%] 181 | tests/test_cli.py::test_cmds_help[run-args0---port] PASSED [ 40%] 182 | tests/test_cli.py::test_cmds_help[create-user-args1-create-user] PASSED [ 50%] 183 | tests/test_cli.py::test_cmds[create-user-args0-created admin2 user] PASSED[ 60%] 184 | tests/test_content_api.py::test_content_create PASSED [ 70%] 185 | tests/test_content_api.py::test_content_list PASSED [ 80%] 186 | tests/test_user_api.py::test_user_list PASSED [ 90%] 187 | tests/test_user_api.py::test_user_create PASSED [100%] 188 | 189 | ----------- coverage: platform linux, python 3.9.6-final-0 ----------- 190 | Name Stmts Miss Cover 191 | ----------------------------------------------------- 192 | fastapi_broilerplate_saas/__init__.py 4 0 100% 193 | fastapi_broilerplate_saas/app.py 16 1 94% 194 | fastapi_broilerplate_saas/cli.py 21 0 100% 195 | fastapi_broilerplate_saas/config.py 5 0 100% 196 | fastapi_broilerplate_saas/db.py 10 0 100% 197 | fastapi_broilerplate_saas/models/__init__.py 0 0 100% 198 | fastapi_broilerplate_saas/models/content.py 47 1 98% 199 | fastapi_broilerplate_saas/routes/__init__.py 11 0 100% 200 | fastapi_broilerplate_saas/routes/content.py 52 25 52% 201 | fastapi_broilerplate_saas/routes/security.py 15 1 93% 202 | fastapi_broilerplate_saas/routes/user.py 52 26 50% 203 | fastapi_broilerplate_saas/security.py 103 12 88% 204 | ----------------------------------------------------- 205 | TOTAL 336 66 80% 206 | 207 | 208 | ========================== 10 passed in 2.34s ================================== 209 | 210 | ``` 211 | 212 | ## Linting and Formatting 213 | 214 | ```bash 215 | make lint # checks for linting errors 216 | make fmt # formats the code 217 | ``` 218 | 219 | 220 | ## Configuration 221 | 222 | This project uses [Dynaconf](https://dynaconf.com) to manage configuration. 223 | 224 | ```py 225 | from fastapi_broilerplate_saas.config import settings 226 | ``` 227 | 228 | ## Acessing variables 229 | 230 | ```py 231 | settings.get("SECRET_KEY", default="sdnfjbnfsdf") 232 | settings["SECRET_KEY"] 233 | settings.SECRET_KEY 234 | settings.db.uri 235 | settings["db"]["uri"] 236 | settings["db.uri"] 237 | settings.DB__uri 238 | ``` 239 | 240 | ## Defining variables 241 | 242 | ### On files 243 | 244 | settings.toml 245 | 246 | ```toml 247 | [development] 248 | dynaconf_merge = true 249 | 250 | [development.db] 251 | echo = true 252 | ``` 253 | 254 | > `dynaconf_merge` is a boolean that tells if the settings should be merged with the default settings defined in fastapi_broilerplate_saas/default.toml. 255 | 256 | ### As environment variables 257 | ```bash 258 | export fastapi_broilerplate_saas_KEY=value 259 | export fastapi_broilerplate_saas_KEY="@int 42" 260 | export fastapi_broilerplate_saas_KEY="@jinja {{ this.db.uri }}" 261 | export fastapi_broilerplate_saas_DB__uri="@jinja {{ this.db.uri | replace('db', 'data') }}" 262 | ``` 263 | 264 | ### Secrets 265 | 266 | There is a file `.secrets.toml` where your sensitive variables are stored, 267 | that file must be ignored by git. (add that to .gitignore) 268 | 269 | Or store your secrets in environment variables or a vault service, Dynaconf 270 | can read those variables. 271 | 272 | ### Switching environments 273 | 274 | ```bash 275 | fastapi_broilerplate_saas_ENV=production fastapi_broilerplate_saas run 276 | ``` 277 | 278 | Read more on https://dynaconf.com 279 | 280 | ## Development 281 | 282 | Read the [CONTRIBUTING.md](CONTRIBUTING.md) file. 283 | --------------------------------------------------------------------------------