Skip to content

Commit

Permalink
chore: add Swagger docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Young-Lord committed Jul 9, 2024
1 parent 1fd27b1 commit 27f3a68
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 74 deletions.
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
3 changes: 2 additions & 1 deletion server/app/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion server/app/models/datastore.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -108,7 +109,7 @@ def __repr__(self):
return f"<File {self.filename}> in {self.note.name} ({self.note.id})>"


class MailAcceptStatus:
class MailAcceptStatus(IntEnum):
ACCEPT = 1
DENY = 2
PENDING = 3
Expand Down
7 changes: 6 additions & 1 deletion server/app/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
180 changes: 125 additions & 55 deletions server/app/resources/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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"),
Expand All @@ -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,
Expand All @@ -262,28 +262,50 @@ 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,
},
)


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):
Expand All @@ -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():
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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/<string:address>/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/<string:address>/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/<string:address>/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:
Expand Down
Loading

0 comments on commit 27f3a68

Please sign in to comment.