├── .do └── deploy.template.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── README.md ├── app ├── __init__.py ├── images │ ├── ingredients-1.png │ ├── ingredients-2.png │ └── not-a-image.txt ├── main.py ├── ocr.py ├── templates │ ├── base.html │ └── home.html ├── test_endpoints.py └── test_production.py ├── entrypoint.sh ├── ms-fastapi-django.code-workspace ├── pytest.ini ├── pyvenv.cfg ├── requirements.txt └── runtime.txt /.do/deploy.template.yaml: -------------------------------------------------------------------------------- 1 | spec: 2 | name: ocr-microservice 3 | services: 4 | - name: web 5 | dockerfile_path: Dockerfile 6 | instance_count: 1 7 | instance_size_slug: basic-xxs 8 | routes: 9 | - path: / 10 | source_dir: / 11 | git: 12 | branch: main 13 | repo_clone_url: https://github.com/codingforentrepreneurs/FastAPI-Microservice-for-Django.git 14 | envs: 15 | - key: DEBUG 16 | value: "0" 17 | - key: ECHO_ACTIVE 18 | value: "0" 19 | - key: APP_AUTH_TOKEN 20 | value: "CHANGE_AND_ENCRYPT_ME" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | bin/ 4 | include/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-yaml 7 | - id: check-added-large-files 8 | - repo: local 9 | hooks: 10 | - id: pytest-check 11 | name: PyTest Runner 12 | entry: pytest 13 | language: system 14 | pass_filenames: false 15 | always_run: true 16 | 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | COPY ./app /app 4 | COPY ./entrypoint.sh /entrypoint.sh 5 | COPY ./requirements.txt /requirements.txt 6 | 7 | RUN apt-get update && \ 8 | apt-get install -y \ 9 | build-essential \ 10 | python3-dev \ 11 | python3-setuptools \ 12 | tesseract-ocr \ 13 | make \ 14 | gcc \ 15 | && python3 -m pip install -r requirements.txt \ 16 | && apt-get remove -y --purge make gcc build-essential \ 17 | && apt-get autoremove -y \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | RUN chmod +x entrypoint.sh 21 | 22 | CMD [ "./entrypoint.sh" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![FastAPI Microservice for Django](https://static.codingforentrepreneurs.com/media/projects/fastapi-microservice-django/images/share/FastAPI_Microservice_for_Try_Django.jpg)](https://www.codingforentrepreneurs.com/projects/fastapi-microservice-django) 2 | 3 | 4 | Learn to deploy a FastAPI application into production DigitalOcean App Platform. This is a microservice for our [Try Django 3.2](https://www.codingforentrepreneurs.com/projects/try-django-3-2) project. The goal is to extract any and all text from images using a technique called OCR. 5 | 6 | Here's a list of the packages we will use to accomplish this: 7 | 8 | - FastAPI 9 | - Tesseract OCR 10 | - pytesseract 11 | - pre-commit 12 | - pytest 13 | - Gunicorn 14 | - Uvicorn 15 | - Requests 16 | - Docker 17 | - and more 18 | 19 | ## Want to just run the app? 20 | Click below to deploy to DigitalOcean. Be sure to grab your $100 credit [here](https://do.co/cfe-github). 21 | 22 | 23 | [![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/codingforentrepreneurs/FastAPI-Microservice-for-Django/tree/main) -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/FastAPI-Microservice-for-Django/6974a18155552aa3389158ac193d3056e17b9f54/app/__init__.py -------------------------------------------------------------------------------- /app/images/ingredients-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/FastAPI-Microservice-for-Django/6974a18155552aa3389158ac193d3056e17b9f54/app/images/ingredients-1.png -------------------------------------------------------------------------------- /app/images/ingredients-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/FastAPI-Microservice-for-Django/6974a18155552aa3389158ac193d3056e17b9f54/app/images/ingredients-2.png -------------------------------------------------------------------------------- /app/images/not-a-image.txt: -------------------------------------------------------------------------------- 1 | hello world -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import os 3 | import io 4 | import uuid 5 | from functools import lru_cache 6 | from fastapi import( 7 | FastAPI, 8 | Header, 9 | HTTPException, 10 | Depends, 11 | Request, 12 | File, 13 | UploadFile 14 | ) 15 | import pytesseract 16 | from fastapi.responses import HTMLResponse, FileResponse 17 | from fastapi.templating import Jinja2Templates 18 | from pydantic import BaseSettings 19 | from PIL import Image 20 | 21 | class Settings(BaseSettings): 22 | app_auth_token: str 23 | debug: bool = False 24 | echo_active: bool = False 25 | app_auth_token_prod: str = None 26 | skip_auth: bool = False 27 | 28 | class Config: 29 | env_file = ".env" 30 | 31 | @lru_cache 32 | def get_settings(): 33 | return Settings() 34 | 35 | settings = get_settings() 36 | DEBUG=settings.debug 37 | 38 | BASE_DIR = pathlib.Path(__file__).parent 39 | UPLOAD_DIR = BASE_DIR / "uploads" 40 | 41 | 42 | app = FastAPI() 43 | templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) 44 | 45 | 46 | @app.get("/", response_class=HTMLResponse) # http GET -> JSON 47 | def home_view(request: Request, settings:Settings = Depends(get_settings)): 48 | return templates.TemplateResponse("home.html", {"request": request, "abc": 123}) 49 | 50 | 51 | def verify_auth(authorization = Header(None), settings:Settings = Depends(get_settings)): 52 | """ 53 | Authorization: Bearer 54 | {"authorization": "Bearer "} 55 | """ 56 | if settings.debug and settings.skip_auth: 57 | return 58 | if authorization is None: 59 | raise HTTPException(detail="Invalid endpoint", status_code=401) 60 | label, token = authorization.split() 61 | if token != settings.app_auth_token: 62 | raise HTTPException(detail="Invalid endpoint", status_code=401) 63 | 64 | 65 | @app.post("/") # http POST 66 | async def prediction_view(file:UploadFile = File(...), authorization = Header(None), settings:Settings = Depends(get_settings)): 67 | verify_auth(authorization, settings) 68 | bytes_str = io.BytesIO(await file.read()) 69 | try: 70 | img = Image.open(bytes_str) 71 | except: 72 | raise HTTPException(detail="Invalid image", status_code=400) 73 | preds = pytesseract.image_to_string(img) 74 | predictions = [x for x in preds.split("\n")] 75 | return {"results": predictions, "original": preds} 76 | 77 | 78 | @app.post("/img-echo/", response_class=FileResponse) # http POST 79 | async def img_echo_view(file:UploadFile = File(...), settings:Settings = Depends(get_settings)): 80 | if not settings.echo_active: 81 | raise HTTPException(detail="Invalid endpoint", status_code=400) 82 | UPLOAD_DIR.mkdir(exist_ok=True) 83 | bytes_str = io.BytesIO(await file.read()) 84 | try: 85 | img = Image.open(bytes_str) 86 | except: 87 | raise HTTPException(detail="Invalid image", status_code=400) 88 | fname = pathlib.Path(file.filename) 89 | fext = fname.suffix # .jpg, .txt 90 | dest = UPLOAD_DIR / f"{uuid.uuid1()}{fext}" 91 | img.save(dest) 92 | return dest 93 | -------------------------------------------------------------------------------- /app/ocr.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import pytesseract 3 | from PIL import Image 4 | 5 | BASE_DIR = pathlib.Path(__file__).parent 6 | IMG_DIR = BASE_DIR / "images" 7 | img_path = IMG_DIR / "ingredients-1.png" 8 | 9 | img = Image.open(img_path) 10 | 11 | preds = pytesseract.image_to_string(img) 12 | predictions = [x for x in preds.split("\n")] 13 | # model.predict(img) 14 | 15 | print(predictions) -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | Code On! 16 | 17 | 18 | {% block content %} 19 | {% endblock %} 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

Code On!

7 |
8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /app/test_endpoints.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import time 3 | import io 4 | from fastapi.testclient import TestClient 5 | from app.main import app, BASE_DIR, UPLOAD_DIR, get_settings 6 | 7 | from PIL import Image, ImageChops 8 | 9 | client = TestClient(app) 10 | 11 | def test_get_home(): 12 | response = client.get("/") # requests.get("") # python requests 13 | assert response.text != "

Hello world

" 14 | assert response.status_code == 200 15 | assert "text/html" in response.headers['content-type'] 16 | 17 | 18 | 19 | def test_invalid_file_upload_error(): 20 | response = client.post("/") # requests.post("") # python requests 21 | assert response.status_code == 422 22 | assert "application/json" in response.headers['content-type'] 23 | 24 | def test_prediction_upload_missing_headers(): 25 | img_saved_path = BASE_DIR / "images" 26 | settings = get_settings() 27 | for path in img_saved_path.glob("*"): 28 | try: 29 | img = Image.open(path) 30 | except: 31 | img = None 32 | response = client.post("/", 33 | files={"file": open(path, 'rb')} 34 | ) 35 | assert response.status_code == 401 36 | 37 | 38 | def test_prediction_upload(): 39 | img_saved_path = BASE_DIR / "images" 40 | settings = get_settings() 41 | for path in img_saved_path.glob("*"): 42 | try: 43 | img = Image.open(path) 44 | except: 45 | img = None 46 | response = client.post("/", 47 | files={"file": open(path, 'rb')}, 48 | headers={"Authorization": f"JWT {settings.app_auth_token}"} 49 | ) 50 | if img is None: 51 | assert response.status_code == 400 52 | else: 53 | # Returning a valid image 54 | assert response.status_code == 200 55 | data = response.json() 56 | assert len(data.keys()) == 2 57 | 58 | 59 | def test_echo_upload(): 60 | img_saved_path = BASE_DIR / "images" 61 | for path in img_saved_path.glob("*"): 62 | try: 63 | img = Image.open(path) 64 | except: 65 | img = None 66 | response = client.post("/img-echo/", files={"file": open(path, 'rb')}) 67 | if img is None: 68 | assert response.status_code == 400 69 | else: 70 | # Returning a valid image 71 | assert response.status_code == 200 72 | r_stream = io.BytesIO(response.content) 73 | echo_img = Image.open(r_stream) 74 | difference = ImageChops.difference(echo_img, img).getbbox() 75 | assert difference is None 76 | # time.sleep(3) 77 | shutil.rmtree(UPLOAD_DIR) 78 | -------------------------------------------------------------------------------- /app/test_production.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import time 3 | import io 4 | from fastapi.testclient import TestClient 5 | from app.main import BASE_DIR, UPLOAD_DIR, get_settings 6 | 7 | from PIL import Image, ImageChops 8 | import requests 9 | 10 | ENDPOINT="https://fastapi-docker-l3j59.ondigitalocean.app/" 11 | 12 | def test_get_home(): 13 | response = requests.get(ENDPOINT) 14 | assert response.text != "

Hello world

" 15 | assert response.status_code == 200 16 | assert "text/html" in response.headers['content-type'] 17 | 18 | 19 | def test_invalid_file_upload_error(): 20 | response = requests.post(ENDPOINT) 21 | assert response.status_code == 422 22 | assert "application/json" in response.headers['content-type'] 23 | 24 | def test_prediction_upload_missing_headers(): 25 | img_saved_path = BASE_DIR / "images" 26 | settings = get_settings() 27 | for path in img_saved_path.glob("*"): 28 | try: 29 | img = Image.open(path) 30 | except: 31 | img = None 32 | response = requests.post(ENDPOINT, 33 | files={"file": open(path, 'rb')} 34 | ) 35 | assert response.status_code == 401 36 | 37 | 38 | def test_prediction_upload(): 39 | img_saved_path = BASE_DIR / "images" 40 | settings = get_settings() 41 | for path in img_saved_path.glob("*"): 42 | try: 43 | img = Image.open(path) 44 | except: 45 | img = None 46 | response = requests.post(ENDPOINT, 47 | files={"file": open(path, 'rb')}, 48 | headers={"Authorization": f"JWT {settings.app_auth_token_prod}"} 49 | ) 50 | if img is None: 51 | assert response.status_code == 400 52 | else: 53 | # Returning a valid image 54 | assert response.status_code == 200 55 | data = response.json() 56 | assert len(data.keys()) == 2 -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RUN_PORT=${PORT:-8000} 4 | 5 | /usr/local/bin/gunicorn --worker-tmp-dir /dev/shm -k uvicorn.workers.UvicornWorker app.main:app --bind "0.0.0.0:${RUN_PORT}" -------------------------------------------------------------------------------- /ms-fastapi-django.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = lib/* bin/* include/* 3 | -------------------------------------------------------------------------------- /pyvenv.cfg: -------------------------------------------------------------------------------- 1 | home = /Library/Frameworks/Python.framework/Versions/3.8/bin 2 | include-system-site-packages = false 3 | version = 3.8.2 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | gunicorn 3 | uvicorn 4 | jinja2 5 | pytest 6 | requests 7 | pre-commit 8 | python-dotenv 9 | aiofiles 10 | python-multipart 11 | pillow 12 | pytesseract -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.2 --------------------------------------------------------------------------------