├── .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 | [](https://github.com/hard-coders/fastcampusapi/actions/workflows/test.yml)
2 | [](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 |
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 |
69 |
Login
70 |
75 |
76 |
77 |
--------------------------------------------------------------------------------