├── orders ├── .env ├── __init__.py ├── requirements.txt ├── Dockerfile ├── init_db.py ├── models.py └── main.py ├── users ├── .env ├── __init__.py ├── fake │ ├── __init__.py │ ├── users.json │ └── db.py ├── tests │ ├── __init__.py │ ├── auth.py │ └── fake_db.py ├── requirements.txt ├── Dockerfile ├── auth.py ├── datastructures.py └── main.py ├── gateway ├── __init__.py ├── .env ├── exceptions.py ├── Dockerfile ├── post_processing.py ├── requirements.txt ├── datastructures │ ├── orders.py │ └── users.py ├── conf.py ├── network.py ├── auth.py ├── main.py └── core.py ├── docs.png ├── diagram.png ├── docker-compose.yml ├── README.md └── .gitignore /orders/.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gateway/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/fake/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baranbartu/microservices-with-fastapi/HEAD/docs.png -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baranbartu/microservices-with-fastapi/HEAD/diagram.png -------------------------------------------------------------------------------- /gateway/.env: -------------------------------------------------------------------------------- 1 | USERS_SERVICE_URL=http://users:8000 2 | ORDERS_SERVICE_URL=http://orders:8000 3 | -------------------------------------------------------------------------------- /orders/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.58.0 2 | uvicorn==0.11.5 3 | tortoise-orm==0.16.13 4 | 5 | # for test purposes 6 | flake8==3.8.3 7 | ipdb==0.13.2 8 | ipython==7.15.0 9 | -------------------------------------------------------------------------------- /users/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.58.0 2 | uvicorn==0.11.5 3 | passlib==1.7.2 4 | bcrypt==3.1.7 5 | 6 | # for test purposes 7 | flake8==3.8.3 8 | ipdb==0.13.2 9 | ipython==7.15.0 10 | -------------------------------------------------------------------------------- /gateway/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthTokenMissing(Exception): 2 | pass 3 | 4 | 5 | class AuthTokenExpired(Exception): 6 | pass 7 | 8 | 9 | class AuthTokenCorrupted(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /orders/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.7 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONBUFFERED 1 7 | 8 | COPY requirements.txt . 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | COPY . . 12 | -------------------------------------------------------------------------------- /users/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.7 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONBUFFERED 1 7 | 8 | COPY requirements.txt . 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | COPY . . 12 | -------------------------------------------------------------------------------- /gateway/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.7 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONBUFFERED 1 7 | 8 | COPY requirements.txt . 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | COPY . . 12 | -------------------------------------------------------------------------------- /gateway/post_processing.py: -------------------------------------------------------------------------------- 1 | from auth import generate_access_token 2 | 3 | 4 | def access_token_generate_handler(data): 5 | access_token = generate_access_token(data) 6 | return { 7 | 'access_token': access_token, 'token_type': 'bearer' 8 | } 9 | -------------------------------------------------------------------------------- /gateway/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.58.0 2 | uvicorn==0.11.5 3 | PyJWT==1.7.1 4 | 5 | # async http requests & fast dns resolving 6 | aiohttp==3.6.2 7 | aiodns==2.0.0 8 | 9 | # for test purposes 10 | flake8==3.8.3 11 | ipdb==0.13.2 12 | ipython==7.15.0 13 | -------------------------------------------------------------------------------- /gateway/datastructures/orders.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class OrderForm(BaseModel): 5 | address: str 6 | item: str 7 | 8 | 9 | class OrderResponse(BaseModel): 10 | id: int 11 | address: str 12 | item: str 13 | created_by: int 14 | created_at: str 15 | -------------------------------------------------------------------------------- /users/auth.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | 4 | pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') 5 | 6 | 7 | def get_password_hash(password): 8 | return pwd_context.hash(password) 9 | 10 | 11 | def verify_password(plain_password, hashed_password): 12 | return pwd_context.verify(plain_password, hashed_password) 13 | -------------------------------------------------------------------------------- /gateway/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class Settings(BaseSettings): 7 | ACCESS_TOKEN_DEFAULT_EXPIRE_MINUTES: int = 360 8 | USERS_SERVICE_URL: str = os.environ.get('USERS_SERVICE_URL') 9 | ORDERS_SERVICE_URL: str = os.environ.get('ORDERS_SERVICE_URL') 10 | GATEWAY_TIMEOUT: int = 59 11 | 12 | 13 | settings = Settings() 14 | -------------------------------------------------------------------------------- /orders/init_db.py: -------------------------------------------------------------------------------- 1 | from tortoise import Tortoise, run_async 2 | 3 | async def init(): 4 | # Here we create a SQLite DB using file "db.sqlite3" 5 | # also specify the app name of "models" 6 | # which contain models from "models" 7 | await Tortoise.init( 8 | db_url='sqlite://db.sqlite3', 9 | modules={'models': ['models']} 10 | ) 11 | # Generate the schema 12 | await Tortoise.generate_schemas() 13 | 14 | # run_async is a helper function to run simple async Tortoise scripts. 15 | run_async(init()) 16 | -------------------------------------------------------------------------------- /orders/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from tortoise import fields, models 4 | from tortoise.contrib.pydantic import pydantic_model_creator 5 | 6 | 7 | class Orders(models.Model): 8 | id = fields.IntField(pk=True) 9 | address = fields.TextField() 10 | item = fields.TextField() 11 | created_by = fields.IntField() 12 | created_at = fields.DatetimeField(auto_now_add=True) 13 | 14 | 15 | Order_Pydantic = pydantic_model_creator(Orders, name='Order') 16 | 17 | 18 | class OrderIn_Pydantic(BaseModel): 19 | address: str 20 | item: str 21 | -------------------------------------------------------------------------------- /users/datastructures.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UsernamePasswordForm(BaseModel): 5 | username: str 6 | password: str 7 | 8 | 9 | class UserForm(UsernamePasswordForm): 10 | email: str = None 11 | full_name: str = None 12 | user_type: str 13 | 14 | 15 | class UserUpdateForm(BaseModel): 16 | username: str = None 17 | email: str = None 18 | full_name: str = None 19 | user_type: str = None 20 | 21 | 22 | class UserInDb(BaseModel): 23 | id: int 24 | username: str 25 | email: str = None 26 | full_name: str = None 27 | user_type: str 28 | hashed_password: str 29 | created_by: int 30 | -------------------------------------------------------------------------------- /users/tests/auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from auth import get_password_hash, verify_password 3 | 4 | 5 | class Test(unittest.TestCase): 6 | 7 | def setUp(self): 8 | self.pwd = 'b' 9 | self.hashed_pwd = ('$2b$12$o5FUxT.lT6PZXU8KHPz4tug1yG' 10 | 'I.gXuyZNT8VWbKBNaAEP10/yI.W') 11 | 12 | def test_plain_password(self): 13 | self.assertTrue(verify_password(self.pwd, self.hashed_pwd)) 14 | 15 | 16 | def test_hashed_password(self): 17 | hashed_pwd = get_password_hash(self.pwd) 18 | self.assertTrue(verify_password(self.pwd, hashed_pwd)) 19 | self.assertNotEquals(hashed_pwd, self.hashed_pwd) 20 | 21 | 22 | unittest.main(verbosity=2) 23 | -------------------------------------------------------------------------------- /gateway/datastructures/users.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UsernamePasswordForm(BaseModel): 5 | username: str 6 | password: str 7 | 8 | 9 | class UserForm(UsernamePasswordForm): 10 | email: str = None 11 | full_name: str = None 12 | user_type: str 13 | 14 | 15 | class UserUpdateForm(BaseModel): 16 | username: str = None 17 | email: str = None 18 | full_name: str = None 19 | user_type: str = None 20 | 21 | 22 | class LoginResponse(BaseModel): 23 | access_token: str 24 | token_type: str 25 | 26 | 27 | class UserResponse(BaseModel): 28 | id: int 29 | username: str 30 | email: str = None 31 | full_name: str = None 32 | user_type: str 33 | hashed_password: str 34 | created_by: int 35 | -------------------------------------------------------------------------------- /users/fake/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "username": "admin", 5 | "email": "admin@baran.com", 6 | "full_name": "Admin Admin", 7 | "user_type": "admin", 8 | "hashed_password": "$2b$12$16kNu5IW80k1Tw7xz2H3iOCsz0.oMZ7q5OSGa/OIfOae0WGFe8aI2", 9 | "created_by": 1 10 | }, 11 | { 12 | "id": 2, 13 | "username": "baranbartu", 14 | "email": "baran@baran.com", 15 | "full_name": "Baran Bartu Demirci", 16 | "user_type": "default", 17 | "hashed_password": "$2b$12$o5FUxT.lT6PZXU8KHPz4tug1yGI.gXuyZNT8VWbKBNaAEP10/yI.W", 18 | "created_by": 1 19 | }, 20 | { 21 | "id": 3, 22 | "username": "test", 23 | "email": "test@baran.com", 24 | "full_name": "tset tset", 25 | "user_type": "default", 26 | "hashed_password": "$2b$12$wf5AZPA/b4gesbPoMUcc2.BvIbGdDQFLd5SZizK9pthHo09WPf/Em", 27 | "created_by": 1 28 | } 29 | ] -------------------------------------------------------------------------------- /orders/main.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import FastAPI, Header 4 | from tortoise.contrib.fastapi import register_tortoise 5 | 6 | from models import Order_Pydantic, OrderIn_Pydantic, Orders 7 | 8 | 9 | app = FastAPI() 10 | 11 | 12 | @app.get('/api/orders', response_model=List[Order_Pydantic]) 13 | async def get_orders(request_user_id: str = Header(None)): 14 | return await Order_Pydantic.from_queryset( 15 | Orders.filter(created_by=request_user_id) 16 | ) 17 | 18 | 19 | @app.post('/api/orders', response_model=Order_Pydantic) 20 | async def create_user(order: OrderIn_Pydantic, 21 | request_user_id: str = Header(None)): 22 | data = order.dict() 23 | data.update({'created_by': request_user_id}) 24 | 25 | order_obj = await Orders.create(**data) 26 | return await Order_Pydantic.from_tortoise_orm(order_obj) 27 | 28 | 29 | register_tortoise( 30 | app, 31 | db_url='sqlite://:memory:', 32 | modules={'models': ['models']}, 33 | generate_schemas=True, 34 | add_exception_handlers=True, 35 | ) 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | gateway: 5 | image: baranbartu/k-api-gateway:latest 6 | command: sh -c "uvicorn main:app --reload --host 0.0.0.0" 7 | build: 8 | context: ./gateway 9 | dockerfile: Dockerfile 10 | env_file: 11 | - ./gateway/.env 12 | ports: 13 | - 8001:8000 14 | depends_on: 15 | - users 16 | - orders 17 | volumes: 18 | - ./gateway:/app 19 | 20 | users: 21 | image: baranbartu/k-users:latest 22 | command: sh -c "uvicorn main:app --reload --host 0.0.0.0" 23 | build: 24 | context: ./users 25 | dockerfile: Dockerfile 26 | env_file: 27 | - ./users/.env 28 | volumes: 29 | - ./users:/app 30 | 31 | orders: 32 | image: baranbartu/k-orders:latest 33 | command: sh -c "uvicorn main:app --reload --host 0.0.0.0" 34 | build: 35 | context: ./orders 36 | dockerfile: Dockerfile 37 | env_file: 38 | - ./orders/.env 39 | volumes: 40 | - ./orders:/app 41 | -------------------------------------------------------------------------------- /gateway/network.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import async_timeout 3 | 4 | from conf import settings 5 | 6 | 7 | async def make_request( 8 | url: str, 9 | method: str, 10 | data: dict = None, 11 | headers: dict = None 12 | ): 13 | """ 14 | Args: 15 | url: is the url for one of the in-network services 16 | method: is the lower version of one of the HTTP methods: GET, POST, PUT, DELETE # noqa 17 | data: is the payload 18 | headers: is the header to put additional headers into request 19 | 20 | Returns: 21 | service result coming / non-blocking http request (coroutine) 22 | e.g: { 23 | "id": 2, 24 | "username": "baranbartu", 25 | "email": "baran@baran.com", 26 | "full_name": "Baran Bartu Demirci", 27 | "user_type": "baran", 28 | "hashed_password": "***", 29 | "created_by": 1 30 | } 31 | """ 32 | if not data: 33 | data = {} 34 | 35 | with async_timeout.timeout(settings.GATEWAY_TIMEOUT): 36 | async with aiohttp.ClientSession() as session: 37 | request = getattr(session, method) 38 | async with request(url, json=data, headers=headers) as response: 39 | data = await response.json() 40 | return (data, response.status) 41 | -------------------------------------------------------------------------------- /gateway/auth.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from conf import settings 6 | from exceptions import AuthTokenMissing, AuthTokenExpired, AuthTokenCorrupted 7 | 8 | 9 | SECRET_KEY = 'e0e5f53b239df3dc39517c34ae0a1c09d1f5d181dfac1578d379a4a5ee3e0ef5' 10 | ALGORITHM = 'HS256' 11 | 12 | 13 | def generate_access_token( 14 | data: dict, 15 | expires_delta: timedelta = timedelta( 16 | minutes=settings.ACCESS_TOKEN_DEFAULT_EXPIRE_MINUTES 17 | ) 18 | ): 19 | 20 | expire = datetime.utcnow() + expires_delta 21 | token_data = { 22 | 'id': data['id'], 23 | 'user_type': data['user_type'], 24 | 'exp': expire, 25 | } 26 | 27 | encoded_jwt = jwt.encode(token_data, SECRET_KEY, algorithm=ALGORITHM) 28 | return encoded_jwt 29 | 30 | 31 | def decode_access_token(authorization: str = None): 32 | if not authorization: 33 | raise AuthTokenMissing('Auth token is missing in headers.') 34 | 35 | token = authorization.replace('Bearer ', '') 36 | try: 37 | payload = jwt.decode(token, SECRET_KEY, algorithms=ALGORITHM) 38 | return payload 39 | except jwt.exceptions.ExpiredSignatureError: 40 | raise AuthTokenExpired('Auth token is expired.') 41 | except jwt.exceptions.DecodeError: 42 | raise AuthTokenCorrupted('Auth token is corrupted.') 43 | 44 | 45 | def generate_request_header(token_payload): 46 | return {'request-user-id': str(token_payload['id'])} 47 | 48 | 49 | def is_admin_user(token_payload): 50 | return token_payload['user_type'] == 'admin' 51 | 52 | 53 | def is_default_user(token_payload): 54 | return token_payload['user_type'] in ['default', 'admin'] 55 | -------------------------------------------------------------------------------- /users/tests/fake_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from fake.db import dump_users, get_all_users, get_user_by_id 5 | 6 | 7 | class Test(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.users = [ 11 | { 12 | 'id': 1, 13 | 'username': 'admin', 14 | 'email': 'admin@baran.com', 15 | 'full_name': 'Admin Admin', 16 | 'user_type': 'admin', 17 | 'hashed_password': ('$2b$12$16kNu5IW80k1Tw7xz2H3iOCsz0' 18 | '.oMZ7q5OSGa/OIfOae0WGFe8aI2'), 19 | 'created_by': 1 20 | }, 21 | { 22 | 'id': 2, 23 | 'username': 'baranbartu', 24 | 'email': 'baran@baran.com', 25 | 'full_name': 'Baran Bartu Demirci', 26 | 'user_type': 'baran', 27 | 'hashed_password': ('$2b$12$o5FUxT.lT6PZXU8KHP' 28 | 'z4tug1yGI.gXuyZNT8VWbKBNaAEP10/yI.W'), 29 | 'created_by': 1 30 | } 31 | ] 32 | self.db = '/tmp/users.json' 33 | dump_users(self.users, self.db) 34 | 35 | def test_get_all_users_are_identical(self): 36 | users = get_all_users(self.db) 37 | for user_in_db in users: 38 | user = user_in_db.dict() 39 | for column, value in user.items(): 40 | _user = list( 41 | filter(lambda u: u['id'] == user['id'], self.users) 42 | )[0] 43 | self.assertTrue(_user[column] == value) 44 | 45 | def test_get_user_by_id(self): 46 | _user = self.users[0] 47 | pk = _user['id'] 48 | user_in_db = get_user_by_id(pk) 49 | user = user_in_db.dict() 50 | for column, value in user.items(): 51 | self.assertEquals(value, _user[column]) 52 | 53 | def tearDown(self): 54 | os.remove(self.db) 55 | 56 | 57 | unittest.main(verbosity=2) 58 | -------------------------------------------------------------------------------- /users/fake/db.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from datastructures import UserInDb, UserUpdateForm 4 | 5 | 6 | users_file = 'fake/users.json' 7 | 8 | 9 | def get_all_users(db: str = users_file): 10 | with open(db) as json_file: 11 | users = json.load(json_file) 12 | for user in users: 13 | yield UserInDb(**user) 14 | 15 | 16 | def get_user_by_id(uid: int): 17 | users = list(filter(lambda u: u.id == uid, get_all_users())) 18 | return users[0] if users else None 19 | 20 | 21 | def get_user_by_username(uname: str): 22 | users = list(filter(lambda u: u.username == uname, get_all_users())) 23 | return users[0] if users else None 24 | 25 | 26 | def get_user_by_email(email: str): 27 | users = list(filter(lambda u: u.email == email, get_all_users())) 28 | return users[0] if users else None 29 | 30 | 31 | def insert_user(data: dict, hashed_password: str, created_by: int): 32 | try: 33 | last_id = max(map(lambda u: u.id, get_all_users())) 34 | pk = last_id + 1 35 | except ValueError: 36 | pk = 1 37 | 38 | data.update({ 39 | 'id': pk, 40 | 'hashed_password': hashed_password, 41 | 'created_by': created_by 42 | }) 43 | 44 | user_in_db = UserInDb(**data) 45 | users = list(map(lambda u: u.dict(), get_all_users())) 46 | users.append(user_in_db.dict()) 47 | 48 | dump_users(users) 49 | 50 | return user_in_db 51 | 52 | 53 | def delete_user_from_db(pk: int): 54 | users = list( 55 | map( 56 | lambda u: u.dict(), 57 | filter(lambda u: u.id != pk, get_all_users()) 58 | ) 59 | ) 60 | dump_users(users) 61 | 62 | 63 | def update_user_in_db(user_in_db: UserInDb, user: UserUpdateForm): 64 | new_user_data = user.dict() 65 | db_user_data = user_in_db.dict() 66 | for column, value in new_user_data.items(): 67 | if value is not None: 68 | db_user_data[column] = value 69 | 70 | new_db_users = list( 71 | map( 72 | lambda u: u.dict() if u.id != user_in_db.id else db_user_data, 73 | get_all_users() 74 | ) 75 | ) 76 | dump_users(new_db_users) 77 | 78 | user_in_db = UserInDb(**db_user_data) 79 | return user_in_db 80 | 81 | 82 | def dump_users(users: list, db: str = users_file): 83 | with open(db, 'w') as json_file: 84 | json.dump(users, json_file, indent=4) 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microservices, API Gateway, Authentication with FastAPI, non-blocking i/o 2 | - This repo is composed of a bunch of small microservices considering API gateway approach 3 | - Expected number of microservices was two, but considering that services 4 |   should not create dependency on each other to prevent SPOF, also to prevent duplicate codes, 5 |   I decided to put one API gateway in front that does JWT authentication for both services 6 |   which I am inspired by Netflix/Zuul 7 | - We have 3 services including gateway. 8 | - Only gateway can access internal microservices through the internal network (users, orders) 9 | 10 | ## Services 11 | - gateway: Built on top of FastAPI, simple API gateway which its only duty is to make proper 12 |   routing while also handling authentication and authorization 13 | - users (a.k.a. admin): Keeps user info in its own fake db (file system). 14 |   Can be executed simple CRUD operations through the service. There is also another 15 |   endpoint for login, but client is abstracted from real response. Thus, gateway service 16 |   will handle login response and generate JWT token accordingly. 17 | - orders: Users (subscribed ones – authentication) can create and view (their – authorization) orders. 18 | 19 | ## Running 20 | - check ./gateway/.env → 2 services URL are defined based on twelve-factor config 21 | - docker-compose up --build 22 | - visit → http://localhost:8001/docs 23 | 24 | # Example requests 25 | - There are already created 2 users in users db 26 | - get API token with admin user 27 |   ``` 28 |   curl --header "Content-Type: application/json" \ 29 |        --request POST \ 30 |        --data '{"username":"admin","password":"a"}' \ 31 |        http://localhost:8001/api/login 32 |   ``` 33 | - You'll see something similar to below 34 |   ``` 35 |   {"access_token":"***","token_type":"bearer"} 36 |   ``` 37 | - use this token to make administrative level requests 38 |   ``` 39 |   curl --header "Content-Type: application/json" \ 40 |        --header "Authorization: Bearer ***" \ 41 |        --request GET \ 42 |        http://localhost:8001/api/users 43 |   ``` 44 | - Similar trials can be also done with default user to create & view orders 45 | 46 | ## IMPORTANT NOTES & POSSIBLE TODOs 47 | - Tried to use coroutines on especially i/o operations to boost the performance of gateway. (aiohttp) 48 | - Again, non-blocking db client library is used. (tortoise-orm) 49 | - Tried to use dependency injection on gateway API as a new router, because i am thinking to 50 |   feed this project and make it open source for other people as well. 51 | - Tried to implement declarative way for API gateway rules, for now it works 52 |   based on decorator, but my purpose is to make it more declarative like using 53 |   YAML configuration 54 | - Since it is executed with docker-compose file, it shouldn't be considered as production ready (a.k.a. scalability) 55 | - API gateway approach is considered, also inspired by Zuul, event-driven approach can be easily applied in case needed. 56 | - Authentication and authorization are separated from the services to keep things clean, one service does for all. 57 | - JWT token are generated in gateway service and other services behind the gateway receive a separated 58 |   header called request-user-id to use user specific info. 59 | - thread-safety was not considered especially on fake users service since we 60 |   use file operations, and it might produce race conditions 61 | - Another authorization level can be added into private-network services 62 |   checking if request-user-id header exists 63 | - hashed_password might be shadowed in user's response 64 | - API versioning might be considered 65 | - JWT token TTL can be changed under ./gateway/conf.py[settings] 66 | - Nginx or similar tool can be added in front of all services to leverage more benefits 67 | 68 | ## Overall Diagram 69 | ![ScreenShot](https://raw.github.com/baranbartu/microservices-with-fastapi/master/diagram.png) 70 | 71 | ## Documentation Page 72 | ![ScreenShot](https://raw.github.com/baranbartu/microservices-with-fastapi/master/docs.png) 73 | -------------------------------------------------------------------------------- /users/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, status, Request, Response, Header 2 | 3 | from auth import verify_password, get_password_hash 4 | from datastructures import UsernamePasswordForm, UserForm, UserUpdateForm 5 | 6 | from fake.db import (get_user_by_username, 7 | get_user_by_email, 8 | insert_user, 9 | get_all_users, 10 | get_user_by_id, 11 | delete_user_from_db, 12 | update_user_in_db) 13 | 14 | app = FastAPI() 15 | PROTECTED_USER_IDS = [1, 2] 16 | 17 | 18 | @app.post('/api/login', status_code=status.HTTP_201_CREATED) 19 | async def login(form_data: UsernamePasswordForm): 20 | user_in_db = get_user_by_username(form_data.username) 21 | 22 | if not user_in_db: 23 | raise HTTPException( 24 | status_code=status.HTTP_404_NOT_FOUND, 25 | detail='User not found with this username.', 26 | ) 27 | 28 | verified = verify_password(form_data.password, user_in_db.hashed_password) 29 | if not verified: 30 | raise HTTPException( 31 | status_code=status.HTTP_401_UNAUTHORIZED, 32 | detail='Password is wrong.', 33 | ) 34 | 35 | return user_in_db 36 | 37 | 38 | @app.post('/api/users', status_code=status.HTTP_201_CREATED) 39 | async def create_user(user: UserForm, 40 | request: Request, response: Response, 41 | request_user_id: str = Header(None)): 42 | 43 | user_in_db = get_user_by_username(user.username) 44 | if user_in_db: 45 | raise HTTPException( 46 | status_code=status.HTTP_409_CONFLICT, 47 | detail='There is already another user with this username.', 48 | ) 49 | 50 | user_in_db = get_user_by_email(user.email) 51 | if user_in_db: 52 | raise HTTPException( 53 | status_code=status.HTTP_409_CONFLICT, 54 | detail='There is already another user with this email.', 55 | ) 56 | 57 | hashed_password = get_password_hash(user.password) 58 | data = user.dict() 59 | user_in_db = insert_user(data, hashed_password, request_user_id) 60 | 61 | return user_in_db 62 | 63 | 64 | @app.get('/api/users', status_code=status.HTTP_200_OK) 65 | async def get_users(request: Request, response: Response, 66 | request_user_id: str = Header(None)): 67 | users = list(get_all_users()) 68 | return users 69 | 70 | 71 | @app.get('/api/users/{user_id}', status_code=status.HTTP_200_OK) 72 | async def get_user(user_id: int, request: Request, response: Response, 73 | request_user_id: str = Header(None)): 74 | 75 | user_in_db = get_user_by_id(user_id) 76 | if not user_in_db: 77 | raise HTTPException( 78 | status_code=status.HTTP_404_NOT_FOUND, 79 | detail='User not found with this id.', 80 | ) 81 | return user_in_db 82 | 83 | 84 | @app.delete('/api/users/{user_id}', status_code=status.HTTP_204_NO_CONTENT) 85 | async def delete_user(user_id: int, request: Request, response: Response, 86 | request_user_id: str = Header(None)): 87 | 88 | if user_id in PROTECTED_USER_IDS: 89 | raise HTTPException( 90 | status_code=status.HTTP_403_FORBIDDEN, 91 | detail='You are not allowed to delete protected users.', 92 | ) 93 | 94 | user_in_db = get_user_by_id(user_id) 95 | if not user_in_db: 96 | raise HTTPException( 97 | status_code=status.HTTP_404_NOT_FOUND, 98 | detail='User not found with this id.', 99 | ) 100 | delete_user_from_db(user_id) 101 | 102 | 103 | @app.put('/api/users/{user_id}', status_code=status.HTTP_200_OK) 104 | async def update_user(user_id: int, user: UserUpdateForm, 105 | request: Request, response: Response, 106 | request_user_id: str = Header(None)): 107 | 108 | user_in_db = get_user_by_id(user_id) 109 | if not user_in_db: 110 | raise HTTPException( 111 | status_code=status.HTTP_409_CONFLICT, 112 | detail='There is already another user with this username.', 113 | ) 114 | 115 | user_in_db = update_user_in_db(user_in_db, user) 116 | return user_in_db 117 | -------------------------------------------------------------------------------- /.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 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | # .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | # Logs 133 | logs 134 | *.log 135 | npm-debug.log* 136 | yarn-debug.log* 137 | yarn-error.log* 138 | lerna-debug.log* 139 | 140 | # Diagnostic reports (https://nodejs.org/api/report.html) 141 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 142 | 143 | # Runtime data 144 | pids 145 | *.pid 146 | *.seed 147 | *.pid.lock 148 | 149 | # Directory for instrumented libs generated by jscoverage/JSCover 150 | lib-cov 151 | 152 | # Coverage directory used by tools like istanbul 153 | coverage 154 | *.lcov 155 | 156 | # nyc test coverage 157 | .nyc_output 158 | 159 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 160 | .grunt 161 | 162 | # Bower dependency directory (https://bower.io/) 163 | bower_components 164 | 165 | # node-waf configuration 166 | .lock-wscript 167 | 168 | # Compiled binary addons (https://nodejs.org/api/addons.html) 169 | build/Release 170 | 171 | # Dependency directories 172 | node_modules/ 173 | jspm_packages/ 174 | 175 | # TypeScript v1 declaration files 176 | typings/ 177 | 178 | # TypeScript cache 179 | *.tsbuildinfo 180 | 181 | # Optional npm cache directory 182 | .npm 183 | 184 | # Optional eslint cache 185 | .eslintcache 186 | 187 | # Microbundle cache 188 | .rpt2_cache/ 189 | .rts2_cache_cjs/ 190 | .rts2_cache_es/ 191 | .rts2_cache_umd/ 192 | 193 | # Optional REPL history 194 | .node_repl_history 195 | 196 | # Output of 'npm pack' 197 | *.tgz 198 | 199 | # Yarn Integrity file 200 | .yarn-integrity 201 | 202 | # dotenv environment variables file 203 | # .env 204 | .env.test 205 | 206 | # parcel-bundler cache (https://parceljs.org/) 207 | .cache 208 | 209 | # Next.js build output 210 | .next 211 | 212 | # Nuxt.js build / generate output 213 | .nuxt 214 | dist 215 | 216 | # Gatsby files 217 | .cache/ 218 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 219 | # https://nextjs.org/blog/next-9-1#public-directory-support 220 | # public 221 | 222 | # vuepress build output 223 | .vuepress/dist 224 | 225 | # Serverless directories 226 | .serverless/ 227 | 228 | # FuseBox cache 229 | .fusebox/ 230 | 231 | # DynamoDB Local files 232 | .dynamodb/ 233 | 234 | # TernJS port file 235 | .tern-port 236 | -------------------------------------------------------------------------------- /gateway/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, status, Request, Response 2 | 3 | from conf import settings 4 | from core import route 5 | 6 | from datastructures.users import (UsernamePasswordForm, 7 | UserForm, 8 | UserUpdateForm) 9 | from datastructures.orders import OrderForm 10 | 11 | app = FastAPI() 12 | 13 | 14 | @route( 15 | request_method=app.post, 16 | path='/api/login', 17 | status_code=status.HTTP_201_CREATED, 18 | payload_key='username_password', 19 | service_url=settings.USERS_SERVICE_URL, 20 | authentication_required=False, 21 | post_processing_func='post_processing.access_token_generate_handler', 22 | response_model='datastructures.users.LoginResponse' 23 | ) 24 | async def login(username_password: UsernamePasswordForm, 25 | request: Request, response: Response): 26 | pass 27 | 28 | 29 | @route( 30 | request_method=app.post, 31 | path='/api/users', 32 | status_code=status.HTTP_201_CREATED, 33 | payload_key='user', 34 | service_url=settings.USERS_SERVICE_URL, 35 | authentication_required=True, 36 | post_processing_func=None, 37 | authentication_token_decoder='auth.decode_access_token', 38 | service_authorization_checker='auth.is_admin_user', 39 | service_header_generator='auth.generate_request_header', 40 | response_model='datastructures.users.UserResponse', 41 | ) 42 | async def create_user(user: UserForm, request: Request, response: Response): 43 | pass 44 | 45 | 46 | @route( 47 | request_method=app.get, 48 | path='/api/users', 49 | status_code=status.HTTP_200_OK, 50 | payload_key=None, 51 | service_url=settings.USERS_SERVICE_URL, 52 | authentication_required=True, 53 | post_processing_func=None, 54 | authentication_token_decoder='auth.decode_access_token', 55 | service_authorization_checker='auth.is_admin_user', 56 | service_header_generator='auth.generate_request_header', 57 | response_model='datastructures.users.UserResponse', 58 | response_list=True 59 | ) 60 | async def get_users(request: Request, response: Response): 61 | pass 62 | 63 | 64 | @route( 65 | request_method=app.get, 66 | path='/api/users/{user_id}', 67 | status_code=status.HTTP_200_OK, 68 | payload_key=None, 69 | service_url=settings.USERS_SERVICE_URL, 70 | authentication_required=True, 71 | post_processing_func=None, 72 | authentication_token_decoder='auth.decode_access_token', 73 | service_authorization_checker='auth.is_admin_user', 74 | service_header_generator='auth.generate_request_header', 75 | response_model='datastructures.users.UserResponse', 76 | ) 77 | async def get_user(user_id: int, request: Request, response: Response): 78 | pass 79 | 80 | 81 | @route( 82 | request_method=app.delete, 83 | path='/api/users/{user_id}', 84 | status_code=status.HTTP_204_NO_CONTENT, 85 | payload_key=None, 86 | service_url=settings.USERS_SERVICE_URL, 87 | authentication_required=True, 88 | post_processing_func=None, 89 | authentication_token_decoder='auth.decode_access_token', 90 | service_authorization_checker='auth.is_admin_user', 91 | service_header_generator='auth.generate_request_header', 92 | ) 93 | async def delete_user(user_id: int, request: Request, response: Response): 94 | pass 95 | 96 | 97 | @route( 98 | request_method=app.put, 99 | path='/api/users/{user_id}', 100 | status_code=status.HTTP_200_OK, 101 | payload_key='user', 102 | service_url=settings.USERS_SERVICE_URL, 103 | authentication_required=True, 104 | post_processing_func=None, 105 | authentication_token_decoder='auth.decode_access_token', 106 | service_authorization_checker='auth.is_admin_user', 107 | service_header_generator='auth.generate_request_header', 108 | response_model='datastructures.users.UserResponse', 109 | ) 110 | async def update_user(user_id: int, user: UserUpdateForm, 111 | request: Request, response: Response): 112 | pass 113 | 114 | 115 | @route( 116 | request_method=app.get, 117 | path='/api/orders', 118 | status_code=status.HTTP_200_OK, 119 | payload_key=None, 120 | service_url=settings.ORDERS_SERVICE_URL, 121 | authentication_required=True, 122 | post_processing_func=None, 123 | authentication_token_decoder='auth.decode_access_token', 124 | service_authorization_checker='auth.is_default_user', 125 | service_header_generator='auth.generate_request_header', 126 | response_model='datastructures.orders.OrderResponse', 127 | response_list=True, 128 | ) 129 | async def get_orders(request: Request, response: Response): 130 | pass 131 | 132 | 133 | @route( 134 | request_method=app.post, 135 | path='/api/orders', 136 | status_code=status.HTTP_200_OK, 137 | payload_key='order', 138 | service_url=settings.ORDERS_SERVICE_URL, 139 | authentication_required=True, 140 | post_processing_func=None, 141 | authentication_token_decoder='auth.decode_access_token', 142 | service_authorization_checker='auth.is_default_user', 143 | service_header_generator='auth.generate_request_header', 144 | response_model='datastructures.orders.OrderResponse', 145 | ) 146 | async def create_order(order: OrderForm, request: Request, response: Response): 147 | pass 148 | -------------------------------------------------------------------------------- /gateway/core.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import functools 3 | 4 | 5 | from importlib import import_module 6 | from fastapi import Request, Response, HTTPException, status 7 | from typing import List 8 | 9 | from exceptions import (AuthTokenMissing, AuthTokenExpired, AuthTokenCorrupted) 10 | from network import make_request 11 | 12 | 13 | def route( 14 | request_method, path: str, status_code: int, 15 | payload_key: str, service_url: str, 16 | authentication_required: bool = False, 17 | post_processing_func: str = None, 18 | authentication_token_decoder: str = 'auth.decode_access_token', 19 | service_authorization_checker: str = 'auth.is_admin_user', 20 | service_header_generator: str = 'auth.generate_request_header', 21 | response_model: str = None, 22 | response_list: bool = False 23 | ): 24 | """ 25 | it is an advanced wrapper for FastAPI router, purpose is to make FastAPI 26 | acts as a gateway API in front of anything 27 | 28 | Args: 29 | request_method: is a callable like (app.get, app.post and so on.) 30 | path: is the path to bind (like app.post('/api/users/')) 31 | status_code: expected HTTP(status.HTTP_200_OK) status code 32 | payload_key: used to easily fetch payload data in request body 33 | authentication_required: is bool to give to user an auth priviliges 34 | post_processing_func: does extra things once in-network service returns 35 | authentication_token_decoder: decodes JWT token as a proper payload 36 | service_authorization_checker: does simple front authorization checks 37 | service_header_generator: generates headers for inner services from jwt token payload # noqa 38 | response_model: shows return type and details on api docs 39 | response_list: decides whether response structure is list or not 40 | 41 | Returns: 42 | wrapped endpoint result as is 43 | 44 | """ 45 | 46 | # request_method: app.post || app.get or so on 47 | # app_any: app.post('/api/login', status_code=200, response_model=int) 48 | if response_model: 49 | response_model = import_function(response_model) 50 | if response_list: 51 | response_model = List[response_model] 52 | 53 | app_any = request_method( 54 | path, status_code=status_code, 55 | response_model=response_model 56 | ) 57 | 58 | def wrapper(f): 59 | @app_any 60 | @functools.wraps(f) 61 | async def inner(request: Request, response: Response, **kwargs): 62 | service_headers = {} 63 | 64 | if authentication_required: 65 | # authentication 66 | authorization = request.headers.get('authorization') 67 | token_decoder = import_function(authentication_token_decoder) 68 | exc = None 69 | try: 70 | token_payload = token_decoder(authorization) 71 | except (AuthTokenMissing, 72 | AuthTokenExpired, 73 | AuthTokenCorrupted) as e: 74 | exc = str(e) 75 | except Exception as e: 76 | # in case a new decoder is used by dependency injection and 77 | # there might be an unexpected error 78 | exc = str(e) 79 | finally: 80 | if exc: 81 | raise HTTPException( 82 | status_code=status.HTTP_401_UNAUTHORIZED, 83 | detail=exc, 84 | headers={'WWW-Authenticate': 'Bearer'}, 85 | ) 86 | 87 | # authorization 88 | if service_authorization_checker: 89 | authorization_checker = import_function( 90 | service_authorization_checker 91 | ) 92 | is_user_eligible = authorization_checker(token_payload) 93 | if not is_user_eligible: 94 | raise HTTPException( 95 | status_code=status.HTTP_403_FORBIDDEN, 96 | detail='You are not allowed to access this scope.', 97 | headers={'WWW-Authenticate': 'Bearer'}, 98 | ) 99 | 100 | # service headers 101 | if service_header_generator: 102 | header_generator = import_function( 103 | service_header_generator 104 | ) 105 | service_headers = header_generator(token_payload) 106 | 107 | scope = request.scope 108 | 109 | method = scope['method'].lower() 110 | path = scope['path'] 111 | 112 | payload_obj = kwargs.get(payload_key) 113 | payload = payload_obj.dict() if payload_obj else {} 114 | 115 | url = f'{service_url}{path}' 116 | 117 | try: 118 | resp_data, status_code_from_service = await make_request( 119 | url=url, 120 | method=method, 121 | data=payload, 122 | headers=service_headers, 123 | ) 124 | except aiohttp.client_exceptions.ClientConnectorError: 125 | raise HTTPException( 126 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 127 | detail='Service is unavailable.', 128 | headers={'WWW-Authenticate': 'Bearer'}, 129 | ) 130 | except aiohttp.client_exceptions.ContentTypeError: 131 | raise HTTPException( 132 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 133 | detail='Service error.', 134 | headers={'WWW-Authenticate': 'Bearer'}, 135 | ) 136 | 137 | response.status_code = status_code_from_service 138 | 139 | if all([ 140 | status_code_from_service == status_code, 141 | post_processing_func 142 | ]): 143 | post_processing_f = import_function(post_processing_func) 144 | resp_data = post_processing_f(resp_data) 145 | 146 | return resp_data 147 | 148 | return wrapper 149 | 150 | 151 | def import_function(method_path): 152 | module, method = method_path.rsplit('.', 1) 153 | mod = import_module(module) 154 | return getattr(mod, method, lambda *args, **kwargs: None) 155 | --------------------------------------------------------------------------------