├── .coveragerc ├── .dockerignore ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── README.md ├── app ├── api │ ├── __init__.py │ ├── deps.py │ └── v1 │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── quiz.py │ │ ├── user.py │ │ └── webhook.py ├── config.py ├── database.py ├── lib │ └── telegram │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── telegram.py ├── main.py ├── models.py ├── schemas.py └── static │ ├── jwt-decode.js │ ├── login.html │ ├── quiz.html │ └── style.css ├── ca.pem ├── docs └── README.md ├── insert_quizzes.sql ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── test_main.py ├── test_quiz.py ├── test_user.py └── test_webhook.py ├── tutorial ├── README.md ├── chapter_1-1 │ ├── 01_intro.py │ ├── 02_path_params.py │ ├── 03_query_params.py │ ├── 04_request_body.py │ ├── 05_resopnse_model.py │ ├── 06_data_validation.py │ └── 07_header_and_cookie_params.py └── chapter_1-2 │ ├── 01_connect_db.py │ ├── 02_form_data.py │ ├── 03_file_handling.py │ ├── 04_error_handling.py │ ├── 05_dependency_injection.py │ ├── 06_auth.py │ ├── 06_auth2.py │ ├── 07_background_tasks.py │ └── 08_middleware.py └── tutorial_app ├── __init__.py ├── database.py ├── init-db.sh ├── models.py ├── requirements.txt ├── schemas.py └── static └── login.html /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | app/lib/* 4 | app/config.py -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.classpath 3 | **/.dockerignore 4 | **/.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/azds.yaml 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | 27 | .coverage 28 | .coveragerc 29 | .pre-commit-config.yaml 30 | docs/ 31 | htmlcov/ 32 | tests/ 33 | tutorial/ 34 | tutorial_app 35 | venv/ 36 | insert_quizzes.sql 37 | pytest.ini 38 | prod.env 39 | README.md 40 | requirements-dev.txt 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: DEPLOY 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["TEST"] 6 | branches: [release] 7 | types: 8 | - completed 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | env: 15 | REGISTRY: hardcoder.azurecr.io 16 | IMAGE_NAME: quizbot 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - uses: actions/checkout@v2 21 | - name: Set up Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: "3.9" 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | 31 | - name: "Login via Azure CLI" 32 | uses: azure/login@v1 33 | with: 34 | creds: ${{ secrets.AZURE_CREDENTIALS }} 35 | 36 | - uses: azure/docker-login@v1 37 | with: 38 | login-server: ${{ env.REGISTRY }} 39 | username: ${{ secrets.AZURE_USERNAME }} 40 | password: ${{ secrets.AZURE_PASSWORD }} 41 | - run: | 42 | docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} . 43 | docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | # - uses: azure/webapps-deploy@v2 45 | # with: 46 | # app-name: fcquiz 47 | # publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} 48 | # images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: TEST 2 | 3 | on: [push] 4 | 5 | env: 6 | APP_ENV: test 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | services: 12 | mysql: 13 | image: mysql:8.0 14 | ports: 15 | - 3306:3306 16 | env: 17 | MYSQL_DATABASE: testing 18 | MYSQL_ROOT_PASSWORD: 1234 19 | MYSQL_USER: admin 20 | MYSQL_PASSWORD: 1234 21 | options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 22 | 23 | steps: 24 | # MySQL 서비스 될 때까지 대기 25 | - name: MySQL health check 26 | env: 27 | PORT: ${{ job.services.mysql.ports[3306] }} 28 | run: | 29 | while ! mysqladmin ping -h"127.0.0.1" -P"$PORT" --silent; do 30 | sleep 1 31 | done 32 | 33 | # 파이썬 설정 34 | - uses: actions/checkout@v2 35 | - name: Set up Python 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: "3.9" 39 | 40 | # 디펜더시 설치 41 | - name: Install dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | pip install -r requirements.txt 45 | pip install -r requirements-dev.txt 46 | 47 | # 테스트 48 | - name: Test Code 49 | env: 50 | TESTING: true 51 | TELEGRAM_BOT_TOKEN: 123:sometoken 52 | DB_USERNAME: admin 53 | DB_PASSWORD: 1234 54 | DB_HOST: 127.0.0.1 55 | DB_PORT: 3306 56 | DB_NAME: testing 57 | run: | 58 | pytest 59 | zip -r9 report.zip htmlcov/ 60 | 61 | # 테스트 결과 62 | - uses: actions/upload-artifact@v2 63 | name: Upload Artifact 64 | with: 65 | name: REPORT 66 | path: report.zip 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | .vscode/ 4 | 5 | # virtual environments 6 | prod.env 7 | .env 8 | env/ 9 | venv/ 10 | 11 | # python 12 | **/__pycache__/ 13 | *.pyc 14 | 15 | # Unit test / coverage reports 16 | htmlcov/ 17 | .tox/ 18 | .nox/ 19 | .coverage 20 | .coverage.* 21 | .cache 22 | nosetests.xml 23 | coverage.xml 24 | *.cover 25 | *.py,cover 26 | .hypothesis/ 27 | .pytest_cache/ 28 | cover/ 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 21.5b2 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | args: ['--line-length', '100'] 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # For more information, please refer to https://aka.ms/vscode-docker-python 2 | FROM python:3.9-slim 3 | 4 | EXPOSE 8000 5 | 6 | # Keeps Python from generating .pyc files in the container 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | 9 | # Turns off buffering for easier container logging 10 | ENV PYTHONUNBUFFERED=1 11 | 12 | # Install pip requirements 13 | COPY requirements.txt . 14 | RUN python -m pip install -r requirements.txt 15 | 16 | WORKDIR /app 17 | COPY . /app 18 | 19 | # Creates a non-root user with an explicit UID and adds permission to access the /app folder 20 | # For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers 21 | RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app 22 | USER appuser 23 | 24 | # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug 25 | CMD ["gunicorn", "--bind", "0.0.0.0:8000", "-k", "uvicorn.workers.UvicornWorker", "app.main:app"] 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![TEST](https://github.com/hard-coders/fastcampusapi/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/hard-coders/fastcampusapi/actions/workflows/test.yml) 2 | [![DEPLOY](https://github.com/hard-coders/fastcampusapi/actions/workflows/deploy.yml/badge.svg?branch=release)](https://github.com/hard-coders/fastcampusapi/actions/workflows/deploy.yml) 3 | 4 | # FastCampus FastAPI Lecture 2021 5 | 6 | ## FastAPI 기초 - `tutorial` 7 | 8 | - Chapter 1-1 9 | - Chapter 1-2 10 | 11 | ## FastAPI 실전 - `app` 12 | 13 | 텔레그램 퀴즈봇 on Azure 14 | 15 | ## 강의노트 16 | 17 | [강의노트 바로가기](https://www.notion.so/fastapi/dddb1dba1d154834bd7968a8daf89995?v=c35c3464fa3d43b3b65d5cfd75cd84a5) 18 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from . import v1, deps # noqa 3 | 4 | router = APIRouter() 5 | router.include_router(v1.router, prefix="/v1") 6 | -------------------------------------------------------------------------------- /app/api/deps.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | from hashlib import sha256 3 | from datetime import datetime, timedelta 4 | from typing import Optional 5 | 6 | from fastapi import Depends, HTTPException 7 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 8 | from jose import jwt 9 | from jose.exceptions import ExpiredSignatureError 10 | from pydantic.main import BaseModel 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app import models 14 | from app.config import settings 15 | from app.database import get_db 16 | 17 | 18 | security = HTTPBearer() 19 | 20 | 21 | class AuthTelegram(BaseModel): 22 | id: str 23 | first_name: str 24 | last_name: str 25 | username: str 26 | photo_url: str 27 | auth_date: str 28 | hash: str 29 | 30 | 31 | async def create_access_token(auth: AuthTelegram, exp: Optional[timedelta] = None) -> str: 32 | expire = datetime.utcnow() + (exp or timedelta(minutes=30)) 33 | data = { 34 | "id": int(auth.id), 35 | "username": auth.username, 36 | "photo_url": auth.photo_url, 37 | "exp": expire, 38 | } 39 | 40 | return jwt.encode(data, settings.SECRET_KEY.get_secret_value(), algorithm="HS256") 41 | 42 | 43 | async def verify_telegram_login(auth: AuthTelegram) -> str: 44 | """ 45 | See: https://core.telegram.org/widgets/login#checking-authorization 46 | """ 47 | data = auth.dict() 48 | hash_ = data.pop("hash") 49 | bot_token = settings.TELEGRAM_BOT_TOKEN.get_secret_value() 50 | 51 | data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items())) 52 | secret_key = sha256(bot_token.encode()).digest() 53 | 54 | h = hmac.new(secret_key, msg=data_check_string.encode(), digestmod=sha256) 55 | 56 | if hmac.compare_digest(h.hexdigest(), hash_): 57 | return await create_access_token(auth) 58 | raise HTTPException(401, "Failed to verify Telegram") 59 | 60 | 61 | async def get_user( 62 | cred: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) 63 | ) -> models.User: 64 | token = cred.credentials 65 | try: 66 | decoded_data = jwt.decode(token, settings.SECRET_KEY.get_secret_value(), "HS256") 67 | except ExpiredSignatureError: 68 | raise HTTPException(401, "Expired") 69 | 70 | db_user = db.query(models.User).filter(models.User.id == decoded_data.get("id")).first() 71 | if not db_user: 72 | raise HTTPException(401, "Not registred") 73 | 74 | return db_user 75 | -------------------------------------------------------------------------------- /app/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api.deps import get_user 4 | from . import webhook, user, quiz, auth 5 | 6 | 7 | router = APIRouter() 8 | router.include_router(webhook.router, prefix="/webhook", dependencies=[Depends(get_user)]) 9 | router.include_router(auth.router, prefix="/auth", tags=["Auth"]) 10 | router.include_router(user.router, prefix="/users", tags=["User"], dependencies=[Depends(get_user)]) 11 | router.include_router( 12 | quiz.router, prefix="/quizzes", tags=["Quiz"], dependencies=[Depends(get_user)] 13 | ) 14 | -------------------------------------------------------------------------------- /app/api/v1/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api.deps import verify_telegram_login 4 | 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.post("") 10 | async def verfiy_telegram(token: str = Depends(verify_telegram_login)): 11 | return {"token": token} 12 | -------------------------------------------------------------------------------- /app/api/v1/quiz.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends, status, Form 4 | from sqlalchemy import func 5 | from sqlalchemy.orm.session import Session 6 | 7 | from app import models, schemas 8 | from app.database import get_db 9 | 10 | 11 | router = APIRouter() 12 | 13 | 14 | async def add_quiz(question: str, content: str, answer: int, db: Session) -> models.Quiz: 15 | row = models.Quiz(question=question, content=content, answer=answer) 16 | db.add(row) 17 | db.commit() 18 | 19 | return row 20 | 21 | 22 | @router.get("", response_model=List[schemas.Quiz]) 23 | async def get_quiz_list(db: Session = Depends(get_db)): 24 | return db.query(models.Quiz).all() 25 | 26 | 27 | @router.post("", response_model=schemas.ResourceId, status_code=status.HTTP_201_CREATED) 28 | async def create_quiz(data: schemas.QuizCreate, db: Session = Depends(get_db)): 29 | return await add_quiz(**data.dict(), db=db) 30 | 31 | 32 | @router.post("/form", response_model=schemas.ResourceId, status_code=status.HTTP_201_CREATED) 33 | async def create_quiz_redirect( 34 | question: str = Form(..., title="퀴즈 질문"), 35 | content: str = Form(..., title="퀴즈 내용"), 36 | answer: int = Form(..., title="정답"), 37 | db: Session = Depends(get_db), 38 | ): 39 | return await add_quiz(question, content, answer, db) 40 | 41 | 42 | @router.get("/random", response_model=schemas.Quiz) 43 | async def get_quiz_randomly(db: Session = Depends(get_db)): 44 | return db.query(models.Quiz).order_by(func.RAND()).first() 45 | -------------------------------------------------------------------------------- /app/api/v1/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends 4 | from sqlalchemy.orm.session import Session 5 | 6 | from app import models, schemas 7 | from app.database import get_db 8 | 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get("", response_model=List[schemas.User]) 14 | async def get_user_list(db: Session = Depends(get_db)): 15 | return db.query(models.User).all() 16 | -------------------------------------------------------------------------------- /app/api/v1/webhook.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Request, Depends 2 | from pydantic import HttpUrl 3 | from sqlalchemy import func 4 | from sqlalchemy.orm.session import Session 5 | 6 | from app import models, schemas 7 | from app.config import settings 8 | from app.database import get_db 9 | from app.lib import telegram 10 | 11 | router = APIRouter() 12 | bot = telegram.Telegram(settings.TELEGRAM_BOT_TOKEN) 13 | 14 | 15 | def add_user(user: schemas.User, db: Session) -> models.User: 16 | row = models.User( 17 | id=user.id, 18 | username=user.username, 19 | first_name=user.first_name, 20 | last_name=user.last_name, 21 | ) 22 | db.add(row) 23 | db.commit() 24 | return row 25 | 26 | 27 | @router.get("") 28 | async def get_webhook(): 29 | return await bot.get_webhook() 30 | 31 | 32 | @router.post("") 33 | async def set_webhook(url: HttpUrl = Body(..., embed=True)): 34 | return await bot.set_webhook(url) 35 | 36 | 37 | @router.post(f"/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}") 38 | async def webhook(request: Request, db: Session = Depends(get_db)): 39 | req = await request.json() 40 | print(req) 41 | update = telegram.schemas.Update.parse_obj(req) 42 | message = update.message 43 | user = update.message.from_ 44 | 45 | db_user = db.query(models.User).filter_by(id=user.id).first() 46 | if not db_user: 47 | db_user = add_user(user, db) 48 | 49 | msg = "✨ '문제' 또는 '퀴즈'라고 말씀하시면 문제를 냅니다!" 50 | if "문제" in message.text or "퀴즈" in message.text: 51 | quiz = db.query(models.Quiz).order_by(func.RAND()).first() 52 | 53 | if not quiz: 54 | await bot.send_message(message.chat.id, "퀴즈가 없습니다") 55 | return 56 | 57 | db_user.quiz_id = quiz.id 58 | msg = f"{quiz.question}\n\n{quiz.content}" 59 | elif db_user.quiz_id and message.text.isnumeric(): 60 | correct = db_user.quiz.answer == int(message.text) 61 | msg = f"아쉽네요, {db_user.quiz.answer}번이 정답입니다." 62 | 63 | if correct: 64 | db_user.score += 1 65 | msg = f"{db_user.quiz.answer}번, 정답입니다!" 66 | 67 | db_user.quiz_id = None 68 | 69 | await bot.send_message(message.chat.id, msg) 70 | db.commit() 71 | 72 | return "OK" 73 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | 4 | from pydantic import BaseSettings, SecretStr 5 | 6 | 7 | class Settings(BaseSettings): 8 | DB_USERNAME: str 9 | DB_PASSWORD: SecretStr 10 | DB_HOST: str 11 | DB_PORT: int 12 | DB_NAME: str 13 | 14 | SECRET_KEY: SecretStr 15 | TELEGRAM_BOT_TOKEN: SecretStr 16 | 17 | TESTING = False 18 | 19 | class Config: 20 | env_file = ".env" 21 | env_file_encoding = "utf-8" 22 | 23 | 24 | class TestSettings(BaseSettings): 25 | DB_USERNAME = "admin" 26 | DB_PASSWORD: SecretStr = "1234" 27 | DB_HOST = "localhost" 28 | DB_PORT = 3306 29 | DB_NAME = "testing" 30 | 31 | SECRET_KEY: SecretStr = "secret" 32 | TELEGRAM_BOT_TOKEN: SecretStr = "secret" 33 | 34 | TESTING = True 35 | 36 | 37 | @lru_cache 38 | def get_settings(): 39 | if os.getenv("APP_ENV", "dev").lower() == "test": 40 | return TestSettings() 41 | return Settings() 42 | 43 | 44 | settings = get_settings() 45 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | from app.config import settings 8 | 9 | 10 | engine = create_engine( 11 | "mysql+pymysql://{username}:{password}@{host}:{port}/{name}?charset=utf8mb4".format( 12 | username=settings.DB_USERNAME, 13 | password=settings.DB_PASSWORD.get_secret_value(), 14 | host=settings.DB_HOST, 15 | port=settings.DB_PORT, 16 | name=settings.DB_NAME, 17 | ), 18 | connect_args={"ssl": {"ca": "/app/ca.pem"}} if os.getenv("APP_ENV") == "prod" else {}, 19 | ) 20 | SessionLocal = sessionmaker( 21 | bind=engine, 22 | autocommit=False, 23 | autoflush=False, 24 | ) 25 | 26 | Base = declarative_base() 27 | 28 | 29 | def get_db(): 30 | db = SessionLocal() 31 | try: 32 | yield db 33 | finally: 34 | db.close() 35 | -------------------------------------------------------------------------------- /app/lib/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from . import schemas # noqa 2 | from .telegram import Telegram # noqa 3 | -------------------------------------------------------------------------------- /app/lib/telegram/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from datetime import datetime 3 | from enum import Enum 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | # ----------------------------------------------------------------------------- 9 | # enums 10 | # ----------------------------------------------------------------------------- 11 | class ChatType(str, Enum): 12 | private = "private" 13 | group = "group" 14 | supergroup = "supergroup" 15 | channel = "channel" 16 | 17 | 18 | # ----------------------------------------------------------------------------- 19 | # models 20 | # ----------------------------------------------------------------------------- 21 | class User(BaseModel): 22 | id: int 23 | is_bot: bool 24 | first_name: str 25 | last_name: str 26 | username: str 27 | language_code: str 28 | 29 | 30 | class Chat(BaseModel): 31 | id: int 32 | type: ChatType 33 | first_name: Optional[str] 34 | last_name: Optional[str] 35 | username: Optional[str] 36 | 37 | 38 | class Message(BaseModel): 39 | message_id: int 40 | from_: Optional[User] = Field( 41 | None, 42 | title="Sender", 43 | description="Sender, empty for messages sent to channels", 44 | alias="from", 45 | ) 46 | chat: Chat 47 | date: datetime 48 | text: Optional[str] = Field(None, max_length=4096) 49 | 50 | 51 | class Update(BaseModel): 52 | update_id: int 53 | message: Optional[Message] 54 | -------------------------------------------------------------------------------- /app/lib/telegram/telegram.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import httpx 4 | from pydantic import SecretStr 5 | 6 | 7 | class Telegram: 8 | API_HOST = "https://api.telegram.org" 9 | 10 | def __init__(self, token: Union[str, SecretStr]) -> None: 11 | self._token = token 12 | self.client = httpx.AsyncClient(base_url=self.host) 13 | 14 | @property 15 | def token(self): 16 | if isinstance(self._token, SecretStr): 17 | return self._token.get_secret_value() 18 | return self._token 19 | 20 | @property 21 | def host(self): 22 | return f"{self.API_HOST}/bot{self.token}/" 23 | 24 | async def get_bot_info(self) -> dict: 25 | """Get current bot info""" 26 | r = await self.client.get("getMe") 27 | return r.json() 28 | 29 | async def get_webhook(self): 30 | r = await self.client.get("getWebhookInfo") 31 | return r.json() 32 | 33 | async def set_webhook(self, url: str): 34 | r = await self.client.post("setWebhook", data={"url": url}) 35 | return r.json() 36 | 37 | async def send_message(self, chat_id: int, text: str): 38 | r = await self.client.post("sendMessage", data={"chat_id": chat_id, "text": text}) 39 | return r.json() 40 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.staticfiles import StaticFiles 4 | 5 | from app import api 6 | 7 | 8 | app = FastAPI() 9 | app.add_middleware( 10 | CORSMiddleware, 11 | allow_origins={"https://fcquiz.azurewebsites.net", "http://localhost"}, 12 | allow_credentials=True, 13 | allow_methods={"OPTIONS", "GET", "POST"}, 14 | allow_headers={"*"}, 15 | ) 16 | app.mount("/static", StaticFiles(directory="app/static"), name="static") 17 | 18 | 19 | @app.on_event("startup") 20 | async def startup_event(): 21 | from app.database import engine, Base 22 | 23 | Base.metadata.create_all(bind=engine) 24 | 25 | 26 | @app.get("/") 27 | async def healthcheck(): 28 | return {"ok": True} 29 | 30 | 31 | app.include_router(api.router) 32 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, DateTime, func, Text 2 | from sqlalchemy.orm import relationship 3 | from sqlalchemy.sql.schema import ForeignKey 4 | 5 | from app.database import Base 6 | 7 | 8 | class BaseMixin: 9 | id = Column(Integer, primary_key=True, index=True) 10 | created_at = Column(DateTime, nullable=False, default=func.utc_timestamp()) 11 | updated_at = Column( 12 | DateTime, nullable=False, default=func.utc_timestamp(), onupdate=func.utc_timestamp() 13 | ) 14 | 15 | 16 | class User(BaseMixin, Base): 17 | __tablename__ = "user" 18 | 19 | quiz_id = Column(Integer, ForeignKey("quiz.id"), nullable=True) 20 | username = Column(String(100), nullable=False) 21 | first_name = Column(String(100)) 22 | last_name = Column(String(100)) 23 | score = Column(Integer, default=0) 24 | 25 | quiz = relationship("Quiz", back_populates="current_users", uselist=False) 26 | 27 | 28 | class Quiz(BaseMixin, Base): 29 | __tablename__ = "quiz" 30 | 31 | question = Column(Text, nullable=False) 32 | content = Column(Text, nullable=False) 33 | answer = Column(Integer, nullable=False) 34 | 35 | current_users = relationship("User", back_populates="quiz") 36 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class ResourceId(BaseModel): 5 | id: int 6 | 7 | class Config: 8 | orm_mode = True 9 | 10 | 11 | class User(BaseModel): 12 | id: int 13 | first_name: str 14 | last_name: str 15 | username: str 16 | score: int 17 | 18 | class Config: 19 | orm_mode = True 20 | 21 | 22 | class QuizCreate(BaseModel): 23 | question: str = Field(..., title="퀴즈 질문", example="🇰🇷 대한민국의 수도는?") 24 | content: str = Field(..., title="퀴즈 내용", example="1️⃣ 서울\n2️⃣ 인천\n3️⃣ 부산\n4️⃣ 대구") 25 | answer: int = Field(..., title="정답", example=1) 26 | 27 | 28 | class Quiz(QuizCreate): 29 | id: int 30 | 31 | class Config: 32 | orm_mode = True 33 | -------------------------------------------------------------------------------- /app/static/jwt-decode.js: -------------------------------------------------------------------------------- 1 | (function (factory) { 2 | typeof define === 'function' && define.amd ? define(factory) : 3 | factory(); 4 | }((function () { 'use strict'; 5 | 6 | /** 7 | * The code was extracted from: 8 | * https://github.com/davidchambers/Base64.js 9 | */ 10 | 11 | var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; 12 | 13 | function InvalidCharacterError(message) { 14 | this.message = message; 15 | } 16 | 17 | InvalidCharacterError.prototype = new Error(); 18 | InvalidCharacterError.prototype.name = "InvalidCharacterError"; 19 | 20 | function polyfill(input) { 21 | var str = String(input).replace(/=+$/, ""); 22 | if (str.length % 4 == 1) { 23 | throw new InvalidCharacterError( 24 | "'atob' failed: The string to be decoded is not correctly encoded." 25 | ); 26 | } 27 | for ( 28 | // initialize result and counters 29 | var bc = 0, bs, buffer, idx = 0, output = ""; 30 | // get next character 31 | (buffer = str.charAt(idx++)); 32 | // character found in table? initialize bit storage and add its ascii value; 33 | ~buffer && 34 | ((bs = bc % 4 ? bs * 64 + buffer : buffer), 35 | // and if not first of each 4 characters, 36 | // convert the first 8 bits to one ascii character 37 | bc++ % 4) ? 38 | (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) : 39 | 0 40 | ) { 41 | // try to find character in table (0-63, not found => -1) 42 | buffer = chars.indexOf(buffer); 43 | } 44 | return output; 45 | } 46 | 47 | var atob = (typeof window !== "undefined" && 48 | window.atob && 49 | window.atob.bind(window)) || 50 | polyfill; 51 | 52 | function b64DecodeUnicode(str) { 53 | return decodeURIComponent( 54 | atob(str).replace(/(.)/g, function(m, p) { 55 | var code = p.charCodeAt(0).toString(16).toUpperCase(); 56 | if (code.length < 2) { 57 | code = "0" + code; 58 | } 59 | return "%" + code; 60 | }) 61 | ); 62 | } 63 | 64 | function base64_url_decode(str) { 65 | var output = str.replace(/-/g, "+").replace(/_/g, "/"); 66 | switch (output.length % 4) { 67 | case 0: 68 | break; 69 | case 2: 70 | output += "=="; 71 | break; 72 | case 3: 73 | output += "="; 74 | break; 75 | default: 76 | throw "Illegal base64url string!"; 77 | } 78 | 79 | try { 80 | return b64DecodeUnicode(output); 81 | } catch (err) { 82 | return atob(output); 83 | } 84 | } 85 | 86 | function InvalidTokenError(message) { 87 | this.message = message; 88 | } 89 | 90 | InvalidTokenError.prototype = new Error(); 91 | InvalidTokenError.prototype.name = "InvalidTokenError"; 92 | 93 | function jwtDecode(token, options) { 94 | if (typeof token !== "string") { 95 | throw new InvalidTokenError("Invalid token specified"); 96 | } 97 | 98 | options = options || {}; 99 | var pos = options.header === true ? 0 : 1; 100 | try { 101 | return JSON.parse(base64_url_decode(token.split(".")[pos])); 102 | } catch (e) { 103 | throw new InvalidTokenError("Invalid token specified: " + e.message); 104 | } 105 | } 106 | 107 | /* 108 | * Expose the function on the window object 109 | */ 110 | 111 | //use amd or just through the window object. 112 | if (window) { 113 | if (typeof window.define == "function" && window.define.amd) { 114 | window.define("jwt_decode", function() { 115 | return jwtDecode; 116 | }); 117 | } else if (window) { 118 | window.jwt_decode = jwtDecode; 119 | } 120 | } 121 | 122 | }))); 123 | //# sourceMappingURL=jwt-decode.js.map -------------------------------------------------------------------------------- /app/static/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

🔒 로그인하기

11 | 14 |
15 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/static/quiz.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QuizBot 6 | 7 | 8 | 9 | 10 | 11 | 19 |
20 |
21 |

🆀 퀴즈 문제 만들기

22 |
23 | 24 | 26 | 27 | 28 |
29 |
30 |
31 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/static/style.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Open+Sans); 2 | 3 | .btn { 4 | display: inline-block; 5 | *display: inline; 6 | *zoom: 1; 7 | padding: 4px 10px 4px; 8 | margin-bottom: 0; 9 | font-size: 13px; 10 | line-height: 18px; 11 | color: #333333; 12 | text-align: center; 13 | text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); 14 | vertical-align: middle; 15 | background-color: #f5f5f5; 16 | background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); 17 | background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6); 18 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); 19 | background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); 20 | background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); 21 | background-image: linear-gradient(top, #ffffff, #e6e6e6); 22 | background-repeat: repeat-x; 23 | filter: progid:dximagetransform.microsoft.gradient(startColorstr=#ffffff, endColorstr=#e6e6e6, GradientType=0); 24 | border-color: #e6e6e6 #e6e6e6 #e6e6e6; 25 | border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); 26 | border: 1px solid #e6e6e6; 27 | -webkit-border-radius: 4px; 28 | -moz-border-radius: 4px; 29 | border-radius: 4px; 30 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 31 | -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 32 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 33 | cursor: pointer; 34 | *margin-left: .3em; 35 | } 36 | 37 | .btn:hover, 38 | .btn:active, 39 | .btn.active, 40 | .btn.disabled, 41 | .btn[disabled] { 42 | background-color: #e6e6e6; 43 | } 44 | 45 | .btn-large { 46 | padding: 9px 14px; 47 | font-size: 15px; 48 | line-height: normal; 49 | -webkit-border-radius: 5px; 50 | -moz-border-radius: 5px; 51 | border-radius: 5px; 52 | } 53 | 54 | .btn:hover { 55 | color: #333333; 56 | text-decoration: none; 57 | background-color: #e6e6e6; 58 | background-position: 0 -15px; 59 | -webkit-transition: background-position 0.1s linear; 60 | -moz-transition: background-position 0.1s linear; 61 | -ms-transition: background-position 0.1s linear; 62 | -o-transition: background-position 0.1s linear; 63 | transition: background-position 0.1s linear; 64 | } 65 | 66 | .btn-primary, 67 | .btn-primary:hover { 68 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 69 | color: #ffffff; 70 | } 71 | 72 | .btn-primary.active { 73 | color: rgba(255, 255, 255, 0.75); 74 | } 75 | 76 | .btn-primary { 77 | background-color: #4a77d4; 78 | background-image: -moz-linear-gradient(top, #6eb6de, #4a77d4); 79 | background-image: -ms-linear-gradient(top, #6eb6de, #4a77d4); 80 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#6eb6de), to(#4a77d4)); 81 | background-image: -webkit-linear-gradient(top, #6eb6de, #4a77d4); 82 | background-image: -o-linear-gradient(top, #6eb6de, #4a77d4); 83 | background-image: linear-gradient(top, #6eb6de, #4a77d4); 84 | background-repeat: repeat-x; 85 | filter: progid:dximagetransform.microsoft.gradient(startColorstr=#6eb6de, endColorstr=#4a77d4, GradientType=0); 86 | border: 1px solid #3762bc; 87 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4); 88 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.5); 89 | } 90 | 91 | .btn-primary:hover, 92 | .btn-primary:active, 93 | .btn-primary.active, 94 | .btn-primary.disabled, 95 | .btn-primary[disabled] { 96 | filter: none; 97 | background-color: #4a77d4; 98 | } 99 | 100 | .btn-block { 101 | width: 100%; 102 | display: block; 103 | } 104 | 105 | * { 106 | -webkit-box-sizing: border-box; 107 | -moz-box-sizing: border-box; 108 | -ms-box-sizing: border-box; 109 | -o-box-sizing: border-box; 110 | box-sizing: border-box; 111 | } 112 | 113 | html { 114 | width: 100%; 115 | height: 100%; 116 | overflow: hidden; 117 | } 118 | 119 | body { 120 | width: 100%; 121 | height: 100%; 122 | font-family: 'Open Sans', sans-serif; 123 | background: #092756; 124 | background: -moz-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -moz-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -moz-linear-gradient(-45deg, #670d10 0%, #092756 100%); 125 | background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -webkit-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -webkit-linear-gradient(-45deg, #670d10 0%, #092756 100%); 126 | background: -o-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -o-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -o-linear-gradient(-45deg, #670d10 0%, #092756 100%); 127 | background: -ms-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), -ms-linear-gradient(top, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), -ms-linear-gradient(-45deg, #670d10 0%, #092756 100%); 128 | background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104, 128, 138, .4) 10%, rgba(138, 114, 76, 0) 40%), linear-gradient(to bottom, rgba(57, 173, 219, .25) 0%, rgba(42, 60, 87, .4) 100%), linear-gradient(135deg, #670d10 0%, #092756 100%); 129 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#3E1D6D', endColorstr='#092756', GradientType=1); 130 | } 131 | 132 | .theme { 133 | position: absolute; 134 | top: 40%; 135 | left: 50%; 136 | margin: -150px 0 0 -150px; 137 | width: 300px; 138 | height: 300px; 139 | } 140 | 141 | .theme h1 { 142 | color: #fff; 143 | text-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 144 | letter-spacing: 1px; 145 | text-align: center; 146 | } 147 | 148 | textarea, 149 | input { 150 | width: 100%; 151 | margin-bottom: 10px; 152 | background: rgba(0, 0, 0, 0.3); 153 | border: none; 154 | outline: none; 155 | padding: 10px; 156 | font-size: 13px; 157 | color: #fff; 158 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3); 159 | border: 1px solid rgba(0, 0, 0, 0.3); 160 | border-radius: 4px; 161 | box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.2), 0 1px 1px rgba(255, 255, 255, 0.2); 162 | -webkit-transition: box-shadow .5s ease; 163 | -moz-transition: box-shadow .5s ease; 164 | -o-transition: box-shadow .5s ease; 165 | -ms-transition: box-shadow .5s ease; 166 | transition: box-shadow .5s ease; 167 | } 168 | 169 | input:focus { 170 | box-shadow: inset 0 -5px 45px rgba(100, 100, 100, 0.4), 0 1px 1px rgba(255, 255, 255, 0.2); 171 | } 172 | 173 | ul { 174 | list-style-type: none; 175 | margin: 0; 176 | padding: 0; 177 | overflow: hidden; 178 | } 179 | 180 | li { 181 | float: left; 182 | display: block; 183 | color: white; 184 | text-align: center; 185 | padding: 15px 10px; 186 | text-decoration: none; 187 | } 188 | 189 | li img { 190 | vertical-align: middle; 191 | width: 30px; 192 | height: 30px; 193 | border-radius: 50%; 194 | } -------------------------------------------------------------------------------- /ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ 3 | RTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYD 4 | VQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoX 5 | DTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9y 6 | ZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVy 7 | VHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKr 8 | mD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjr 9 | IZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeK 10 | mpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSu 11 | XmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZy 12 | dc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/ye 13 | jl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1 14 | BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3 15 | DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT92 16 | 9hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3Wgx 17 | jkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0 18 | Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhz 19 | ksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLS 20 | R9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp 21 | -----END CERTIFICATE----- 22 | 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hard-coders/fastcampusapi/89f77c55ac1abc45c925bef84d01a48c24ef703f/docs/README.md -------------------------------------------------------------------------------- /insert_quizzes.sql: -------------------------------------------------------------------------------- 1 | insert into quiz(id,created_at,updated_at,question,content,answer) values('1','2021-06-05 07:45:02','2021-06-05 07:45:02','🇰🇷 대한민국의 수도는?','1️⃣ 서울 2 | 2️⃣ 인천 3 | 3️⃣ 부산 4 | 4️⃣ 대구','1'); 5 | insert into quiz(id,created_at,updated_at,question,content,answer) values('2','2021-06-05 08:00:50','2021-06-05 08:00:50','🐍 파이썬은 무슨 동물인가요?','1️⃣ 돌고래 6 | 2️⃣ 두더지 7 | 3️⃣ 비단뱀 8 | 4️⃣ 코끼리','3'); 9 | insert into quiz(id,created_at,updated_at,question,content,answer) values('3','2021-06-05 08:03:00','2021-06-05 08:03:00','🐬 MySQL 로고의 동물은 무엇인가요?','1️⃣ 고래 10 | 2️⃣ 돌고래 11 | 3️⃣ 물개 12 | 4️⃣ 코끼리','2'); 13 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov=app --cov-report term --cov-report html -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | devtools 3 | flake8 4 | pytest 5 | pytest-cov 6 | pytest-asyncio 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==0.6.0 2 | appdirs==1.4.4 3 | appnope==0.1.2 4 | attrs==21.2.0 5 | backcall==0.2.0 6 | bcrypt==3.2.0 7 | certifi==2020.12.5 8 | cffi==1.14.5 9 | click==7.1.2 10 | cryptography==3.4.7 11 | decorator==5.0.8 12 | ecdsa==0.14.1 13 | fastapi==0.63.0 14 | greenlet==1.0.0 15 | h11==0.12.0 16 | httpcore==0.13.3 17 | httpx==0.18.1 18 | idna==3.1 19 | iniconfig==1.1.1 20 | ipykernel==5.5.5 21 | ipython==7.24.1 22 | ipython-genutils==0.2.0 23 | jedi==0.18.0 24 | jupyter-client==6.1.12 25 | jupyter-core==4.7.1 26 | matplotlib-inline==0.1.2 27 | mccabe==0.6.1 28 | packaging==20.9 29 | parso==0.8.2 30 | passlib==1.7.4 31 | pathspec==0.8.1 32 | pexpect==4.8.0 33 | pickleshare==0.7.5 34 | pluggy==0.13.1 35 | prompt-toolkit==3.0.18 36 | ptyprocess==0.7.0 37 | py==1.10.0 38 | pyasn1==0.4.8 39 | pycodestyle==2.7.0 40 | pycparser==2.20 41 | pydantic==1.8.1 42 | pyflakes==2.3.1 43 | Pygments==2.9.0 44 | PyMySQL==1.0.2 45 | pyparsing==2.4.7 46 | python-dateutil==2.8.1 47 | python-dotenv==0.17.1 48 | python-jose==3.2.0 49 | python-multipart==0.0.5 50 | pyzmq==22.0.3 51 | regex==2021.4.4 52 | rfc3986==1.5.0 53 | rsa==4.7.2 54 | six==1.15.0 55 | sniffio==1.2.0 56 | SQLAlchemy==1.4.11 57 | starlette==0.13.6 58 | toml==0.10.2 59 | tornado==6.1 60 | traitlets==5.0.5 61 | typing-extensions==3.7.4.3 62 | uvicorn==0.13.4 63 | uvloop==0.15.2 64 | wcwidth==0.2.5 65 | gunicorn 66 | httptools 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hard-coders/fastcampusapi/89f77c55ac1abc45c925bef84d01a48c24ef703f/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | from app import main, models 5 | from app.database import engine, get_db 6 | from app.config import settings 7 | from app.api import deps 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def app(): 12 | if not settings.TESTING: 13 | raise SystemError("TESTING environment must be set true") 14 | 15 | return main.app 16 | 17 | 18 | @pytest.fixture 19 | async def session(): 20 | db = next(get_db()) 21 | try: 22 | yield db 23 | finally: 24 | db.close() 25 | 26 | 27 | @pytest.fixture 28 | async def default_client(app): 29 | async with AsyncClient(app=app, base_url="http://test") as ac: 30 | yield ac 31 | 32 | 33 | @pytest.fixture 34 | async def client(app, add_user): 35 | async def mock_get_user(): 36 | return add_user() 37 | 38 | app.dependency_overrides[deps.get_user] = mock_get_user 39 | 40 | async with AsyncClient(app=app, base_url="http://test/v1") as ac: 41 | models.Base.metadata.drop_all(bind=engine) 42 | models.Base.metadata.create_all(bind=engine) 43 | yield ac 44 | 45 | 46 | @pytest.fixture 47 | def add_user(session): 48 | def func(username: str = None, first_name: str = None, last_name: str = None): 49 | row = models.User( 50 | username=username or "fc2021", 51 | first_name=first_name or "fast", 52 | last_name=last_name or "campus", 53 | ) 54 | session.add(row) 55 | session.commit() 56 | return row 57 | 58 | return func 59 | 60 | 61 | @pytest.fixture 62 | def add_quiz(session): 63 | def func(question: str = None, content: str = None, answer: int = None) -> models.Quiz: 64 | r = models.Quiz( 65 | question=question or "qqq", 66 | content=content or "text", 67 | answer=answer or 1, 68 | ) 69 | session.add(r) 70 | session.commit() 71 | return r 72 | 73 | return func 74 | 75 | 76 | @pytest.fixture(autouse=True) 77 | async def mock_telegram(monkeypatch): 78 | from app.lib.telegram import Telegram 79 | 80 | async def mock_get_bot_info(*args, **kwargs): 81 | ... 82 | 83 | async def mock_get_webhook(*args, **kwargs): 84 | return { 85 | "ok": True, 86 | "result": { 87 | "has_custom_certificate": False, 88 | "ip_address": "127.0.0.1", 89 | "last_error_date": 1622887352, 90 | "last_error_message": "Wrong response from the webhook: 500 Internal Server Error", 91 | "max_connections": 40, 92 | "pending_update_count": 0, 93 | "url": "https://localhost/v1/webhook/sometoken", 94 | }, 95 | } 96 | 97 | async def mock_set_webhook(*args, **kwargs): 98 | return {"ok": True, "result": True, "description": "Webhook is already set"} 99 | 100 | async def mock_send_message(*args, **kwargs): 101 | return { 102 | "message_id": 1234, 103 | "from": { 104 | "id": 123, 105 | "is_bot": False, 106 | "first_name": "first", 107 | "last_name": "last", 108 | "username": "username", 109 | "language_code": "ko", 110 | }, 111 | "chat": { 112 | "id": 123, 113 | "type": "private", 114 | "first_name": "first", 115 | "last_name": "last", 116 | "username": "username", 117 | }, 118 | "datetime": 1622902229, 119 | "text": "some text", 120 | } 121 | 122 | monkeypatch.setattr(Telegram, "get_bot_info", mock_get_bot_info) 123 | monkeypatch.setattr(Telegram, "get_webhook", mock_get_webhook) 124 | monkeypatch.setattr(Telegram, "set_webhook", mock_set_webhook) 125 | monkeypatch.setattr(Telegram, "send_message", mock_send_message) 126 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_index(default_client): 6 | r = await default_client.get("") 7 | assert r.status_code == 200 8 | -------------------------------------------------------------------------------- /tests/test_quiz.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app import models 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_get_quiz_list(client): 8 | r = await client.get("/quizzes") 9 | 10 | assert r.status_code == 200 11 | assert isinstance(r.json(), list) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "q, expected", 16 | [ 17 | (None, 422), 18 | ("🇰🇷 대한민국의 수도는?", 201), 19 | ("🐬 MySQL 로고의 동물은 무엇인가요?", 201), 20 | ], 21 | ) 22 | @pytest.mark.asyncio 23 | async def test_create_quiz(client, session, q, expected): 24 | data = { 25 | "question": q, 26 | "content": "1️⃣ 서울\n2️⃣ 인천\n3️⃣ 부산\n4️⃣ 대구", 27 | "answer": 1, 28 | } 29 | 30 | r = await client.post("/quizzes", json=data) 31 | row = session.query(models.Quiz).first() 32 | 33 | assert r.status_code == expected 34 | assert q == (row and row.question) 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_get_random_quiz(client, add_quiz): 39 | for _ in range(10): 40 | add_quiz() 41 | 42 | r = await client.get("/quizzes/random") 43 | 44 | assert r.status_code == 200 45 | assert not isinstance(r.json(), list) 46 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_get_empty_user(client): 6 | r = await client.get("/users") 7 | 8 | assert r.status_code == 200 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_get_user(client, add_user): 13 | user = add_user() 14 | r = await client.get("/users") 15 | data = r.json() 16 | 17 | assert r.status_code == 200 18 | assert isinstance(data, list) 19 | assert data[0].get("username") == user.username 20 | -------------------------------------------------------------------------------- /tests/test_webhook.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.config import settings 4 | 5 | 6 | data = { 7 | "update_id": 1234, 8 | "message": { 9 | "message_id": 123, 10 | "from": { 11 | "id": 12345, 12 | "is_bot": False, 13 | "first_name": "호열", 14 | "last_name": "이", 15 | "username": "hard_coders", 16 | "language_code": "ko", 17 | }, 18 | "chat": { 19 | "id": 123, 20 | "first_name": "호열", 21 | "last_name": "이", 22 | "username": "hard_coders", 23 | "type": "private", 24 | }, 25 | "date": 1622902229, 26 | "text": None, 27 | }, 28 | } 29 | text_from_user = [("문제 내줘"), ("퀴즈퀴즈"), ("1"), ("123")] 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_get_webhook(client): 34 | r = await client.get("/webhook") 35 | data = r.json() 36 | 37 | assert r.status_code == 200 38 | assert data.get("ok") 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_set_webhook(client): 43 | r = await client.post("/webhook", json={"url": "https://example.com"}) 44 | data = r.json() 45 | 46 | assert r.status_code == 200 47 | assert data.get("ok") 48 | 49 | 50 | @pytest.mark.parametrize("text", text_from_user) 51 | @pytest.mark.asyncio 52 | async def test_webhook(client, add_quiz, text): 53 | for _ in range(10): 54 | add_quiz() 55 | data["message"]["text"] = text 56 | 57 | r = await client.post( 58 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 59 | json=data, 60 | ) 61 | 62 | assert r.status_code == 200 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_webhook_with_right_answer(client, add_quiz): 67 | quiz = add_quiz() 68 | data["message"]["text"] = "문제내줘" 69 | 70 | r = await client.post( 71 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 72 | json=data, 73 | ) 74 | 75 | data["message"]["text"] = str(quiz.id) 76 | r2 = await client.post( 77 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 78 | json=data, 79 | ) 80 | 81 | assert r.status_code == 200 82 | assert r2.status_code == 200 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_webhook_with_wrong_answer(client, add_quiz): 87 | quiz = add_quiz() 88 | data["message"]["text"] = "퀴즈!" 89 | 90 | r = await client.post( 91 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 92 | json=data, 93 | ) 94 | 95 | data["message"]["text"] = str(quiz.id + 1) 96 | r2 = await client.post( 97 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 98 | json=data, 99 | ) 100 | 101 | assert r.status_code == 200 102 | assert r2.status_code == 200 103 | 104 | 105 | @pytest.mark.parametrize("text", text_from_user) 106 | @pytest.mark.asyncio 107 | async def test_webhook_with_empty_quiz(client, text): 108 | data["message"]["text"] = text 109 | r = await client.post( 110 | f"/webhook/{settings.TELEGRAM_BOT_TOKEN.get_secret_value()}", 111 | json=data, 112 | ) 113 | 114 | assert r.status_code == 200 115 | -------------------------------------------------------------------------------- /tutorial/README.md: -------------------------------------------------------------------------------- 1 | [강의노트 바로가기](https://www.notion.so/fastapi/dddb1dba1d154834bd7968a8daf89995?v=c35c3464fa3d43b3b65d5cfd75cd84a5) 2 | 3 | ## FastAPI 기초 4 | 5 | Chapter1-1 및 Chapter1-2 강의노트에 수록된 코드 조각입니다. 6 | 7 | ## Chapter1-1 8 | 각 파일은 `python .py` 와 같이 바로 실행 할 수 있습니다. 9 | 10 | ### 사전 설치 11 | 12 | ```bash 13 | $ pip install fastapi uvicorn 14 | ``` 15 | 16 | ## Chapter1-2 17 | 18 | 각 파일은 단독 실행이 불가능합니다.. vs code에서는 프로젝트의 소스 디렉토리 설정이 어려운 이유와 더불어, 19 | 20 | - database.py 21 | - models.py 22 | - schemas.py 23 | 24 | 를 Chapter1-2에서 공통으로 사용할 예정이기 때문도 있습니다. 이 디렉토리의 소스를 tutorial_app/main.py 에 복사/붙여넣기 하여 사용하세요. 25 | 26 | ### 사전 설치 27 | 28 | 먼저, tutorial_app/init-db.sh를 실행하세요. 도커로 MySQL을 실행합니다. 29 | 30 | ```shell 31 | $ cd tutorial_app 32 | $ ./init-db.sh 33 | ``` 34 | 35 | 다음은 필수 라이브러리를 설치해야 합니다. 36 | 37 | ```bash 38 | $ cd tutorial_app 39 | $ pip install requirements.txt 40 | ``` 41 | 42 | ### 실행 43 | 44 | tutorial/chapter_1-2/ 하위에 있는 파일을 tutorial_app/main.py에 복사/붙여넣기 하세요. 그리고 45 | 46 | ```bash 47 | $ uvicorn tutorial_app.main:app --host 0.0.0.0 --reload 48 | ``` 49 | 을 실행합니다. 50 | 51 | ### 강의노트와의 차이점 52 | 53 | 강의노트는 설명을 편하게/쉽게 보기 위해 uvicorn 임포트 구문이 없습니다. 추가로 강의노트는 보다 더 집중할 수 있도록 앤드포인트가 하나였지만, 본 코드들에는 소주제 별로 묶여서 하나의 파일로 각각 존재합니다. 때문에 앤드포인트가 다르거나 함수명이 다를수 있지만 놀라지 마세요. 내용은 전부 같습니다! 😎 54 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/01_intro.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from fastapi import FastAPI 4 | 5 | 6 | app = FastAPI() 7 | 8 | 9 | @app.get("/") 10 | def hello(): 11 | return "Hello, World!" 12 | 13 | 14 | if __name__ == "__main__": 15 | uvicorn.run(app) 16 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/02_path_params.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from fastapi import FastAPI 4 | 5 | app = FastAPI() 6 | 7 | 8 | @app.get("/users/me") 9 | def get_current_user(): 10 | return {"user_id": 123} 11 | 12 | 13 | @app.get("/users/{user_id}") 14 | def get_user(user_id: int): 15 | return {"user_id": user_id} 16 | 17 | 18 | if __name__ == "__main__": 19 | uvicorn.run(app) 20 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/03_query_params.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import uvicorn 4 | 5 | from fastapi import FastAPI 6 | 7 | app = FastAPI() 8 | 9 | 10 | class UserLevel(str, Enum): 11 | a = "a" 12 | b = "b" 13 | c = "c" 14 | 15 | 16 | @app.get("/users") 17 | def get_users(is_admin: bool, limit: int = 100): 18 | return {"is_admin": is_admin, "limit": limit} 19 | 20 | 21 | @app.get("/users/grade") 22 | def get_users_grade(grade: UserLevel = UserLevel.a): 23 | return {"grade": grade} 24 | 25 | 26 | if __name__ == "__main__": 27 | uvicorn.run(app) 28 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/04_request_body.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | import uvicorn 4 | 5 | from fastapi import FastAPI 6 | from pydantic import BaseModel, HttpUrl 7 | 8 | 9 | app = FastAPI() 10 | 11 | 12 | class Item(BaseModel): 13 | name: str 14 | price: float 15 | amount: int = 0 16 | 17 | 18 | class User(BaseModel): 19 | name: str 20 | password: str 21 | avatar_url: Optional[HttpUrl] = None 22 | inventory: List[Item] = [] 23 | 24 | 25 | @app.post("/users") 26 | def create_user(user: User): 27 | return user 28 | 29 | 30 | @app.get("/users/me") 31 | def get_user(): 32 | fake_user = User( 33 | name="FastCampus", 34 | password="1234", 35 | inventory=[ 36 | Item(name="전설 무기", price=1_000_000), 37 | Item(name="전설 방어구", price=900_000), 38 | ] 39 | ) 40 | return fake_user 41 | 42 | 43 | if __name__ == "__main__": 44 | uvicorn.run(app) 45 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/05_resopnse_model.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from fastapi import FastAPI, status 4 | from pydantic import BaseModel, HttpUrl 5 | 6 | 7 | app = FastAPI() 8 | 9 | 10 | class User(BaseModel): 11 | name: str 12 | avatar_url: HttpUrl = "https://icotar.com/avatar/fastcampus.png?s=200" 13 | 14 | 15 | class CreateUser(User): 16 | password: str 17 | 18 | 19 | @app.post("/users", response_model=User, status_code=status.HTTP_201_CREATED) 20 | def create_user(user: CreateUser): 21 | return user 22 | 23 | 24 | if __name__ == "__main__": 25 | uvicorn.run(app) 26 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/06_data_validation.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import uvicorn 4 | 5 | from fastapi import FastAPI, Query, Path 6 | from pydantic import BaseModel, Field 7 | 8 | app = FastAPI() 9 | 10 | 11 | inventory = ( 12 | { 13 | "id": 1, 14 | "user_id": 1, 15 | "name": "레전드포션", 16 | "price": 2500.0, 17 | "amount": 100, 18 | }, 19 | { 20 | "id": 2, 21 | "user_id": 1, 22 | "name": "포션", 23 | "price": 300.0, 24 | "amount": 50, 25 | }, 26 | ) 27 | 28 | 29 | class Item(BaseModel): 30 | name: str = Field(..., min_length=1, max_length=100, title="이름") 31 | price: float = Field(None, ge=0) 32 | amount: int = Field( 33 | default=1, 34 | gt=0, 35 | le=100, 36 | title="수량", 37 | description="아이템 갯수. 1~100 개 까지 소지 가능", 38 | ) 39 | 40 | 41 | @app.get("/users/{user_id}/inventory", response_model=List[Item]) 42 | def get_item( 43 | user_id: int = Path(..., gt=0, title="사용자 id", description="DB의 user.id"), 44 | name: str = Query(None, min_length=1, max_length=2, title="아이템 이름"), 45 | ): 46 | # 사용자의 아이템 검색 47 | user_items = [] 48 | for item in inventory: 49 | if item["user_id"] == user_id: 50 | user_items.append(item) 51 | 52 | # 아이템 이름 검색 53 | response = [] 54 | for item in user_items: 55 | if name is None: 56 | response = user_items 57 | break 58 | if item["name"] == name: 59 | response.append(item) 60 | 61 | return response 62 | 63 | 64 | @app.post("/users/{user_id}/item") 65 | def create_item(item: Item): 66 | return item 67 | 68 | 69 | if __name__ == "__main__": 70 | uvicorn.run(app) 71 | -------------------------------------------------------------------------------- /tutorial/chapter_1-1/07_header_and_cookie_params.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from fastapi import FastAPI, Header, Cookie 4 | 5 | app = FastAPI() 6 | 7 | 8 | @app.get("/cookie") 9 | def get_cookies(ga: str = Cookie(None, title="구글 애널리틱스", example="GA1.2.3")): 10 | return {"ga": ga} 11 | 12 | 13 | @app.get("/header") 14 | def get_headers(x_token: str = Header(None, title="토큰")): 15 | return {"X-Token": x_token} 16 | 17 | 18 | if __name__ == "__main__": 19 | uvicorn.run(app) 20 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/01_connect_db.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import Depends, FastAPI, HTTPException 4 | from sqlalchemy.orm import Session 5 | 6 | from . import models, schemas 7 | from .database import SessionLocal, engine 8 | 9 | models.Base.metadata.create_all(bind=engine) 10 | 11 | 12 | app = FastAPI() 13 | 14 | 15 | def get_db(): 16 | db = SessionLocal() 17 | try: 18 | yield db 19 | finally: 20 | db.close() 21 | 22 | 23 | @app.post("/users", response_model=schemas.User) 24 | def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): 25 | existed_user = db.query(models.User).filter_by( 26 | email=user.email 27 | ).first() 28 | 29 | if existed_user: 30 | raise HTTPException(status_code=400, detail="Email already registered") 31 | 32 | user = models.User(email=user.email, hashed_password=user.password) 33 | db.add(user) 34 | db.commit() 35 | 36 | return user 37 | 38 | 39 | @app.get("/users", response_model=List[schemas.User]) 40 | def read_users(db: Session = Depends(get_db)): 41 | return db.query(models.User).all() 42 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/02_form_data.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Form 2 | from fastapi.staticfiles import StaticFiles 3 | 4 | 5 | app = FastAPI() 6 | app.mount("/static", StaticFiles(directory="tutorial_app/static"), name="static") 7 | 8 | 9 | @app.post("/login") 10 | def login(username: str = Form(...), password: str = Form(...)): 11 | return {"username": username} 12 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/03_file_handling.py: -------------------------------------------------------------------------------- 1 | from tempfile import NamedTemporaryFile 2 | from typing import IO 3 | 4 | from fastapi import FastAPI, File, UploadFile 5 | 6 | app = FastAPI() 7 | 8 | 9 | async def save_file(file: IO): 10 | with NamedTemporaryFile("wb", delete=False) as tempfile: 11 | tempfile.write(file.read()) 12 | return tempfile.name 13 | 14 | 15 | @app.post("/file/size") 16 | async def get_filesize(file: bytes = File(...)): 17 | return {"file_size": len(file)} 18 | 19 | 20 | @app.post("/file/info") 21 | async def get_file_info(file: UploadFile = File(...)): 22 | return { 23 | "content_type": file.content_type, 24 | "filename": file.filename 25 | } 26 | 27 | 28 | @app.post("/file/store") 29 | async def store_file(file: UploadFile = File(...)): 30 | path = await save_file(file.file) 31 | return {"filepath": path} 32 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/04_error_handling.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Dict 2 | 3 | from fastapi import FastAPI, HTTPException, Request, status 4 | from fastapi.responses import JSONResponse 5 | 6 | 7 | class SomeError(Exception): 8 | def __init__(self, name: str, code: int): 9 | self.name = name 10 | self.code = code 11 | 12 | def __str__(self): 13 | return f"<{self.name}> is occured. code: <{self.code}>" 14 | 15 | 16 | class SomeFastAPIError(HTTPException): 17 | def __init__( 18 | self, 19 | status_code: int, 20 | detail: Any = None, 21 | headers: Optional[Dict[str, Any]] = None, 22 | ) -> None: 23 | super().__init__( 24 | status_code=status_code, detail=detail, headers=headers 25 | ) 26 | 27 | 28 | app = FastAPI() 29 | 30 | 31 | @app.exception_handler(SomeError) 32 | async def some_error_handler(request: Request, exc: SomeError): 33 | return JSONResponse( 34 | content={"message": f"error is {exc.name}"}, status_code=exc.code 35 | ) 36 | 37 | 38 | users = { 39 | 1: {"name": "Fast"}, 40 | 2: {"name": "Campus"}, 41 | 3: {"name": "API"}, 42 | } 43 | 44 | 45 | @app.get("/users/{user_id}") 46 | async def get_user(user_id: int): 47 | if user_id not in users.keys(): 48 | raise HTTPException( 49 | status_code=status.HTTP_404_NOT_FOUND, 50 | detail=f" is not exists.", 51 | ) 52 | return users[user_id] 53 | 54 | 55 | @app.get("/error") 56 | async def get_error(): 57 | raise SomeError("Hello", 500) 58 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/05_dependency_injection.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Dict 2 | 3 | from fastapi import FastAPI, Header, Depends, HTTPException 4 | from pydantic import BaseModel, Field 5 | 6 | app = FastAPI() 7 | 8 | 9 | items = ({"name": "Foo"}, {"name": "Bar"}, {"name": "Baz"}) 10 | 11 | 12 | async def verify_token(x_token: str = Header(...)) -> None: 13 | if len(x_token) < 10: 14 | raise HTTPException(401, detail="Not authorized") 15 | 16 | 17 | async def get_q(q: Optional[str] = None) -> Optional[str]: 18 | return q 19 | 20 | 21 | async def func_params( 22 | q: Optional[str] = None, offset: int = 0, limit: int = 100 23 | ) -> Dict[str, Any]: 24 | return {"q": q, "offset": offset, "limit": limit} 25 | 26 | 27 | async def func_params_with_sub( 28 | q: Optional[str] = Depends(get_q), offset: int = 0, limit: int = 100 29 | ) -> Dict[str, Any]: 30 | return {"q": q, "offset": offset, "limit": limit} 31 | 32 | 33 | class ClassParams: 34 | def __init__( 35 | self, q: Optional[str] = None, offset: int = 0, limit: int = 100 36 | ): 37 | self.q = q 38 | self.offset = offset 39 | self.limit = limit 40 | 41 | 42 | class PydanticParams(BaseModel): 43 | q: Optional[str] = Field(None, min_length=2) 44 | offset: int = Field(0, ge=0) 45 | limit: int = Field(100, gt=0) 46 | 47 | 48 | @app.get("/items", dependencies=[Depends(verify_token)]) 49 | async def get_items(): 50 | return items 51 | 52 | 53 | @app.get("/items/func") 54 | async def get_items_with_func(params: dict = Depends(func_params)): 55 | response = {} 56 | if params["q"]: 57 | response.update({"q": params["q"]}) 58 | 59 | result = items[params["offset"]: params["offset"] + params["limit"]] 60 | response.update({"items": result}) 61 | 62 | return response 63 | 64 | 65 | @app.get("/items/func/sub") 66 | async def get_items_with_func_sub( 67 | params: dict = Depends(func_params_with_sub) 68 | ): 69 | response = {} 70 | if params["q"]: 71 | response.update({"q": params["q"]}) 72 | 73 | result = items[params["offset"]: params["offset"] + params["limit"]] 74 | response.update({"items": result}) 75 | 76 | return response 77 | 78 | 79 | @app.get("/items/class") 80 | async def get_items_with_class(params: ClassParams = Depends(ClassParams)): 81 | response = {} 82 | if params.q: 83 | response.update({"q": params.q}) 84 | 85 | result = items[params.offset: params.offset + params.limit] 86 | response.update({"items": result}) 87 | 88 | return response 89 | 90 | 91 | @app.get("/items/pydantic") 92 | async def get_items_with_pydantic(params: PydanticParams = Depends()): 93 | response = {} 94 | if params.q: 95 | response.update({"q": params.q}) 96 | 97 | result = items[params.offset: params.offset + params.limit] 98 | response.update({"items": result}) 99 | 100 | return response 101 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/06_auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, FastAPI 2 | from fastapi.security import ( 3 | HTTPBasic, 4 | HTTPBasicCredentials, 5 | HTTPBearer, 6 | HTTPAuthorizationCredentials, 7 | ) 8 | 9 | 10 | app = FastAPI() 11 | security = HTTPBasic() 12 | security_bearer = HTTPBearer() 13 | 14 | 15 | @app.get("/basic") 16 | async def get_current_user_basic(credentials: HTTPBasicCredentials = Depends(security)): 17 | return {"username": credentials.username, "password": credentials.password} 18 | 19 | 20 | @app.get("/bearer") 21 | async def get_current_user_bearer( 22 | credentials: HTTPAuthorizationCredentials = Depends(security_bearer), 23 | ): 24 | return credentials 25 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/06_auth2.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Optional 3 | 4 | import bcrypt 5 | from fastapi import Depends, FastAPI, HTTPException 6 | from fastapi.security import ( 7 | HTTPBearer, 8 | HTTPAuthorizationCredentials, 9 | OAuth2PasswordRequestForm, 10 | ) 11 | from pydantic import BaseModel 12 | from jose import jwt 13 | from jose.exceptions import ExpiredSignatureError 14 | 15 | 16 | app = FastAPI() 17 | security = HTTPBearer() 18 | 19 | ALGORITHM = "HS256" 20 | # openssl rand -hex 32 21 | # python -c "import secrets;print(secrets.token_hex(32))" 22 | SECRET_KEY = "e9f17f1273a60019da967cd0648bdf6fd06f216ce03864ade0b51b29fa273d75" 23 | fake_user_db = { 24 | "fastcampus": { 25 | "id": 1, 26 | "username": "fastcampus", 27 | "email": "fastcampus@fastcampus.com", 28 | # bcrypt.hashpw("password".encode(), bcrypt.gensalt()) 29 | "password": "$2b$12$kEsp4W6Vrm57c24ez4H1R.rdzYrXipAuSUZR.hxbqtYpjPLWbYtwS", 30 | } 31 | } 32 | 33 | 34 | class User(BaseModel): 35 | id: int 36 | username: str 37 | email: str 38 | 39 | 40 | class UserPayload(User): 41 | exp: datetime 42 | 43 | 44 | async def create_access_token(data: dict, exp: Optional[timedelta] = None) -> str: 45 | expire = datetime.utcnow() + (exp or timedelta(minutes=30)) 46 | user_info = UserPayload(**data, exp=expire) 47 | 48 | return jwt.encode(user_info.dict(), SECRET_KEY, algorithm=ALGORITHM) 49 | 50 | 51 | async def get_user(cred: HTTPAuthorizationCredentials = Depends(security)) -> dict: 52 | token = cred.credentials 53 | try: 54 | decoded_data = jwt.decode(token, SECRET_KEY, ALGORITHM) 55 | except ExpiredSignatureError: 56 | raise HTTPException(401, "Expired") 57 | user_info = User(**decoded_data) 58 | 59 | return fake_user_db[user_info.username] 60 | 61 | 62 | @app.post("/login") 63 | async def issue_token(data: OAuth2PasswordRequestForm = Depends()): 64 | user = fake_user_db[data.username] 65 | 66 | if bcrypt.checkpw(data.password.encode(), user["password"].encode()): 67 | return await create_access_token(user, exp=timedelta(minutes=30)) 68 | raise HTTPException(401) 69 | 70 | 71 | @app.get("/users/me", response_model=User) 72 | async def get_current_user(user: dict = Depends(get_user)): 73 | return user 74 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/07_background_tasks.py: -------------------------------------------------------------------------------- 1 | """ 2 | source from: https://fastapi.tiangolo.com/tutorial/background-tasks 3 | 위 소스 코드에서 time 만 추가했습니다. 4 | """ 5 | import time 6 | 7 | from typing import Optional 8 | from fastapi import BackgroundTasks, Depends, FastAPI, status 9 | 10 | app = FastAPI() 11 | 12 | 13 | def write_log(message: str): 14 | time.sleep(2.0) 15 | 16 | with open("log.txt", mode="a") as log: 17 | log.write(message) 18 | 19 | 20 | def get_query(background_tasks: BackgroundTasks, q: Optional[str] = None): 21 | if q: 22 | message = f"found query: {q}\n" 23 | background_tasks.add_task(write_log, message) 24 | return q 25 | 26 | 27 | @app.post("/send-notification/{email}", status_code=status.HTTP_202_ACCEPTED) 28 | async def send_notification( 29 | email: str, background_tasks: BackgroundTasks, q: str = Depends(get_query) 30 | ): 31 | message = f"message to {email}\n" 32 | background_tasks.add_task(write_log, message) 33 | 34 | return {"message": "Message sent"} 35 | -------------------------------------------------------------------------------- /tutorial/chapter_1-2/08_middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fastapi import FastAPI, Request 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | app = FastAPI() 7 | 8 | 9 | app.add_middleware( 10 | CORSMiddleware, 11 | allow_origins=[ 12 | "http://localhost", 13 | "http://localhost:8000", 14 | "http://localhost:8080", 15 | ], 16 | allow_credentials=True, 17 | allow_methods=["HEAD", "OPTION", "POST", "GET", "PUT", "DELETE", "PATCH"], 18 | allow_headers=["*"], 19 | ) 20 | 21 | 22 | @app.middleware("http") 23 | async def add_process_time_header(request: Request, call_next): 24 | start_time = time.time() 25 | response = await call_next(request) 26 | process_time = time.time() - start_time 27 | response.headers["X-Process-Time"] = str(process_time) 28 | 29 | return response 30 | 31 | 32 | @app.get("/") 33 | async def hello(): 34 | return {"message": "Hello World!"} 35 | -------------------------------------------------------------------------------- /tutorial_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hard-coders/fastcampusapi/89f77c55ac1abc45c925bef84d01a48c24ef703f/tutorial_app/__init__.py -------------------------------------------------------------------------------- /tutorial_app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | 6 | engine = create_engine("mysql+pymysql://admin:1234@0.0.0.0:3306/dev") 7 | SessionLocal = sessionmaker( 8 | bind=engine, 9 | autocommit=False, 10 | autoflush=False, 11 | ) 12 | 13 | Base = declarative_base() 14 | -------------------------------------------------------------------------------- /tutorial_app/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run -d --name fastapi-db \ 4 | -p 3306:3306 \ 5 | -e MYSQL_ROOT_PASSWORD=1234 \ 6 | -e MYSQL_DATABASE=dev \ 7 | -e MYSQL_USER=admin \ 8 | -e MYSQL_PASSWORD=1234 \ 9 | mysql:8.0 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci -------------------------------------------------------------------------------- /tutorial_app/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, Integer, String 2 | 3 | from .database import Base 4 | 5 | 6 | class User(Base): 7 | __tablename__ = "user" 8 | 9 | id = Column(Integer, primary_key=True) 10 | email = Column(String(255), unique=True, index=True) 11 | password = Column(String(255)) 12 | is_active = Column(Boolean, default=True) 13 | -------------------------------------------------------------------------------- /tutorial_app/requirements.txt: -------------------------------------------------------------------------------- 1 | cffi==1.14.5 2 | click==7.1.2 3 | cryptography==3.4.7 4 | fastapi==0.63.0 5 | flake8==3.9.1 6 | greenlet==1.0.0 7 | h11==0.12.0 8 | mccabe==0.6.1 9 | pycodestyle==2.7.0 10 | pycparser==2.20 11 | pydantic==1.8.1 12 | pyflakes==2.3.1 13 | PyMySQL==1.0.2 14 | SQLAlchemy==1.4.11 15 | starlette==0.13.6 16 | typing-extensions==3.7.4.3 17 | uvicorn==0.13.4 18 | -------------------------------------------------------------------------------- /tutorial_app/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UserBase(BaseModel): 5 | email: str 6 | 7 | 8 | class UserCreate(UserBase): 9 | password: str 10 | 11 | 12 | class User(UserBase): 13 | id: int 14 | email: str 15 | is_active: bool 16 | 17 | class Config: 18 | orm_mode = True 19 | -------------------------------------------------------------------------------- /tutorial_app/static/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FastAPI Login 7 | 66 | 67 | 68 | 76 | 77 | --------------------------------------------------------------------------------