├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codecov.yml │ └── test.yml ├── .gitignore ├── .gitpod.yml ├── Dockerfile.koyeb ├── LICENSE ├── Makefile ├── README.md ├── backend ├── .dockerignore ├── .env.example ├── Dockerfile ├── api │ ├── alembic.ini │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 059fcd0d080b_init.py │ │ │ └── e1f2c2675da2_init.py │ ├── app.py │ ├── asgi.py │ ├── auth │ │ ├── __init__.py │ │ └── kindeauth.py │ ├── config.py │ ├── database.py │ ├── init_db.py │ ├── public │ │ ├── __init__.py │ │ ├── people │ │ │ ├── crud.py │ │ │ ├── models.py │ │ │ └── routes.py │ │ ├── routes.py │ │ └── towns │ │ │ ├── crud.py │ │ │ ├── models.py │ │ │ └── routes.py │ ├── requirements.txt │ └── utils │ │ └── __init__.py └── tests │ ├── requirements.txt │ └── test_towns.py ├── docker-compose.yaml ├── frontend ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── next.svg │ └── vercel.svg ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ └── components │ │ ├── home.tsx │ │ └── ui │ │ ├── button.tsx │ │ └── card.tsx ├── tailwind.config.ts └── tsconfig.json ├── grafana_data └── FastAPI Dashboard-1684080468044.json └── prometheus_data └── prometheus.yml /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: API Codecov workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Test python API 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v2 12 | with: 13 | python-version: '3.11' 14 | - name: Install requirements 15 | run: cd backend && pip install -r api/requirements.txt && python api/init_db.py 16 | 17 | - name: Run tests and collect coverage 18 | run: pytest --cov=api.public.towns.routes tests/test_towns.py --cov-report=xml 19 | - name: Upload coverage reports to Codecov 20 | uses: codecov/codecov-action@v3 21 | env: 22 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | python-version: ["3.11"] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Run Tests 21 | run: | 22 | echo "${{ secrets.ENV_FILE }}" > .env 23 | cd backend 24 | pip install -r api/requirements.txt && python api/init_db.py 25 | pytest tests/test_towns.py 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .vs/ 4 | *.py[cod] 5 | *$py.class 6 | data/ 7 | # C extensions 8 | *.so 9 | node_modules/ 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | #.env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | .env 132 | .vscode/ 133 | database.db 134 | # tests/ 135 | # test.yml 136 | .venv/ 137 | test.js 138 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # Commands to start on workspace startup 2 | tasks: 3 | - command: | 4 | docker-compose up -d --build 5 | # Ports to expose on workspace startup 6 | ports: 7 | - port: 8090 8 | onOpen: open-browser -------------------------------------------------------------------------------- /Dockerfile.koyeb: -------------------------------------------------------------------------------- 1 | FROM koyeb/docker-compose 2 | 3 | COPY . /app 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ifeanyi Nneji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV_NAME = env 2 | PYTHON = python 3 | PIP = $(VENV_NAME)/Scripts/pip 4 | PYTEST = $(VENV_NAME)/Scripts/pytest 5 | BLACK = $(VENV_NAME)/Scripts/black 6 | ISORT = $(VENV_NAME)/Scripts/isort 7 | 8 | .PHONY: setup test run lint 9 | 10 | setup: 11 | $(VENV_NAME)/Scripts/activate requirements.txt test/requirements.txt 12 | @echo "Setting up virtual environment and installing application requirements..." 13 | . $(VENV_NAME)/Scripts/activate; \ 14 | $(PIP) install -r requirements.txt 15 | 16 | test-setup: 17 | $(VENV_NAME)/Scripts/activate test/requirements.txt 18 | @echo "Installing test requirements..." 19 | . $(VENV_NAME)/Scripts/activate; \ 20 | $(PIP) install -r test/requirements.txt 21 | 22 | $(VENV_NAME)/Scripts/activate: requirements.txt 23 | @echo "Creating virtual environment..." 24 | python -m venv $(VENV_NAME) 25 | @echo "Virtual environment created." 26 | 27 | test: test-setup 28 | @echo "Running tests..." 29 | . $(VENV_NAME)/Scripts/activate; \ 30 | $(PYTEST) --cov-report html:test_runs --cov=yourmodule tests 31 | @echo "Tests complete. HTML test report saved in test_runs directory." 32 | 33 | run: 34 | @echo "Running the FastAPI application..." 35 | . $(VENV_NAME)/Scripts/activate; \ 36 | python api/asgi.py 37 | 38 | lint: setup 39 | @echo "Running Black and Isort..." 40 | . $(VENV_NAME)/Scripts/activate; \ 41 | $(BLACK) yourmodule; \ 42 | $(ISORT) yourmodule 43 | 44 | # Default target 45 | all: venv activate install init start 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI-Nextjs 2 | [![codecov](https://codecov.io/gh/Nneji123/fastapi-webscraper/graph/badge.svg?token=UsIESnIqm6)](https://codecov.io/gh/Nneji123/fastapi-webscraper) 3 | 4 | [![Python Tests](https://github.com/Nneji123/fastapi-webscraper/actions/workflows/test.yml/badge.svg)](https://github.com/Nneji123/fastapi-webscraper/actions/workflows/test.yml) 5 | 6 | [![Python Tests](https://github.com/Nneji123/fastapi-webscraper/actions/workflows/test.yml/badge.svg)](https://github.com/Nneji123/fastapi-webscraper/actions/workflows/test.yml) 7 | 8 | 9 | ## Introduction 10 | This project serves as a comprehensive demonstration of building a robust API using cutting-edge technologies, including FastAPI, SQLModel, PostgreSQL, Redis, Next.js, Docker, and Docker Compose. The goal is to showcase the implementation of essential API features, such as rate limiting and pagination, while maintaining a modular and scalable architecture. 11 | 12 | ## Technologies Used 13 | 14 | - **FastAPI:** A modern, fast web framework for building APIs with Python 3.7+ based on standard Python type hints. 15 | 16 | - **SQLModel:** An SQL database toolkit for FastAPI that provides a simple way to interact with databases using Python models. 17 | 18 | - **PostgreSQL:** A powerful, open-source relational database system, chosen for its reliability and scalability. 19 | 20 | - **Redis:** An in-memory data structure store used for caching and as a message broker. 21 | 22 | - **Next.js:** A React framework for building server-side rendered and statically generated web applications. 23 | 24 | - **Docker:** Containerization technology to ensure consistent deployment across different environments. 25 | 26 | - **Docker Compose:** A tool for defining and running multi-container Docker applications. 27 | 28 | 29 | ## Features 30 | 31 | 1. **RESTful API Endpoints:** 32 | - Implemented CRUD operations for towns and people. 33 | - Defined RESTful API endpoints using FastAPI decorators. 34 | 35 | 2. **Database Integration:** 36 | - Integrated with a PostgreSQL database using SQLAlchemy. 37 | - Defined data models for towns and people. 38 | 39 | 3. **Data Pagination:** 40 | - Implemented pagination for large datasets for improved performance. 41 | 42 | 4. **Validation and Request Handling:** 43 | - Utilized FastAPI's automatic data validation. 44 | - Implemented request validation using Pydantic models. 45 | 46 | 5. **Rate Limiting:** 47 | - Implemented rate limiting for specific endpoints to control client requests. 48 | 49 | 6. **Dependency Injection:** 50 | - Leveraged FastAPI's dependency injection system for managing database sessions. 51 | 52 | 53 | 8. **API Documentation:** 54 | - Generated API documentation using Swagger UI and ReDoc. 55 | - Documented request/response models, available endpoints, and usage examples. 56 | 57 | 9. **Testing:** 58 | - Wrote unit tests and integration tests using Pytest. 59 | 60 | 10. **Dockerization:** 61 | - Dockerized the FastAPI application for consistent deployment environments. 62 | - Used Docker Compose for managing multi-container applications (frontend, postgres, redis). 63 | 64 | 11. **Database Migrations:** 65 | - Implemented database migrations using Alembic to manage schema changes. 66 | 67 | 12. **Cross-Origin Resource Sharing (CORS):** 68 | - Enabled CORS to control API access from different domains. 69 | 70 | 13. **Environmental Configuration:** 71 | - Used environment variables for configuration settings. 72 | 73 | 14. **Implemented frontend:** 74 | - Used Nextjs to develop a frontend to interact with the API. Utilized docker compose for communication between frontend and backend. 75 | 76 | ## Setup 77 | 78 | ### Cloning and Environment Setup 79 | 1. Clone the repository: `git clone https://github.com/Nneji123/fastapi-nextjs.git` 80 | 2. Navigate to the project directory: `fastapi-nexjs` 81 | 3. Create and activate a virtual environment: 82 | - Using `venv` on Linux: `python3 -m venv env && source env/bin/activate` 83 | - Using `venv` on Windows and Git Bash: `python3 -m venv env && source env/Scripts/activate` 84 | 4. Install dependencies: `cd backend && pip install -r api/requirements.txt` 85 | 5. Setup sqlite database: `python api/init_db.py` 86 | 87 | ### Setup Database Migrations with Alembic 88 | Alembic is used in this project to handle schema changes in the database(add new columns, remove columns etc without deleting the database). 89 | 90 | Assuming we're working in the backend directory: 91 | 92 | 1. Firstly run: `alembic init -t sync migrations` 93 | 2. Within the generated "migrations" folder, import sqlmodel into script.py.mako since this project uses sqlmodel. 94 | 3. Import sqlmodel into the migrations/env.py file: `from sqlmodel import SQLModel` and also import your database tables in the same file: 95 | ``` 96 | from api.public.people.models import Person 97 | from api.public.towns.models import Town 98 | ``` 99 | 4. Set your metadata in the same file: 100 | ``` 101 | # target_metadata = mymodel.Base.metadata 102 | target_metadata = SQLModel.metadata 103 | ``` 104 | 5. To generate the first migration file, run: `alembic revision --autogenerate -m "init"` 105 | 6. Apply the migration: `alembic upgrade head` 106 | 7. To make changes to the database edit the models and then run steps 5 and 6 again. 107 | 108 | ### Running the API 109 | 1. Ensure you are in the project directory (`backend`). 110 | 2. Run the FastAPI application: `python api/asgi.py` 111 | 3. Access the API at `http://localhost:8000` 112 | 113 | You should be able to see the swagger docs at `http://localhost:8000/docs` 114 | 115 | ### Running the Project Using Docker 116 | 1. Ensure Docker and Docker Compose are installed. 117 | 2. Navigate to the project directory: `cd yourproject` 118 | 3. Build and start the containers: `docker-compose up --build` 119 | 4. Access the API at `http://localhost:8000` and the frontend built with nextjs and (https://v0.dev)[v0.dev] at `http://localhost:3000` 120 | 121 | 122 | ## Endpoints 123 | 124 |
125 | 126 | ### Get Single Person 127 | 128 | - **Endpoint:** `GET /{person_id}` 129 | - **Description:** Retrieves details of a single person by ID. 130 | - **Request:** 131 | - Method: `GET` 132 | - Path: `/{person_id}` (Replace `{person_id}` with the actual ID) 133 | - **Response:** 134 | - Status Code: `200 OK` if person is found, `404 Not Found` if person with the specified ID does not exist. 135 | - Body: Person details in the format specified by `PersonReadWithTown` model. 136 | 137 | ### Get All People 138 | 139 | - **Endpoint:** `GET /` 140 | - **Description:** Retrieves a paginated list of all people. 141 | - **Request:** 142 | - Method: `GET` 143 | - Path: `/` 144 | - Query Parameters: `skip` (number of items to skip), `limit` (maximum number of items to return) 145 | - **Response:** 146 | - Status Code: `200 OK` 147 | - Body: Paginated list of people in the format specified by `Page[PersonRead]` model. 148 | 149 | ### Create New Person 150 | 151 | - **Endpoint:** `POST /` 152 | - **Description:** Creates a new person. 153 | - **Request:** 154 | - Method: `POST` 155 | - Path: `/` 156 | - Body: JSON object representing the new person (See `PersonCreate` model) 157 | - **Response:** 158 | - Status Code: `200 OK` if successful, `500 Internal Server Error` if there's an exception during person creation. 159 | - Body: Created person details in the format specified by `Person` model. 160 | 161 | ### Update Existing Person 162 | 163 | - **Endpoint:** `PUT /{person_id}` 164 | - **Description:** Updates details of an existing person by ID. 165 | - **Request:** 166 | - Method: `PUT` 167 | - Path: `/{person_id}` (Replace `{person_id}` with the actual ID) 168 | - Body: JSON object representing the updated person details (See `PersonUpdate` model) 169 | - **Response:** 170 | - Status Code: `200 OK` if successful, `404 Not Found` if person with the specified ID does not exist. 171 | - Body: Updated person details in the format specified by `Person` model. 172 | 173 | ### Delete Existing Person 174 | 175 | - **Endpoint:** `DELETE /{person_id}` 176 | - **Description:** Deletes an existing person by ID. 177 | - **Request:** 178 | - Method: `DELETE` 179 | - Path: `/{person_id}` (Replace `{person_id}` with the actual ID) 180 | - **Response:** 181 | - Status Code: `200 OK` if successful, `404 Not Found` if person with the specified ID does not exist. 182 | - Body: Deleted person details in the format specified by `Person` model. 183 | 184 | Please make sure to replace placeholders like `{person_id}` with actual values when making requests. Additionally, provide appropriate details in the request bodies according to your data models (`PersonCreate`, `PersonUpdate`, etc.). 185 | 186 | ### Create a New Town 187 | 188 | - **Endpoint:** `POST /` 189 | - **Description:** Creates a new town. 190 | - **Request:** 191 | - Method: `POST` 192 | - Path: `/` 193 | - Body: JSON object representing the new town (See `TownCreate` model) 194 | - **Response:** 195 | - Status Code: `200 OK` if successful, `500 Internal Server Error` if there's an exception during town creation. 196 | - Body: 197 | ```json 198 | { 199 | "status": "success", 200 | "msg": "Town created successfully", 201 | "data": { /* Town details */ } 202 | } 203 | ``` 204 | 205 | ### Get Single Town 206 | 207 | - **Endpoint:** `GET /{town_id}` 208 | - **Description:** Retrieves details of a single town by ID. 209 | - **Request:** 210 | - Method: `GET` 211 | - Path: `/{town_id}` (Replace `{town_id}` with the actual ID) 212 | - **Response:** 213 | - Status Code: `200 OK` if town is found, `404 Not Found` if town with the specified ID does not exist. 214 | - Body: Town details in the format specified by `TownReadWithPeople` model. 215 | 216 | ### Get All Towns 217 | 218 | - **Endpoint:** `GET /` 219 | - **Description:** Retrieves a paginated list of all towns. 220 | - **Request:** 221 | - Method: `GET` 222 | - Path: `/` 223 | - Query Parameters: `skip` (number of items to skip), `limit` (maximum number of items to return) 224 | - **Response:** 225 | - Status Code: `200 OK` 226 | - Body: Paginated list of towns in the format specified by `Page[TownRead]` model. 227 | 228 | ### Update Existing Town 229 | 230 | - **Endpoint:** `PUT /{town_id}` 231 | - **Description:** Updates details of an existing town by ID. 232 | - **Request:** 233 | - Method: `PUT` 234 | - Path: `/{town_id}` (Replace `{town_id}` with the actual ID) 235 | - Body: JSON object representing the updated town details (See `TownUpdate` model) 236 | - **Response:** 237 | - Status Code: `200 OK` if successful, `404 Not Found` if town with the specified ID does not exist. 238 | - Body: Updated town details in the format specified by `TownRead` model. 239 | 240 | ### Delete Existing Town 241 | 242 | - **Endpoint:** `DELETE /{town_id}` 243 | - **Description:** Deletes an existing town by ID. 244 | - **Request:** 245 | - Method: `DELETE` 246 | - Path: `/{town_id}` (Replace `{town_id}` with the actual ID) 247 | - **Response:** 248 | - Status Code: `200 OK` if successful, `404 Not Found` if town with the specified ID does not exist. 249 | - Body: 250 | ```json 251 | { 252 | "status": "success", 253 | "msg": "Successfully deleted town with ID {town_id}" 254 | } 255 | ``` 256 | or 257 | ```json 258 | { 259 | "status": "error", 260 | "msg": "Failed to delete town with ID {town_id}" 261 | } 262 | ``` 263 | Certainly! Here's a description for the rate-limited endpoint in your FastAPI project: 264 | 265 | ### Rate-Limited Endpoint 266 | 267 | - **Endpoint:** `GET /rate_limit` 268 | - **Description:** Returns a message indicating that this is a rate-limited endpoint. The endpoint is rate-limited to allow a maximum of 2 requests every 5 seconds. 269 | - **Request:** 270 | - Method: `GET` 271 | - Path: `/rate_limit` 272 | - **Response:** 273 | - Status Code: `200 OK` if the rate limit is not exceeded, `429 Too Many Requests` if the rate limit is exceeded. 274 | - Body: 275 | - If the rate limit is not exceeded: 276 | ```json 277 | { 278 | "Hello": "This is a rate-limited endpoint!" 279 | } 280 | ``` 281 | - If the rate limit is exceeded: 282 | ```json 283 | { 284 | "detail": "Too many requests, try again later." 285 | } 286 | ``` 287 | - **Rate Limit:** 288 | - Maximum Requests: 2 requests per 5 seconds. 289 | 290 | This endpoint is configured to limit the number of requests to 2 every 5 seconds, and it will respond with a success message if the rate limit is not exceeded. If the rate limit is exceeded, it will respond with a "Too Many Requests" error message. 291 | 292 |
293 | 294 | ## Deployment 295 | For deployment I used Koyeb. Koyeb is a developer-friendly serverless platform to deploy apps globally. No-ops, servers, or infrastructure management. You can learn more about it here: `https://www.koyeb.com/tutorials/deploy-apps-using-docker-compose-on-koyeb` 296 | 297 | Follow the steps below to deploy and run the docker-compose application on your Koyeb account. 298 | 299 | ### Requirements 300 | 301 | You need a Koyeb account to successfully deploy and run this application. If you don't already have an account, you can sign-up for free [here](https://app.koyeb.com/auth/signup). 302 | 303 | ### Deploy using the Koyeb button 304 | 305 | The fastest way to deploy the docker-compose application is to click the **Deploy to Koyeb** button below. 306 | 307 | [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=fastapi-nextjs&privileged=true&type=git&repository=koyeb/example-docker-compose&branch=main&builder=dockerfile&dockerfile=Dockerfile.koyeb) 308 | 309 | Clicking on this button brings you to the Koyeb App creation page with everything pre-set to launch this application. 310 | 311 | ### Fork and deploy to Koyeb 312 | 313 | If you want to customize and enhance this application, you need to fork this repository. 314 | 315 | If you used the **Deploy to Koyeb** button, you can simply link your service to your forked repository to be able to push changes. 316 | Alternatively, you can manually create the application as described below. 317 | 318 | On the [Koyeb Control Panel](//app.koyeb.com/apps), click the **Create App** button to go to the App creation page. 319 | 320 | 1. Select `GitHub` as the deployment method to use 321 | 2. In the repositories list, select the repository you just forked 322 | 3. Specify the branch to deploy, in this case `main` 323 | 4. Set the `Dockerfile location` to `Dockerfile.koyeb` 324 | 5. Set the `privileged` flag 325 | 6. Then, give your App a name, i.e `docker-compose-on-koyeb`, and click **Create App.** 326 | 327 | You land on the deployment page where you can follow the build of your docker-compose application. Once the build is completed, your application is being deployed and you will be able to access it via `-.koyeb.app`. 328 | 329 | ## Logging and Metrics 330 | 331 | ### Prometheus 332 | 333 | [Prometheus](https://prometheus.io/) is used to collect and store metrics from your FastAPI application. It gathers data from various services and components, providing insights into the performance and behavior of your application. 334 | 335 | #### Configuration 336 | 337 | The Prometheus configuration is defined in the `prometheus_data/prometheus.yml` file. Make sure to customize this file based on your application's specific metrics and requirements. 338 | 339 | #### Accessing Prometheus 340 | 341 | Prometheus can be accessed at [http://localhost:9090](http://localhost:9090) in your local development environment. 342 | 343 | ### Grafana 344 | 345 | [Grafana](https://grafana.com/) is utilized for visualizing and analyzing the metrics collected by Prometheus. It offers powerful tools for creating dashboards and monitoring the health of your application. 346 | 347 | #### Configuration 348 | 349 | No specific Grafana configuration is needed for basic functionality. However, you can customize Grafana settings and dashboards based on your monitoring needs. 350 | 351 | To learn more about this you can read this article: https://dev.to/ken_mwaura1/getting-started-monitoring-a-fastapi-app-with-grafana-and-prometheus-a-step-by-step-guide-3fbn 352 | 353 | #### Accessing Grafana 354 | 355 | Grafana is accessible at [http://localhost:4000](http://localhost:4000) in your local development environment. 356 | 357 | #### Default Credentials 358 | 359 | - **Username:** admin 360 | - **Password:** admin 361 | 362 | ### Metrics and Dashboards 363 | 364 | Your FastAPI application is instrumented to expose relevant metrics, which are then visualized in Grafana dashboards. These metrics provide valuable insights into request/response times, error rates, and various aspects of your application's performance. 365 | 366 | ## Pagination 367 | Pagination using FastAPI-Pagination is implemented in the get all towns and get all people routes. 368 | 369 | ## Testing 370 | 1. To run tests with in-memory SQLite database: 371 | ``` 372 | cd backend 373 | pip install -r api/requirements.txt 374 | python init_db.py 375 | pytest tests 376 | ``` 377 | 2. Check coverage with: `pytest --cov=yourmodule` 378 | 379 | ## To-Do 380 | 1. [x] Add pagination using FastAPI-Pagination 381 | 2. [x] Write test cases for the api. 382 | 3. [x] Add GitHub Actions template for running tests 383 | 4. [x] Write Makefile for easier project management 384 | 5. [x] Remove unnecessary files 385 | 6. [x] Abstract routes to a `public_routes.py` file 386 | 7. [x] Improve documentation for public routes API 387 | 8. [x] Add Alembic support for database migrations 388 | 9. [ ] Implement either Propelauth or FastAPI-Auth0 for authentication 389 | 10. [x] Implement rate limiting 390 | 11. [x] Allow CORS 391 | 12. [x] Write Docker and Docker Compose files for production deployment. 392 | 13. [ ] Replace current linter with "ruff". 393 | 14. [ ] Use bookmark to add code samples and change logo. generate logo with bing 394 | 15. [x] Create a new branch and remove async functionality for ease of use. 395 | 16. [x] Create a frontend with nextjs/reactjs showing how to use the api 396 | 17. [ ] Background tasks with celery 397 | 18. [ ] Logging with Prometheus and Grafana 398 | 399 | ## References 400 | Certainly! Here are five FastAPI repositories with their names and links: 401 | 402 | 1. **Fast-Api-Grafana-Starter** 403 | - [Repository Link](https://github.com/KenMwaura1/Fast-Api-Grafana-Starter) 404 | 405 | 2. **prometheus-fastapi-instrumentator** 406 | - [Repository Link](https://github.com/trallnag/prometheus-fastapi-instrumentator) 407 | 408 | 3. **fastapi-keycloak** 409 | - [Repository Link](https://github.com/code-specialist/fastapi-keycloak) 410 | Certainly! Here are five more FastAPI repositories with their names and links: 411 | 412 | 4. **propelauth-fastapi** 413 | - [Repository Link](https://github.com/PropelAuth/propelauth-fastapi) 414 | 415 | 5. **Shortify** 416 | - [Repository Link](https://github.com/IHosseini083/Shortify) 417 | 418 | 6. **fastapi-beanie-jwt** 419 | - [Repository Link](https://github.com/flyinactor91/fastapi-beanie-jwt) 420 | 421 | 7. **fastapi-best-practices** 422 | - [Repository Link](https://github.com/zhanymkanov/fastapi-best-practices) 423 | 424 | 8. **fastapi-sqlmodel-alembic** 425 | - [Repository Link](https://github.com/testdrivenio/fastapi-sqlmodel-alembic) 426 | 427 | ## License 428 | This project is licensed under the MIT LICENSE - see the [LICENSE](./LICENSE) file for details. 429 | 430 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .vscode/ 3 | tests/ 4 | .idea/ 5 | database.db/ 6 | .gitpod.yml 7 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | AUTH0_CLIENT_ID= 2 | AUTH0_CLIENT_SECRET= 3 | AUTH0_DOMAIN= 4 | APP_SECRET_KEY= 5 | API_AUDIENCE = 6 | 7 | ENV=development 8 | DATABASE_URI=sqlite:///./database.db 9 | API_USERNAME=svc_test 10 | API_PASSWORD=superstrongpassword 11 | REDIS_DATABASE="" 12 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a base image 2 | FROM python:3.11 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file into the container at /app 8 | COPY api/requirements.txt /app/ 9 | 10 | # Install any needed packages specified in requirements.txt 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | # Copy the entire project directory into the container at /app 14 | COPY . /app/ 15 | 16 | # Expose the port that Uvicorn listens to 17 | EXPOSE 8000 18 | 19 | # Run Uvicorn when the container launches 20 | CMD ["python", "api/asgi.py"] 21 | -------------------------------------------------------------------------------- /backend/api/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = .. 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = sqlite:///../database.db 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 78 | # hooks = ruff 79 | # ruff.type = exec 80 | # ruff.executable = %(here)s/.venv/bin/ruff 81 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 82 | 83 | # Logging configuration 84 | [loggers] 85 | keys = root,sqlalchemy,alembic 86 | 87 | [handlers] 88 | keys = console 89 | 90 | [formatters] 91 | keys = generic 92 | 93 | [logger_root] 94 | level = WARN 95 | handlers = console 96 | qualname = 97 | 98 | [logger_sqlalchemy] 99 | level = WARN 100 | handlers = 101 | qualname = sqlalchemy.engine 102 | 103 | [logger_alembic] 104 | level = INFO 105 | handlers = 106 | qualname = alembic 107 | 108 | [handler_console] 109 | class = StreamHandler 110 | args = (sys.stderr,) 111 | level = NOTSET 112 | formatter = generic 113 | 114 | [formatter_generic] 115 | format = %(levelname)-5.5s [%(name)s] %(message)s 116 | datefmt = %H:%M:%S 117 | -------------------------------------------------------------------------------- /backend/api/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /backend/api/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | from sqlmodel import SQLModel 6 | 7 | from api.public.people.models import Person 8 | from api.public.towns.models import Town 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | target_metadata = SQLModel.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline() -> None: 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure( 45 | url=url, 46 | target_metadata=target_metadata, 47 | literal_binds=True, 48 | dialect_opts={"paramstyle": "named"}, 49 | ) 50 | 51 | with context.begin_transaction(): 52 | context.run_migrations() 53 | 54 | 55 | def run_migrations_online() -> None: 56 | """Run migrations in 'online' mode. 57 | 58 | In this scenario we need to create an Engine 59 | and associate a connection with the context. 60 | 61 | """ 62 | connectable = engine_from_config( 63 | config.get_section(config.config_ini_section, {}), 64 | prefix="sqlalchemy.", 65 | poolclass=pool.NullPool, 66 | ) 67 | 68 | with connectable.connect() as connection: 69 | context.configure(connection=connection, target_metadata=target_metadata) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | if context.is_offline_mode(): 76 | run_migrations_offline() 77 | else: 78 | run_migrations_online() 79 | -------------------------------------------------------------------------------- /backend/api/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = ${repr(up_revision)} 17 | down_revision: Union[str, None] = ${repr(down_revision)} 18 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 19 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 20 | 21 | 22 | def upgrade() -> None: 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /backend/api/alembic/versions/059fcd0d080b_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 059fcd0d080b 4 | Revises: 5 | Create Date: 2024-01-08 10:37:34.448728 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | import sqlalchemy as sa 11 | import sqlmodel 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "059fcd0d080b" 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.drop_index("ix_person_gender", table_name="person") 24 | op.drop_column("person", "gender") 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade() -> None: 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.add_column("person", sa.Column("gender", sa.VARCHAR(), nullable=True)) 31 | op.create_index("ix_person_gender", "person", ["gender"], unique=False) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /backend/api/alembic/versions/e1f2c2675da2_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: e1f2c2675da2 4 | Revises: 059fcd0d080b 5 | Create Date: 2024-01-08 10:38:09.188973 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | import sqlalchemy as sa 11 | import sqlmodel 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "e1f2c2675da2" 16 | down_revision: Union[str, None] = "059fcd0d080b" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column( 24 | "person", sa.Column("gender", sqlmodel.sql.sqltypes.AutoString(), nullable=True) 25 | ) 26 | op.create_index(op.f("ix_person_gender"), "person", ["gender"], unique=False) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade() -> None: 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_index(op.f("ix_person_gender"), table_name="person") 33 | op.drop_column("person", "gender") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /backend/api/app.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from fastapi_pagination import add_pagination 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | from api.config import Settings 9 | from api.database import create_db_and_tables, create_town_and_people, get_db 10 | from api.public.routes import public_router 11 | from api.utils import * 12 | from prometheus_fastapi_instrumentator import Instrumentator 13 | import redis.asyncio as redis 14 | from fastapi import FastAPI 15 | from dotenv import load_dotenv 16 | import os 17 | from fastapi_limiter import FastAPILimiter 18 | 19 | env_path = "../.env" 20 | load_dotenv(env_path) 21 | 22 | REDIS_ENV = os.getenv("REDIS_DATABASE" ,"redis://redis:6379/") 23 | 24 | @asynccontextmanager 25 | async def lifespan(app: FastAPI): 26 | db = next(get_db()) # Fetching the database session 27 | create_db_and_tables() 28 | redis_connection= redis.from_url(REDIS_ENV, encoding="utf-8", decode_responses=True) 29 | await FastAPILimiter.init(redis_connection) 30 | try: 31 | create_town_and_people(db) 32 | yield 33 | except (IntegrityError, Exception) as e: 34 | yield 35 | 36 | 37 | def create_app(settings: Settings): 38 | app = FastAPI( 39 | title=settings.PROJECT_NAME, 40 | version=settings.VERSION, 41 | docs_url="/docs", 42 | description=settings.DESCRIPTION, 43 | lifespan=lifespan, 44 | ) 45 | 46 | app.add_middleware( 47 | CORSMiddleware, 48 | allow_origins=["http://frontend:8080/", 49 | "http://frontend:8080/", 50 | "*"], 51 | # origins, 52 | allow_credentials=True, 53 | allow_methods=["*"], 54 | allow_headers=["*"], 55 | ), 56 | app.include_router(public_router) 57 | Instrumentator().instrument(app).expose(app) 58 | add_pagination(app) 59 | return app 60 | -------------------------------------------------------------------------------- /backend/api/asgi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | import time 4 | import uvicorn 5 | 6 | # Get the absolute path of the directory containing this script 7 | current_file = Path(__file__).resolve() 8 | parent_directory = current_file.parent 9 | project_directory = parent_directory.parent 10 | 11 | sys.path.insert(0, str(project_directory)) 12 | 13 | from api.app import create_app 14 | from api.config import settings 15 | 16 | api = create_app(settings) 17 | 18 | if __name__ == "__main__": 19 | time.sleep(10) 20 | uvicorn.run("asgi:api", host="0.0.0.0", port=8000, reload=True) 21 | -------------------------------------------------------------------------------- /backend/api/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # from propelauth_fastapi import init_auth 2 | 3 | # auth = init_auth() 4 | -------------------------------------------------------------------------------- /backend/api/auth/kindeauth.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, Depends 2 | from fastapi.security import OAuth2PasswordBearer 3 | import requests 4 | from dotenv import load_dotenv 5 | import os 6 | 7 | app = FastAPI() 8 | 9 | # Load environment variables from .env file 10 | load_dotenv() 11 | 12 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 13 | 14 | # Dependency function to get the access token 15 | def get_access_token(): 16 | your_domain = os.getenv("DOMAIN") 17 | client_id = os.getenv("CLIENT_ID") 18 | client_secret = os.getenv("CLIENT_SECRET") 19 | 20 | access_token_url = f"https://fastscraper.kinde.com/oauth2/token" 21 | 22 | payload = { 23 | "grant_type": "client_credentials", 24 | "client_id": client_id, 25 | "client_secret": client_secret 26 | } 27 | 28 | headers = { 29 | "content-type": "application/x-www-form-urlencoded" 30 | } 31 | 32 | try: 33 | response = requests.post(access_token_url, data=payload, headers=headers) 34 | response.raise_for_status() 35 | return response.json()["access_token"] 36 | except requests.exceptions.RequestException as e: 37 | raise HTTPException(status_code=500, detail=f"Error: {e}") 38 | 39 | -------------------------------------------------------------------------------- /backend/api/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | from typing import Literal 4 | 5 | from pydantic_settings import BaseSettings, SettingsConfigDict 6 | 7 | 8 | def load_env(): 9 | from dotenv import load_dotenv 10 | 11 | env_path = "../.env" 12 | load_dotenv(env_path) 13 | 14 | 15 | class Settings(BaseSettings): 16 | PROJECT_NAME: str = ( 17 | f"FastAPI Server - {os.getenv('ENV', 'development').capitalize()}" 18 | ) 19 | DESCRIPTION: str = "FastAPI + Nextjs Example" 20 | ENV: Literal["development", "staging", "production"] = "development" 21 | VERSION: str = "0.1" 22 | SECRET_KEY: str = secrets.token_urlsafe(32) 23 | DATABASE_URI: str = os.getenv("DATABASE_URI", "postgresql://user:password@postgres:5432/dbname") 24 | 25 | class Config: 26 | case_sensitive = True 27 | 28 | 29 | class TestSettings(Settings): 30 | DATABASE_URI: str = os.getenv("TEST_DATABASE_URI", "sqlite+aiosqlite://") 31 | 32 | class Config: 33 | case_sensitive = True 34 | 35 | 36 | settings = Settings() 37 | test_settings = TestSettings() 38 | -------------------------------------------------------------------------------- /backend/api/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 2 | from sqlalchemy.orm import sessionmaker 3 | from sqlmodel import Session, SQLModel, create_engine 4 | from sqlmodel.ext.asyncio import session 5 | 6 | from api.config import settings 7 | from api.public.people.crud import create_person 8 | from api.public.people.models import PersonCreate 9 | from api.public.towns.crud import create_town 10 | from api.public.towns.models import TownCreate 11 | 12 | # connect_args = {"check_same_thread": False} 13 | 14 | engine = create_engine(settings.DATABASE_URI, echo=False) 15 | 16 | 17 | def create_db_and_tables(): 18 | SQLModel.metadata.create_all(engine) 19 | 20 | 21 | def get_db(): 22 | with Session(engine) as session: 23 | yield session 24 | 25 | 26 | def create_town_and_people(db: Session): 27 | # Create towns 28 | town_data = [ 29 | {"name": "Town A", "population": 10000, "country": "Country A"}, 30 | {"name": "Town B", "population": 15000, "country": "Country B"}, 31 | # Add more towns as needed 32 | ] 33 | 34 | created_towns = [] 35 | for town_info in town_data: 36 | town = TownCreate(**town_info) 37 | created_town = create_town(db, town) 38 | created_towns.append(created_town) 39 | 40 | # Create people 41 | people_data = [ 42 | {"name": "Alice", "age": 30, "gender": "female", "town_id": created_towns[0].id}, 43 | {"name": "Bob", "age": 25, "gender": "male", "town_id": created_towns[1].id}, 44 | # Assign people to towns created above, adjust town_id as needed 45 | ] 46 | 47 | created_people = [] 48 | for person_info in people_data: 49 | person = PersonCreate(**person_info) 50 | created_person = create_person(db, person) 51 | created_people.append(created_person) 52 | 53 | return created_towns, created_people 54 | -------------------------------------------------------------------------------- /backend/api/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 2 | from sqlalchemy.orm import sessionmaker 3 | from sqlmodel import Session, SQLModel, create_engine 4 | from sqlmodel.ext.asyncio import session 5 | from sqlalchemy.exc import IntegrityError 6 | 7 | import sys 8 | from pathlib import Path 9 | 10 | # Get the absolute path of the directory containing this script 11 | current_file = Path(__file__).resolve() 12 | parent_directory = current_file.parent 13 | project_directory = parent_directory.parent 14 | 15 | sys.path.insert(0, str(project_directory)) 16 | 17 | from api.database import create_db_and_tables, create_town_and_people, get_db 18 | 19 | 20 | def create_database_for_tests(): 21 | db = next(get_db()) # Fetching the database session 22 | create_db_and_tables() 23 | try: 24 | create_town_and_people(db) 25 | except (IntegrityError, Exception) as e: 26 | print(e) 27 | pass 28 | 29 | create_database_for_tests() 30 | -------------------------------------------------------------------------------- /backend/api/public/__init__.py: -------------------------------------------------------------------------------- 1 | """Public API People and Town Data""" 2 | -------------------------------------------------------------------------------- /backend/api/public/people/crud.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from sqlmodel import Session, select 4 | 5 | from api.public.people.models import Person, PersonCreate, PersonUpdate 6 | 7 | 8 | def create_person(db: Session, person: PersonCreate) -> Person: 9 | db_person = Person(**person.model_dump()) 10 | db.add(db_person) 11 | db.commit() 12 | db.refresh(db_person) 13 | return db_person 14 | 15 | 16 | def get_person(db: Session, person_id: int) -> Optional[Person]: 17 | query = select(Person).where(Person.id == person_id) 18 | return db.exec(query).first() 19 | 20 | 21 | def get_people(db: Session, skip: int = 0, limit: int = 10) -> List[Person]: 22 | query = select(Person).offset(skip).limit(limit) 23 | return db.exec(query).all() 24 | 25 | 26 | def update_person(db: Session, person: Person, updated_person: PersonUpdate) -> Person: 27 | for key, value in updated_person.model_dump(exclude_unset=True).items(): 28 | setattr(person, key, value) 29 | db.add(person) 30 | db.commit() 31 | db.refresh(person) 32 | return person 33 | 34 | 35 | def delete_person(db: Session, person_id: int) -> Person: 36 | person = db.get(Person, person_id) 37 | db.delete(person) 38 | db.commit() 39 | return person 40 | -------------------------------------------------------------------------------- /backend/api/public/people/models.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional 2 | 3 | from sqlmodel import Field, Relationship, SQLModel 4 | 5 | if TYPE_CHECKING: 6 | from api.public.towns.models import Town 7 | 8 | 9 | class PersonBase(SQLModel): 10 | name: str = Field(index=True, unique=True) 11 | gender: Optional[str] = Field(index=True) 12 | age: Optional[int] = Field(default=None, index=True) 13 | town_id: Optional[int] = Field(default=None, foreign_key="town.id") 14 | 15 | 16 | class Person(PersonBase, table=True): 17 | id: Optional[int] = Field(default=None, primary_key=True) 18 | 19 | towns: Optional["Town"] = Relationship(back_populates="people") 20 | 21 | 22 | class PersonCreate(PersonBase): 23 | pass 24 | 25 | 26 | class PersonRead(PersonBase): 27 | id: int 28 | 29 | 30 | class PersonUpdate(SQLModel): 31 | name: Optional[str] = None 32 | age: Optional[int] = None 33 | town_id: Optional[int] = None 34 | 35 | 36 | class PersonReadWithTown(PersonRead): 37 | town: Optional[PersonRead] = None 38 | -------------------------------------------------------------------------------- /backend/api/public/people/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from sqlmodel import Session 3 | from starlette.responses import JSONResponse 4 | from fastapi_pagination import Page, paginate 5 | from api.database import get_db 6 | from api.public.people.crud import ( 7 | create_person, 8 | delete_person, 9 | get_people, 10 | get_person, 11 | update_person, 12 | ) 13 | from api.public.people.models import ( 14 | Person, 15 | PersonCreate, 16 | PersonRead, 17 | PersonReadWithTown, 18 | PersonUpdate, 19 | ) 20 | 21 | router = APIRouter() 22 | 23 | 24 | @router.get("/{person_id}", response_model=PersonReadWithTown) 25 | def get_single_person(person_id: int, db: Session = Depends(get_db)): 26 | person = get_person(db, person_id) 27 | if person is None: 28 | raise HTTPException(status_code=404, detail="Person not found") 29 | return person 30 | 31 | 32 | @router.get("/", response_model=Page[PersonRead]) 33 | def get_all_people(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): 34 | return paginate(get_people(db, skip=skip, limit=limit)) 35 | 36 | 37 | @router.post("/", response_model=Person) 38 | def create_new_person(person: PersonCreate, db: Session = Depends(get_db)): 39 | try: 40 | created_person = create_person(db, person) 41 | return created_person 42 | except Exception as e: 43 | return JSONResponse( 44 | content={"status": "error", "msg": f"Failed to create person: {str(e)}"}, 45 | status_code=500, 46 | ) 47 | 48 | 49 | @router.put("/{person_id}", response_model=Person) 50 | def update_existing_person( 51 | person_id: int, updated_person: PersonUpdate, db: Session = Depends(get_db) 52 | ): 53 | person = get_person(db, person_id) 54 | if person is None: 55 | raise HTTPException(status_code=404, detail="Person not found") 56 | try: 57 | updated = update_person(db, person, updated_person) 58 | return updated 59 | except Exception as e: 60 | return JSONResponse( 61 | content={"status": "error", "msg": f"Failed to update person: {str(e)}"}, 62 | status_code=500, 63 | ) 64 | 65 | 66 | @router.delete("/{person_id}", response_model=Person) 67 | def delete_existing_person(person_id: int, db: Session = Depends(get_db)): 68 | person = get_person(db, person_id) 69 | if person is None: 70 | raise HTTPException(status_code=404, detail="Person not found") 71 | try: 72 | deleted = delete_person(db, person_id) 73 | return deleted 74 | except Exception as e: 75 | return JSONResponse( 76 | content={"status": "error", "msg": f"Failed to delete person: {str(e)}"}, 77 | status_code=500, 78 | ) 79 | -------------------------------------------------------------------------------- /backend/api/public/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from fastapi_limiter.depends import RateLimiter 3 | 4 | 5 | from api.public.people.routes import router as peoplerouter 6 | from api.public.towns.routes import router as townrouter 7 | 8 | 9 | public_router = APIRouter() 10 | 11 | @public_router.get("/rate_limit", dependencies=[Depends(RateLimiter(times=2, seconds=5))]) 12 | def test_rate_limit(): 13 | return {"Hello": "This is a rate limited endpoint!"} 14 | 15 | 16 | public_router.include_router(peoplerouter, prefix="/people", tags=["People"]) 17 | public_router.include_router(townrouter, prefix="/towns", tags=["Towns"]) 18 | -------------------------------------------------------------------------------- /backend/api/public/towns/crud.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from sqlmodel import Session, select 4 | 5 | from api.public.towns.models import Town, TownCreate, TownUpdate 6 | 7 | 8 | def create_town(db: Session, town: TownCreate) -> Town: 9 | db_town = Town(**town.model_dump()) 10 | db.add(db_town) 11 | db.commit() 12 | db.refresh(db_town) 13 | return db_town 14 | 15 | 16 | def get_town(db: Session, town_id: int) -> Optional[Town]: 17 | query = select(Town).where(Town.id == town_id) 18 | return db.exec(query).first() 19 | 20 | 21 | def get_towns(db: Session, skip: int = 0, limit: int = 10) -> List[Town]: 22 | query = select(Town).offset(skip).limit(limit) 23 | return db.exec(query).all() 24 | 25 | 26 | def update_town(db: Session, town: Town, updated_town: TownUpdate) -> Town: 27 | for key, value in updated_town.model_dump(exclude_unset=True).items(): 28 | setattr(town, key, value) 29 | db.add(town) 30 | db.commit() 31 | db.refresh(town) 32 | return town 33 | 34 | 35 | def delete_town(db: Session, town_id: int) -> Town: 36 | town = db.get(Town, town_id) 37 | if town: 38 | db.delete(town) 39 | db.commit() 40 | return town 41 | -------------------------------------------------------------------------------- /backend/api/public/towns/models.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional 2 | 3 | from sqlmodel import Field, Relationship, SQLModel 4 | 5 | 6 | if TYPE_CHECKING: 7 | from api.public.people.models import Person 8 | 9 | 10 | class TownBase(SQLModel): 11 | """Base Town class""" 12 | 13 | name: str = Field(index=True, unique=True) 14 | population: int = Field(default=None, index=True) 15 | country: Optional[str] = Field(default=None) 16 | 17 | 18 | class Town(TownBase, table=True): 19 | """Town Table""" 20 | 21 | id: Optional[int] = Field(default=None, primary_key=True) 22 | 23 | people: List["Person"] = Relationship(back_populates="towns") 24 | 25 | 26 | class TownCreate(TownBase): 27 | """Create a town""" 28 | 29 | pass 30 | 31 | 32 | class TownRead(TownBase): 33 | """Read details of a town.""" 34 | 35 | id: int 36 | 37 | 38 | class TownUpdate(SQLModel): 39 | """Update details of a town.""" 40 | 41 | name: Optional[str] = None 42 | population: Optional[int] = None 43 | country: Optional[str] = None 44 | 45 | 46 | class TownReadWithPeople(TownRead): 47 | """Get towns and a list of people in that town""" 48 | 49 | people: List[TownRead] = [] 50 | -------------------------------------------------------------------------------- /backend/api/public/towns/routes.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from fastapi_pagination import Page, paginate 5 | from fastapi_pagination.utils import disable_installed_extensions_check 6 | from sqlalchemy.orm import Session 7 | from starlette.responses import JSONResponse 8 | 9 | from api.database import get_db 10 | from api.public.towns.crud import ( 11 | create_town, 12 | delete_town, 13 | get_town, 14 | get_towns, 15 | update_town, 16 | ) 17 | from api.public.towns.models import ( 18 | Town, 19 | TownCreate, 20 | TownRead, 21 | TownReadWithPeople, 22 | TownUpdate, 23 | ) 24 | 25 | router = APIRouter() 26 | disable_installed_extensions_check() 27 | 28 | @router.post("/", response_model=TownRead) 29 | def create_new_town(town: TownCreate, db: Session = Depends(get_db)): 30 | try: 31 | created_town = create_town(db, town) 32 | return JSONResponse( 33 | content={ 34 | "status": "success", 35 | "msg": "Town created successfully", 36 | "data": created_town.model_dump(), 37 | } 38 | ) 39 | except Exception as e: 40 | return JSONResponse( 41 | content={"status": "error", "msg": f"Failed to create town: {str(e)}"}, 42 | status_code=500, 43 | ) 44 | 45 | 46 | @router.get("/{town_id}", response_model=TownReadWithPeople) 47 | def get_single_town(town_id: int, db: Session = Depends(get_db)): 48 | town = get_town(db, town_id) 49 | if town is None: 50 | raise HTTPException(status_code=404, detail="Town not found") 51 | return town 52 | 53 | 54 | @router.get("/", response_model=Page[TownRead]) 55 | def get_all_towns(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): 56 | return paginate(get_towns(db, skip=skip, limit=limit)) 57 | 58 | 59 | @router.put("/{town_id}", response_model=TownRead) 60 | def update_existing_town(town_id: int, town: TownUpdate, db: Session = Depends(get_db)): 61 | existing_town = get_town(db, town_id) 62 | if existing_town is None: 63 | raise HTTPException(status_code=404, detail="Town not found") 64 | return update_town(db, existing_town, town) 65 | 66 | 67 | @router.delete("/{town_id}", response_model=TownRead) 68 | def delete_existing_town(town_id: int, db: Session = Depends(get_db)): 69 | delete_result = delete_town(db, town_id) 70 | if delete_result: 71 | return JSONResponse( 72 | content={ 73 | "status": "success", 74 | "msg": f"Successfully deleted town with ID {town_id}", 75 | } 76 | ) 77 | else: 78 | return JSONResponse( 79 | content={ 80 | "status": "error", 81 | "msg": f"Failed to delete town with ID {town_id}", 82 | }, 83 | status_code=500, 84 | ) 85 | -------------------------------------------------------------------------------- /backend/api/requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.13.1 2 | annotated-types==0.6.0 3 | anyio==4.2.0 4 | async-timeout==4.0.3 5 | asyncpg==0.29.0 6 | black==23.12.1 7 | click==8.1.7 8 | colorama==0.4.6 9 | docopt==0.6.2 10 | ecdsa==0.18.0 11 | fastapi==0.108.0 12 | fastapi-auth0==0.5.0 13 | pipreqs 14 | psycopg2-binary 15 | fastapi-pagination 16 | prometheus-client==0.19.0 17 | prometheus-fastapi-instrumentator==6.1.0 18 | fastapi-limiter==0.1.6 19 | greenlet==3.0.3 20 | gunicorn==21.2.0 21 | h11==0.14.0 22 | httpcore==1.0.2 23 | httpx==0.26.0 24 | iniconfig==2.0.0 25 | Mako==1.3.0 26 | pathspec==0.12.1 27 | pipreqs==0.4.13 28 | pluggy==1.3.0 29 | pyasn1==0.5.1 30 | pydantic==2.5.3 31 | pydantic-settings==2.1.0 32 | pydantic_core==2.14.6 33 | pytest==7.4.4 34 | pytest-cov 35 | python-dotenv==1.0.0 36 | python-jose==3.3.0 37 | redis==5.1.0b1 38 | rsa==4.9 39 | SQLAlchemy==2.0.25 40 | sqlmodel==0.0.14 41 | starlette==0.32.0.post1 42 | uvicorn==0.25.0 43 | yarg==0.1.9 44 | -------------------------------------------------------------------------------- /backend/api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """App helper functions""" 2 | import os 3 | 4 | origins = [ 5 | "http://localhost.tiangolo.com", 6 | "https://localhost.tiangolo.com", 7 | "http://localhost", 8 | "http://localhost:8080", 9 | ] 10 | 11 | 12 | lable_lang_mapping = {"Plain JS": "JavaScript", "NodeJS": "JavaScript"} 13 | 14 | 15 | def add_examples(openapi_schema: dict, docs_dir): 16 | path_key = "paths" 17 | code_key = "x-codeSamples" 18 | 19 | for folder in os.listdir(docs_dir): 20 | base_path = os.path.join(docs_dir, folder) 21 | files = [ 22 | f 23 | for f in os.listdir(base_path) 24 | if os.path.isfile(os.path.join(base_path, f)) 25 | ] 26 | for f in files: 27 | parts = f.split("-") 28 | if len(parts) >= 2: 29 | route = "/" + "/".join(parts[:-1]) 30 | method = parts[-1].split(".")[0] 31 | print(f"[{path_key}][{route}][{method}][{code_key}]") 32 | 33 | if route in openapi_schema[path_key]: 34 | if code_key not in openapi_schema[path_key][route][method]: 35 | openapi_schema[path_key][route][method].update({code_key: []}) 36 | 37 | openapi_schema[path_key][route][method][code_key].append( 38 | { 39 | "lang": lable_lang_mapping[folder], 40 | "source": open(os.path.join(base_path, f), "r").read(), 41 | "label": folder, 42 | } 43 | ) 44 | else: 45 | print(f"Error in adding examples code to openapi {f}") 46 | 47 | return openapi_schema 48 | -------------------------------------------------------------------------------- /backend/tests/requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.6.0 2 | anyio==4.2.0 3 | async-timeout==4.0.3 4 | certifi==2023.11.17 5 | charset-normalizer==3.3.2 6 | click==8.1.7 7 | colorama==0.4.6 8 | docopt==0.6.2 9 | ecdsa==0.18.0 10 | fastapi==0.108.0 11 | greenlet==3.0.3 12 | h11==0.14.0 13 | idna==3.6 14 | Mako==1.3.0 15 | MarkupSafe==2.1.3 16 | mypy-extensions==1.0.0 17 | packaging==23.2 18 | pathspec==0.12.1 19 | platformdirs==4.1.0 20 | pyasn1==0.5.1 21 | pydantic==2.5.3 22 | pydantic-settings==2.1.0 23 | pydantic_core==2.14.6 24 | python-dotenv==1.0.0 25 | psycopg2-binary 26 | python-jose==3.3.0 27 | pytest 28 | httpx 29 | redis==5.1.0b1 30 | requests==2.31.0 31 | rsa==4.9 32 | six==1.16.0 33 | sniffio==1.3.0 34 | SQLAlchemy==2.0.25 35 | sqlmodel==0.0.14 36 | starlette==0.32.0.post1 37 | typing_extensions==4.9.0 38 | urllib3==2.1.0 39 | uvicorn==0.25.0 40 | yarg==0.1.9 41 | -------------------------------------------------------------------------------- /backend/tests/test_towns.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time # Import the time module 4 | import warnings 5 | 6 | import pytest 7 | from fastapi.testclient import TestClient 8 | from sqlmodel import Session, SQLModel, create_engine 9 | 10 | # Get the current directory (where the test_towns.py file is located) 11 | current_dir = os.path.dirname(os.path.realpath(__file__)) 12 | 13 | # Append the parent directory (project root) to the Python path 14 | project_root = os.path.dirname(current_dir) 15 | sys.path.insert(0, project_root) 16 | 17 | from api.app import create_app # Replace with your actual FastAPI app file 18 | from api.config import test_settings 19 | 20 | # Now you should be able to import your modules using absolute paths 21 | from api.public.towns.models import * # Replace with your Town model 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def test_app(settings=test_settings): 26 | # Create an in-memory SQLite database for testing 27 | engine = create_engine("sqlite:///:memory:") 28 | SQLModel.metadata.create_all(engine) 29 | 30 | # Create a session per test module 31 | app = create_app(settings) 32 | with Session(engine) as session: 33 | yield TestClient(app), session 34 | 35 | 36 | def test_create_town(test_app): 37 | with warnings.catch_warnings(): 38 | warnings.simplefilter("ignore") 39 | client, session = test_app 40 | 41 | # Generate a unique name using a timestamp 42 | town_name = f"Town_{int(time.time())}" 43 | 44 | town_data = {"name": town_name, "population": 10000, "country": "Country A"} 45 | 46 | # Use the session from the fixture to interact with the database 47 | created_town = Town(**town_data) 48 | session.add(created_town) 49 | session.commit() 50 | 51 | response = client.post("/towns/", json=town_data) 52 | print(response.text) 53 | assert response.status_code == 200 54 | 55 | fetched_town = response.json() 56 | assert fetched_town["status"] == "success" # Verify the overall status 57 | assert ( 58 | fetched_town["msg"] == "Town created successfully" 59 | ) # Verify the success message 60 | assert fetched_town["data"]["name"] == town_name 61 | 62 | 63 | def test_get_single_town(test_app): 64 | with warnings.catch_warnings(): 65 | warnings.simplefilter("ignore") 66 | client, session = test_app 67 | town_name = f"Town_{int(time.time())}s" 68 | 69 | town_data = {"name": town_name, "population": 10000, "country": "Country A"} 70 | 71 | # Use the session from the fixture to interact with the database 72 | created_town = Town(**town_data) 73 | session.add(created_town) 74 | session.commit() 75 | 76 | # Assuming town with ID=1 exists in the database 77 | response = client.get(f"/towns/{created_town.id}") 78 | assert response.status_code == 200 79 | assert isinstance(response.json(), TownReadWithPeople) == False 80 | 81 | 82 | def test_get_all_towns(test_app): 83 | with warnings.catch_warnings(): 84 | warnings.simplefilter("ignore") 85 | client, _ = test_app 86 | 87 | response = client.get("/towns/") 88 | assert response.status_code == 200 89 | assert isinstance(response.json(), list) 90 | for town in response.json(): 91 | assert isinstance(town, TownRead) == False 92 | 93 | 94 | def test_update_existing_town(test_app): 95 | with warnings.catch_warnings(): 96 | warnings.simplefilter("ignore") 97 | client, _ = test_app 98 | 99 | # Assuming town with ID=1 exists in the database 100 | town_update_data = { 101 | "name": "Updated Town Name", 102 | "population": 15000, 103 | "country": "Updated Country", 104 | } 105 | response = client.put("/towns/1", json=town_update_data) 106 | assert response.status_code == 200 107 | assert isinstance(response.json(), TownRead) == False 108 | 109 | 110 | def test_delete_existing_town(test_app): 111 | with warnings.catch_warnings(): 112 | warnings.simplefilter("ignore") 113 | client, _ = test_app 114 | 115 | # Assuming town with ID=1 exists in the database 116 | response = client.delete("/towns/1") 117 | assert response.status_code == 200 118 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: ./frontend # Path to your Next.js frontend code 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8080:8080" # Expose the Next.js development server port 10 | depends_on: 11 | - backend 12 | networks: 13 | - my-network 14 | 15 | backend: 16 | build: 17 | context: ./backend 18 | dockerfile: Dockerfile 19 | ports: 20 | - "8000:8000" 21 | depends_on: 22 | - postgres 23 | - redis 24 | networks: 25 | - my-network 26 | environment: 27 | - DATABASE_URL=postgresql://user:password@postgres:5432/dbname # Update with your PostgreSQL connection details 28 | - REDIS_URL=redis://redis:6379/ # Update with your Redis connection details 29 | 30 | postgres: 31 | image: bitnami/postgresql:13.3.0 32 | restart: always 33 | container_name: postgres 34 | # env_file: ".env" 35 | # user: root 36 | volumes: 37 | - postgres_data:/var/lib/postgresql/data 38 | networks: 39 | - my-network 40 | ports: 41 | - 5454:5432 # Remove this on production 42 | expose: 43 | - 5432 44 | environment: 45 | - POSTGRES_USERNAME=user 46 | - POSTGRES_PASSWORD=password 47 | - POSTGRES_DATABASE=dbname 48 | 49 | redis: 50 | image: redis:latest 51 | networks: 52 | - my-network 53 | 54 | prometheus: 55 | image: prom/prometheus 56 | container_name: prometheus 57 | ports: 58 | - 9090:9090 59 | volumes: 60 | - ./prometheus_data/prometheus.yml:/etc/prometheus/prometheus.yml 61 | command: 62 | - '--config.file=/etc/prometheus/prometheus.yml' 63 | 64 | 65 | grafana: 66 | image: grafana/grafana 67 | container_name: grafana 68 | ports: 69 | - 3000:3000 70 | volumes: 71 | - ./grafana/provisioning/:/etc/grafana/provisioning/ 72 | - ./data/grafana:/var/lib/grafana/ 73 | user: '0' 74 | volumes: 75 | prometheus_data: 76 | driver: local 77 | driver_opts: 78 | o: bind 79 | type: none 80 | device: ./prometheus_data 81 | grafana_data: 82 | driver: local 83 | driver_opts: 84 | o: bind 85 | type: none 86 | device: ./grafana_data 87 | postgres_data: 88 | 89 | networks: 90 | my-network: 91 | driver: bridge 92 | 93 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | .git/ 4 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # ./frontend/Dockerfile 2 | FROM node:latest 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 8080 13 | 14 | CMD ["yarn", "dev", "-p", "8080"] 15 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": false 11 | }, 12 | "aliases": { 13 | "utils": "@/lib/utils", 14 | "components": "@/components" 15 | } 16 | } -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-icons": "^1.3.0", 13 | "@radix-ui/react-slot": "^1.0.2", 14 | "class-variance-authority": "^0.7.0", 15 | "clsx": "^2.1.0", 16 | "lucide-react": "^0.309.0", 17 | "next": "14.0.4", 18 | "react": "^18", 19 | "react-dom": "^18", 20 | "tailwind-merge": "^2.2.0", 21 | "tailwindcss-animate": "^1.0.7" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20", 25 | "@types/react": "^18", 26 | "@types/react-dom": "^18", 27 | "autoprefixer": "^10.0.1", 28 | "eslint": "^8", 29 | "eslint-config-next": "14.0.4", 30 | "postcss": "^8", 31 | "tailwindcss": "^3.3.0", 32 | "typescript": "^5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nneji123/fastapi-nextjs/9fef4708f44c6a1b9ed7df7671dd1c23914f1010/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | 7 | export const metadata: Metadata = { 8 | title: 'Create Next App', 9 | description: 'Generated by create next app', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { Button } from "@/components/ui/button"; 5 | import { CardContent, Card } from "@/components/ui/card"; 6 | 7 | type TownRead = { 8 | id: number; 9 | name: string; 10 | population: number; 11 | country: string; 12 | // Add other fields as needed 13 | }; 14 | 15 | type PeopleRead = { 16 | id: number; 17 | name: string; 18 | gender: string; 19 | age: string; 20 | // Add other fields as needed 21 | }; 22 | 23 | const TownData = () => { 24 | const [townData, setTownData] = useState(null); 25 | const [peopleData, setPeopleData] = useState(null); 26 | 27 | useEffect(() => { 28 | // Fetch town data from your FastAPI endpoint 29 | fetch('http://localhost:8000/towns/') 30 | .then(response => response.json()) 31 | .then(data => setTownData(data)) 32 | .catch(error => console.error('Error fetching town data:', error)); 33 | 34 | // Fetch people data from your FastAPI endpoint 35 | fetch('http://localhost:8000/people/') 36 | .then(response => response.json()) 37 | .then(data => setPeopleData(data)) 38 | .catch(error => console.error('Error fetching people data:', error)); 39 | }, []); 40 | 41 | if (!townData || !peopleData) { 42 | return "API Resolution Error!"; // or a loading indicator 43 | } 44 | 45 | return ( 46 |
47 |
48 |

Town & People Data

49 | 52 |
53 |
54 |
55 |

Town Data

56 |
57 | {Array.isArray(townData) ? ( 58 | townData.map(town => ( 59 | 60 | 61 |

{town.name}

62 |

Population: {town.population}

63 | {/* Add other town-related information here */} 64 |
65 |
66 | )) 67 | ) : ( 68 |

No town data available

69 | )} 70 |
71 |
72 |
73 |

People Data

74 |
75 | {Array.isArray(peopleData) ? ( 76 | peopleData.map(person => ( 77 | 78 | 79 |

{person.name}

80 |

Age: {person.age}

81 |

Gender: {person.gender}

82 | {/* Add other person-related information here */} 83 |
84 |
85 | )) 86 | ) : ( 87 |

No people data available

88 | )} 89 |
90 |
91 |
92 |
93 |

94 | © 2024 Town & People Data. All rights reserved. 95 |

96 |
97 |
98 | ); 99 | }; 100 | 101 | export default TownData; 102 | -------------------------------------------------------------------------------- /frontend/src/components/home.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This code was generated by v0 by Vercel. 3 | * @see https://v0.dev/t/j0VbVC89oHF 4 | */ 5 | import { Button } from "@/components/ui/button" 6 | import { CardContent, Card } from "@/components/ui/card" 7 | 8 | export function home() { 9 | return ( 10 |
11 |
12 |

Town & People Data

13 | 16 |
17 |
18 |
19 |

Town Data

20 |
21 | 22 | 23 |

Town Name

24 |

Population: 5000

25 |

Location: 40.7128° N, 74.0060° W

26 |
27 |
28 |
29 |
30 |
31 |

People Data

32 |
33 | 34 | 35 |

Person Name

36 |

Age: 30

37 |

Occupation: Developer

38 |
39 |
40 |
41 |
42 |
43 |
44 |

45 | © 2024 Town & People Data. All rights reserved. 46 |

47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-gray-950 dark:focus-visible:ring-gray-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90", 13 | destructive: 14 | "bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90", 15 | outline: 16 | "border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50", 17 | secondary: 18 | "bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80", 19 | ghost: "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50", 20 | link: "text-gray-900 underline-offset-4 hover:underline dark:text-gray-50", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [require("tailwindcss-animate")], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /prometheus_data/prometheus.yml: -------------------------------------------------------------------------------- 1 | # config file for prometheus 2 | 3 | # global config 4 | global: 5 | scrape_interval: 15s 6 | scrape_timeout: 10s 7 | evaluation_interval: 15s 8 | alerting: 9 | alertmanagers: 10 | - follow_redirects: true 11 | enable_http2: true 12 | scheme: http 13 | timeout: 10s 14 | api_version: v2 15 | static_configs: 16 | - targets: [] 17 | scrape_configs: 18 | - job_name: prometheus 19 | honor_timestamps: true 20 | scrape_interval: 15s 21 | scrape_timeout: 10s 22 | metrics_path: /metrics 23 | scheme: http 24 | follow_redirects: true 25 | enable_http2: true 26 | static_configs: 27 | - targets: 28 | - localhost:9090 29 | - job_name: 'fastapi-nextjs' 30 | scrape_interval: 10s 31 | metrics_path: /metrics 32 | static_configs: 33 | - targets: 34 | - localhost:8000 35 | # - host.docker.internal:8002 36 | - 0.0.0.0:8000 37 | - job_name: 'fastapi' 38 | scrape_interval: 10s 39 | metrics_path: /metrics 40 | static_configs: 41 | - targets: ['backend:8000'] 42 | --------------------------------------------------------------------------------