-
Notifications
You must be signed in to change notification settings - Fork 8
refactor: migrate web framework from Flask to FastAPI #333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9da99af
728e53d
0c13e7f
7572e57
6fe3d87
a9ef4ee
01d4a35
ae1fd85
6e48f1a
4f40ca9
6bdcb3e
bc6d1aa
f42f781
8f4ac2a
dabdf0e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,7 +29,9 @@ RUN poetry install --only main | |
| # 'development' stage installs all dev deps and can be used to develop code. | ||
| # For example using docker-compose to mount local volume under /app | ||
| FROM python-base AS development | ||
| ENV FLASK_DEBUG=True | ||
| ENV UVICORN_HOST=0.0.0.0 \ | ||
| UVICORN_PORT=8080 \ | ||
| UVICORN_RELOAD=true | ||
|
|
||
| # Copying poetry and venv into image | ||
| COPY --from=builder-base $POETRY_HOME $POETRY_HOME | ||
|
|
@@ -44,15 +46,18 @@ WORKDIR /app | |
| COPY . . | ||
|
|
||
| EXPOSE 8080 | ||
| CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.dev.py"] | ||
| CMD ["uvicorn", "app:app"] | ||
|
|
||
| # 'production' stage uses the clean 'python-base' stage and copyies | ||
| # in only our runtime deps that were installed in the 'builder-base' | ||
| FROM python-base AS production | ||
| ENV UVICORN_HOST=0.0.0.0 \ | ||
| UVICORN_PORT=8080 \ | ||
| UVICORN_WORKERS=4 | ||
|
|
||
| COPY --from=builder-base $VENV_PATH $VENV_PATH | ||
| COPY ./ /app | ||
|
|
||
| WORKDIR /app | ||
| EXPOSE 8080 | ||
| CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.py"] | ||
| CMD ["uvicorn", "app:app"] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,52 +1,83 @@ | ||||||||||||||||
| import os | ||||||||||||||||
| import logging | ||||||||||||||||
| from flask import Flask | ||||||||||||||||
| from contextlib import asynccontextmanager | ||||||||||||||||
| import httpx | ||||||||||||||||
| from fastapi import FastAPI, Request | ||||||||||||||||
| from fastapi.encoders import jsonable_encoder | ||||||||||||||||
| from fastapi.responses import JSONResponse | ||||||||||||||||
| from fastapi.exceptions import RequestValidationError | ||||||||||||||||
| from model import * | ||||||||||||||||
| from mongo import * | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def app(): | ||||||||||||||||
| # Create a flask app | ||||||||||||||||
| app = Flask(__name__) | ||||||||||||||||
| app.config['PREFERRED_URL_SCHEME'] = 'https' | ||||||||||||||||
| app.url_map.strict_slashes = False | ||||||||||||||||
| setup_smtp(app) | ||||||||||||||||
| # Register flask blueprint | ||||||||||||||||
| api2prefix = [ | ||||||||||||||||
| (auth_api, '/auth'), | ||||||||||||||||
| (profile_api, '/profile'), | ||||||||||||||||
| (problem_api, '/problem'), | ||||||||||||||||
| (submission_api, '/submission'), | ||||||||||||||||
| (course_api, '/course'), | ||||||||||||||||
| (homework_api, '/homework'), | ||||||||||||||||
| (test_api, '/test'), | ||||||||||||||||
| (ann_api, '/ann'), | ||||||||||||||||
| (ranking_api, '/ranking'), | ||||||||||||||||
| (post_api, '/post'), | ||||||||||||||||
| (copycat_api, '/copycat'), | ||||||||||||||||
| (health_api, '/health'), | ||||||||||||||||
| (user_api, '/user'), | ||||||||||||||||
| ] | ||||||||||||||||
| for api, prefix in api2prefix: | ||||||||||||||||
| app.register_blueprint(api, url_prefix=prefix) | ||||||||||||||||
| @asynccontextmanager | ||||||||||||||||
| async def lifespan(app: FastAPI): | ||||||||||||||||
| app.state.http_client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0)) | ||||||||||||||||
| yield | ||||||||||||||||
| app.state.http_client.close() | ||||||||||||||||
|
Comment on lines
+15
to
+17
|
||||||||||||||||
| app.state.http_client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0)) | |
| yield | |
| app.state.http_client.close() | |
| app.state.http_client = httpx.AsyncClient( | |
| timeout=httpx.Timeout(5.0, read=30.0)) | |
| yield | |
| await app.state.http_client.aclose() |
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,65 +1,71 @@ | ||
| from datetime import datetime | ||
| from flask import Blueprint | ||
| from typing import Optional | ||
| from fastapi import APIRouter, Depends | ||
|
|
||
| from mongo import * | ||
| from mongo.utils import * | ||
| from .auth import * | ||
| from .auth import login_required | ||
| from .utils import * | ||
| from .schemas import CreateAnnouncementBody, UpdateAnnouncementBody, DeleteAnnouncementBody | ||
| from .course import * | ||
| from .course import course_router | ||
|
|
||
| __all__ = ['ann_api'] | ||
| __all__ = ['ann_router'] | ||
|
|
||
| ann_api = Blueprint('ann_api', __name__) | ||
| ann_router = APIRouter() | ||
|
|
||
|
|
||
| @ann_api.route('/', methods=['GET']) | ||
| @ann_api.route('/<ann_id>', methods=['GET']) | ||
| def get_sys_ann(ann_id=None): | ||
| public_name = Course.get_public().course_name | ||
| anns = Announcement.ann_list(None, public_name) | ||
| data = [{ | ||
| def _format_ann(an): | ||
| return { | ||
| 'annId': str(an.id), | ||
| 'title': an.title, | ||
| 'createTime': int(an.create_time.timestamp()), | ||
| 'updateTime': int(an.update_time.timestamp()), | ||
| 'creator': an.creator.info, | ||
| 'updater': an.updater.info, | ||
| 'markdown': an.markdown, | ||
| 'pinned': an.pinned | ||
| } for an in anns if ann_id == None or str(an.id) == ann_id] | ||
| 'pinned': an.pinned, | ||
| } | ||
|
|
||
|
|
||
| @ann_router.get('') | ||
| @ann_router.get('/{ann_id}') | ||
| def get_sys_ann(ann_id: Optional[str] = None): | ||
| public_name = Course.get_public().course_name | ||
| anns = Announcement.ann_list(None, public_name) | ||
| data = [ | ||
| _format_ann(an) for an in anns | ||
| if ann_id is None or str(an.id) == ann_id | ||
| ] | ||
| return HTTPResponse('Sys Ann bro', data=data) | ||
|
|
||
|
|
||
| @course_api.get('/<course_name>/ann') | ||
| @ann_api.get('/<course_name>/<ann_id>') | ||
| @login_required | ||
| def get_announcements(user, course_name=None, ann_id=None): | ||
| # Get an announcement list | ||
| @course_router.get('/{course_name}/ann') | ||
| def get_course_announcements(course_name: str, user=Depends(login_required)): | ||
| try: | ||
| anns = Announcement.ann_list(user.obj, course_name or 'Public') | ||
| anns = Announcement.ann_list(user.obj, course_name) | ||
| except (DoesNotExist, ValidationError): | ||
| return HTTPError('Cannot Access a Announcement', 403) | ||
| if anns is None: | ||
| return HTTPError('Announcement Not Found', 404) | ||
| data = [{ | ||
| 'annId': str(an.id), | ||
| 'title': an.title, | ||
| 'createTime': int(an.create_time.timestamp()), | ||
| 'updateTime': int(an.update_time.timestamp()), | ||
| 'creator': an.creator.info, | ||
| 'updater': an.updater.info, | ||
| 'markdown': an.markdown, | ||
| 'pinned': an.pinned | ||
| } for an in anns if ann_id is None or str(an.id) == ann_id] | ||
| data = [_format_ann(an) for an in anns] | ||
| return HTTPResponse('Announcement List', data=data) | ||
|
|
||
|
|
||
| @ann_api.post('/') | ||
| @login_required | ||
| @parse_body(CreateAnnouncementBody) | ||
| def create_announcement(user, body: CreateAnnouncementBody): | ||
| # Create a new announcement | ||
| @ann_router.get('/{course_name}/{ann_id}') | ||
| def get_ann_by_id(course_name: str, ann_id: str, user=Depends(login_required)): | ||
| try: | ||
| anns = Announcement.ann_list(user.obj, course_name) | ||
| except (DoesNotExist, ValidationError): | ||
| return HTTPError('Cannot Access a Announcement', 403) | ||
| if anns is None: | ||
| return HTTPError('Announcement Not Found', 404) | ||
| data = [_format_ann(an) for an in anns if str(an.id) == ann_id] | ||
| return HTTPResponse('Announcement List', data=data) | ||
|
Comment on lines
+42
to
+63
|
||
|
|
||
|
|
||
| @ann_router.post('') | ||
| def create_announcement(body: CreateAnnouncementBody, | ||
| user=Depends(login_required)): | ||
| try: | ||
| ann = Announcement.new_ann( | ||
| title=body.title, | ||
|
|
@@ -76,20 +82,17 @@ def create_announcement(user, body: CreateAnnouncementBody): | |
| return HTTPError('Failed to Create Announcement', 403) | ||
| data = { | ||
| 'annId': str(ann.id), | ||
| 'createTime': int(ann.create_time.timestamp()) | ||
| 'createTime': int(ann.create_time.timestamp()), | ||
| } | ||
| return HTTPResponse('Announcement Created', data=data) | ||
|
|
||
|
|
||
| @ann_api.put('/') | ||
| @login_required | ||
| @parse_body(UpdateAnnouncementBody) | ||
| def update_announcement(user, body: UpdateAnnouncementBody): | ||
| # Update an announcement | ||
| @ann_router.put('') | ||
| def update_announcement(body: UpdateAnnouncementBody, | ||
| user=Depends(login_required)): | ||
| ann = Announcement(body.ann_id) | ||
| if not ann: | ||
| return HTTPError('Announcement Not Found', 404) | ||
|
|
||
| course = Course(ann.course) | ||
| if not course.permission(user, Course.Permission.GRADE): | ||
| return HTTPError('Failed to Update Announcement', 403) | ||
|
|
@@ -102,23 +105,18 @@ def update_announcement(user, body: UpdateAnnouncementBody): | |
| pinned=body.pinned, | ||
| ) | ||
| except ValidationError as ve: | ||
| return HTTPError( | ||
| 'Failed to Update Announcement', | ||
| 400, | ||
| data=ve.to_dict(), | ||
| ) | ||
| return HTTPError('Failed to Update Announcement', | ||
| 400, | ||
| data=ve.to_dict()) | ||
| return HTTPResponse('Updated') | ||
|
|
||
|
|
||
| @ann_api.delete('/') | ||
| @login_required | ||
| @parse_body(DeleteAnnouncementBody) | ||
| def delete_announcement(user, body: DeleteAnnouncementBody): | ||
| # Delete an announcement | ||
| @ann_router.delete('') | ||
| def delete_announcement(body: DeleteAnnouncementBody, | ||
| user=Depends(login_required)): | ||
| ann = Announcement(body.ann_id) | ||
| if not ann: | ||
| return HTTPError('Announcement Not Found', 404) | ||
|
|
||
| course = Course(ann.course) | ||
| if not course.permission(user, Course.Permission.GRADE): | ||
| return HTTPError('Failed to Delete Announcement', 403) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CMD ["uvicorn", "app:app"]ignores the UVICORN_HOST/UVICORN_PORT/UVICORN_WORKERS env vars; uvicorn will default to 127.0.0.1:8000, which typically makes the container unreachable and also runs a single worker in production. Pass--host/--port(and--workersin production,--reloadin development) explicitly in the CMD/ENTRYPOINT, or add a small entrypoint script that maps these env vars to uvicorn flags.