From 27f3a688b9453231d85b1441a528f1e83fc98d68 Mon Sep 17 00:00:00 2001 From: LY <51789698+Young-Lord@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:26:35 +0800 Subject: [PATCH] chore: add Swagger docs --- .env.development | 3 + frontend/package.json | 4 +- server/app/factory.py | 3 +- server/app/models/datastore.py | 3 +- server/app/resources/base.py | 7 +- server/app/resources/note.py | 180 +++++++++++++++++++++++---------- server/wsgi.py | 50 ++++++--- 7 files changed, 176 insertions(+), 74 deletions(-) diff --git a/.env.development b/.env.development index 2b16cdd..0ac3a5d 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,7 @@ APP_SECRET="" +FLASK_SERVER_NAME="localhost:5000" +FLASK_APPLICATION_ROOT="/" +FLASK_PREFERRED_URL_SCHEME="http" VITE_BASE_DOMAIN="http://localhost:53000" VITE_BASE_PATH="" # example: "/service/clip" VITE_API_BASE_DOMAIN="http://localhost:5000" diff --git a/frontend/package.json b/frontend/package.json index a27e790..6b0be45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,9 +3,9 @@ "version": "0.0.35", "private": true, "scripts": { - "gen-metadata": "cd .. && cd server && poetry run python wsgi.py --export-metadata ../frontend/src/metadata.json", + "gen-metadata": "cd .. && cd server && poetry run python wsgi.py --export-metadata ../frontend/src/metadata.json && cd .. && cd frontend", "dev": "yarn run gen-metadata && vite", - "build": "yarn run gen-metadata && cd .. && cd frontend && vite build --emptyOutDir", + "build": "yarn run gen-metadata && vite build --emptyOutDir", "preview": "vite preview", "lint": "eslint . --fix" }, diff --git a/server/app/factory.py b/server/app/factory.py index 56e6ab5..3ae082b 100644 --- a/server/app/factory.py +++ b/server/app/factory.py @@ -23,6 +23,7 @@ def environment(self) -> str: def set_flask(self, **kwargs) -> Flask: self.flask = Flask(__name__, **kwargs, static_folder=None, template_folder=None) self.flask.config.from_object(config) + self.flask.config.from_prefixed_env() return self.flask @@ -78,10 +79,10 @@ def validate_csrf_source(): return make_response( f"CSRF Error! {CSRF_HEADER_NAME} header must be set." ) + if self.flask.config["DEBUG"] is not True: self.flask.before_request(validate_csrf_source) - def set_jwt(self) -> None: from .resources.base import file_jwt diff --git a/server/app/models/datastore.py b/server/app/models/datastore.py index c89e0cd..c4506c0 100644 --- a/server/app/models/datastore.py +++ b/server/app/models/datastore.py @@ -1,5 +1,6 @@ from abc import ABC import datetime +from enum import IntEnum import os import time from typing import Any, Callable, NamedTuple, Optional @@ -108,7 +109,7 @@ def __repr__(self): return f" in {self.note.name} ({self.note.id})>" -class MailAcceptStatus: +class MailAcceptStatus(IntEnum): ACCEPT = 1 DENY = 2 PENDING = 3 diff --git a/server/app/resources/base.py b/server/app/resources/base.py index bf25db7..712e2d1 100644 --- a/server/app/resources/base.py +++ b/server/app/resources/base.py @@ -8,14 +8,19 @@ from app.note_const import Metadata + SWAGGER_OPTIONS: dict[str, Any] = dict(doc=False, add_specs=False) if current_app.config["DEBUG"] is True: SWAGGER_OPTIONS.pop("doc", None) SWAGGER_OPTIONS.pop("add_specs", None) +jwt_authorizations = { + "headers": {"type": "apiKey", "in": "header", "name": "Authorization"}, + "query_string": {"type": "apiKey", "in": "query", "name": "jwt"}, +} # this is the blueprint for normal API, usually at `/api` endpoint api_bp = Blueprint("api", "api") -api_restx = Api(**SWAGGER_OPTIONS) +api_restx = Api(**SWAGGER_OPTIONS, authorizations=jwt_authorizations) api_restx.init_app(api_bp) diff --git a/server/app/resources/note.py b/server/app/resources/note.py index 242ee27..eb1f2eb 100644 --- a/server/app/resources/note.py +++ b/server/app/resources/note.py @@ -8,9 +8,9 @@ import traceback from urllib.parse import quote from flask_mailman import EmailMessage -from flask_restx import Resource, marshal +from flask_restx import Model, Resource, marshal import functools -from typing import Any, ClassVar, Literal, Optional +from typing import Any, ClassVar, Final, Literal, Optional from flask import ( Flask, Response, @@ -203,7 +203,7 @@ def decorated_function(*args, **kwargs): def note_to_jwt_id(note: Note) -> str: - return "note_"+sha256( + return "note_" + sha256( combine_name_and_password_and_readonly( note.name, note.password, note.readonly_name_if_has ) @@ -229,7 +229,7 @@ def create_file_link(file: File, suffix: Literal["download", "preview"]) -> str: ) -file_model = api.model( +file_model: Model = api.model( "File", { "filename": fields.String(attribute="filename"), @@ -251,7 +251,7 @@ def create_file_link(file: File, suffix: Literal["download", "preview"]) -> str: }, ) -note_model = api.model( +note_model: Model = api.model( "Note", { "name": fields.String, @@ -262,7 +262,7 @@ def create_file_link(file: File, suffix: Literal["download", "preview"]) -> str: ), "timeout_seconds": fields.Integer, "is_readonly": fields.Boolean(default=False), - "files": fields.List(fields.Nested(file_model)), + "files": fields.Nested(file_model, as_list=True), "file_count": fields.Integer(attribute=lambda note: len(note.files)), "all_file_size": fields.Integer, "user_property": fields.String, @@ -270,20 +270,42 @@ def create_file_link(file: File, suffix: Literal["download", "preview"]) -> str: ) -def marshal_note(note: Note, status_code: int = 200, message: Optional[str] = None): +def resp_model(data_model: Optional[Model] = None) -> Model: + if data_model is None: + name = "ResponseForEmpty" + else: + name = "ResponseFor" + data_model.name + return api.model( + name, + { + "status": fields.Integer, + "message": fields.String, + "data": ( + fields.Raw(default=None) + if data_model is None + else fields.Nested(data_model) + ), + "error_id": fields.String, + }, + ) + + +def marshal_note( + note: Note, status_code: int = 200, message: Optional[str] = None +) -> Response: return return_json( marshal(note, note_model), status_code=status_code, message=message ) -ALLOW_PROPS: list[str] = [ +ALLOW_PROPS: Final[list[str]] = [ "content", "readonly_name", "files", "all_file_size", "user_property", ] -PROP_DEFAULT_VALUES: dict[str, Any] = {} +PROP_DEFAULT_VALUES: Final[dict[str, Any]] = {} def mashal_readonly_note(note: Note, status_code: int = 200): @@ -308,7 +330,7 @@ class BaseRest(Resource): file_limiter = limiter.limit(Metadata.limiter_file) -def on_user_access_note_with_new_thread(name: str): +def on_user_access_note_with_new_thread(name: str) -> None: app: Flask = current_app._get_current_object() # type: ignore def on_user_access_note_thread_function(): @@ -325,6 +347,7 @@ def on_user_access_note_thread_function(): class NoteRest(BaseRest): decorators = [note_limiter] + base_decorators + @api.response(200, "Success", resp_model(note_model)) def get(self, name: str): if g.note is None: if g.is_readonly: @@ -340,6 +363,7 @@ def get(self, name: str): on_user_access_note_with_new_thread(name) return marshal_note(g.note) + @api.response(200, "Success", resp_model(note_model)) def post(self, name: str): # create a new note if g.note is not None: @@ -349,6 +373,7 @@ def post(self, name: str): note = datastore.update_note(name=name) return marshal_note(note) + @api.response(200, "Success", resp_model(note_model)) def put(self, name: str): note = g.note if note is None: @@ -406,6 +431,7 @@ def put(self, name: str): return marshal_note(note, status_code=status_code, message=str(e)) return marshal_note(note) + @api.response(204, "Success", resp_model()) def delete(self, name: str): if g.note is None or g.is_readonly: return return_json(status_code=404, message="No note found") @@ -433,6 +459,7 @@ def get(self, name: str): class FileRest(BaseRest): decorators = [file_limiter] + base_decorators + @api.response(200, "Success", resp_model(file_model)) def get(self, name: str, id: int): if g.note is None or g.is_readonly: return return_json(status_code=404, message="No note found") @@ -444,6 +471,7 @@ def get(self, name: str, id: int): status_code=200, ) + @api.response(200, "Success", resp_model(file_model)) def post(self, name: str, id: int): if g.note is None or g.is_readonly: return return_json(status_code=404, message="No note found") @@ -460,13 +488,13 @@ def post(self, name: str, id: int): if file.filename is None: return return_json(status_code=400, message="Filename is empty") user_property: str = request.form.get("user_property", "{}") - if ( - len(user_property) > Metadata.max_user_property_length - ): + if len(user_property) > Metadata.max_user_property_length: return return_json(status_code=400, message="Content too long") # add file to database - file_instance = datastore.add_file(note, file.filename, file.save, user_property) + file_instance = datastore.add_file( + note, file.filename, file.save, user_property + ) file_size = file_instance.file_size # check limits @@ -499,6 +527,7 @@ def post(self, name: str, id: int): ) return marshal_note(g.note) + @api.response(200, "Success", resp_model(note_model)) def delete(self, name: str, id: int): if g.note is None or g.is_readonly: return return_json(status_code=404, message="No note found") @@ -556,55 +585,96 @@ def get(self, id: int): class PreviewFileContentRest(DownloadFileContentRest): as_attachment = False +mail_setting_input_model = api.model( + "MailSettingsInput", + { + "subscribe": fields.String( + description="Subscribe setting", + enum=["true", "false", "reset", "delete"], + ) + }, +) -@api_bp.route("/mail//settings", methods=["GET"]) -@limiter.limit("20/minute") -@jwt_required(locations=["headers"]) -def api_get_mail_setting(address: str): - # validate JWT token - if get_jwt_identity() != mail_address_to_jwt_id(address): - return return_json(status_code=403, message="Permission denied") - - setting = datastore.get_mail_subscribe_setting(address) - return return_json(status_code=200, message="OK", data={"subscribe": setting}) - +mail_setting_output_model = api.model( + "MailSettingsOutput", + { + "subscribe": fields.Integer( + description="Subscribe setting", + # enum=[e.value for e in MailAcceptStatus], # useless + ), + }, +) -@api_bp.route("/mail//settings", methods=["POST"]) -@limiter.limit("20/minute") -@jwt_required(locations=["headers"]) -def api_mail_setting(address: str): - # validate JWT token - if get_jwt_identity() != mail_address_to_jwt_id(address): - return return_json(status_code=403, message="Permission denied") +@api.response(200, "Success", mail_setting_output_model) +@api.doc( + security="headers", + params={"address": "Mail address to operate"}, + responses={ + 200: "Success", + 401: "Wrong JWT provided", + 403: "Wrong JWT provided", + }, +) +@api.route("/mail//settings") +class MailSettingsRest(Resource): + """ + Query or change mail subscribe setting + """ - # get mail subscribe setting from querystring - data = request.get_json() - if "subscribe" not in data: - return return_json(status_code=400, message="No subscribe setting provided") - arg_subscribe_str = data["subscribe"].lower() - - new_status: int - match arg_subscribe_str: - case "true": - new_status = MailAcceptStatus.ACCEPT - case "false": - new_status = MailAcceptStatus.DENY - case "reset": - new_status = MailAcceptStatus.NO_REQUESTED - case "delete": - new_status = MailAcceptStatus.NO_REQUESTED - datastore.delete_mail_address(address) - case _: # should not happen - return return_json(status_code=400, message="Invalid subscribe setting") - - if arg_subscribe_str != "delete": - datastore.set_mail_subscribe_setting(address, new_status) - return return_json(status_code=200, message="OK", data={"subscribe": new_status}) + @limiter.limit("20/minute") + @jwt_required(locations=["headers"]) + def get(self, address: str): + # validate JWT token + if get_jwt_identity() != mail_address_to_jwt_id(address): + return return_json(status_code=403, message="Permission denied") + + setting = datastore.get_mail_subscribe_setting(address) + return return_json(status_code=200, message="OK", data={"subscribe": setting}) + + @api.expect(mail_setting_input_model) + @api.doc( + responses={ + 400: "Invalid subscribe setting", + } + ) + @limiter.limit("20/minute") + @jwt_required(locations=["headers"]) + def post(self, address: str): + # validate JWT token + if get_jwt_identity() != mail_address_to_jwt_id(address): + return return_json(status_code=403, message="Permission denied") + + # get mail subscribe setting from querystring + data = request.get_json() + if "subscribe" not in data: + return return_json(status_code=400, message="No subscribe setting provided") + arg_subscribe_str = data["subscribe"].lower() + + new_status: int + match arg_subscribe_str: + case "true": + new_status = MailAcceptStatus.ACCEPT + case "false": + new_status = MailAcceptStatus.DENY + case "reset": + new_status = MailAcceptStatus.NO_REQUESTED + case "delete": + new_status = MailAcceptStatus.NO_REQUESTED + datastore.delete_mail_address(address) + case _: # should not happen + return return_json(status_code=400, message="Invalid subscribe setting") + + if arg_subscribe_str != "delete": + datastore.set_mail_subscribe_setting(address, new_status) + return return_json( + status_code=200, message="OK", data={"subscribe": new_status} + ) def mail_address_to_jwt_id(address: str) -> str: return "mail_" + sha256(address) + def create_subscribe_post_link( address: str, subscribe: bool, frontend: bool = True, email_header: bool = False ) -> str: diff --git a/server/wsgi.py b/server/wsgi.py index cd8217a..4bf0dc7 100644 --- a/server/wsgi.py +++ b/server/wsgi.py @@ -1,26 +1,48 @@ -from io import TextIOWrapper from os import environ from os.path import basename -import argparse from sys import argv -parser = argparse.ArgumentParser() -parser.add_argument( - "--export-metadata", - help="Export metadata.json to file", - type=argparse.FileType("w", encoding="UTF-8"), - metavar="FILENAME", -) - def parse_custom_args() -> None: + from io import TextIOWrapper + from argparse import ArgumentParser, FileType + parser = ArgumentParser() + parser.add_argument( + "--export-metadata", + help="Export metadata.json to file", + type=FileType("w", encoding="UTF-8"), + metavar="FILENAME", + ) + parser.add_argument( + "--export-openapi", + help="Export openapi.json to file", + type=FileType("w", encoding="UTF-8"), + metavar="FILENAME", + ) args = parser.parse_args() - export_file: TextIOWrapper = args.export_metadata - if export_file: - from app.note_const import Metadata_dict + export_metadata: TextIOWrapper = args.export_metadata + export_openapi: TextIOWrapper = args.export_openapi + no_run_server: bool = False + + if any((export_metadata, export_openapi)): + no_run_server = True from json import dump - dump(Metadata_dict, export_file) + if export_metadata: + from app.note_const import Metadata_dict + + dump(Metadata_dict, export_metadata) + + if export_openapi: + from app.main import create_app + + app = create_app() + from app.resources.base import api_restx + + with app.app_context(): + dump(api_restx.__schema__, export_openapi, indent=4) + + if no_run_server: exit(0)