Skip to content

Commit cb974bb

Browse files
authored
Merge pull request #128 from docat-org/fastapi
Refactor to use FastAPI instead of Flask
2 parents 89d8faa + ae2dd2b commit cb974bb

File tree

12 files changed

+612
-406
lines changed

12 files changed

+612
-406
lines changed

.github/workflows/docat.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ jobs:
2929
run: |
3030
python -m poetry run flake8 docat tests
3131
32+
- name: run backend static code analysis
33+
working-directory: docat
34+
run: |
35+
python -m poetry run mypy .
36+
3237
- name: run backend tests
3338
working-directory: docat
3439
run: |

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ RUN cp docat/nginx/default /etc/nginx/http.d/default.conf
5252
COPY --from=backend /app /app/docat
5353

5454
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
55-
CMD ["sh", "-c", "nginx && .venv/bin/python -m gunicorn --access-logfile - --bind=0.0.0.0:5000 docat.app:app"]
55+
CMD ["sh", "-c", "nginx && .venv/bin/python -m uvicorn --host 0.0.0.0 --port 5000 docat.app:app"]

doc/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Use `docatl --help` to discover all other commands to manage your docat document
2121

2222
The following sections document the RAW API endpoints you can `curl`.
2323

24+
The API specification is exposed as OpenAPI at http://localhost:8000/api/v1/openapi.json
25+
and available with Swagger UI at http://localhost:8000/api/docs and a pure documentation
26+
is available with redoc at http://localhost:8000/api/redoc.
27+
2428
#### Upload your documentation
2529

2630
You can upload any static HTML site by zipping it and

docat/docat/__main__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22

3+
import uvicorn
4+
35
from docat.app import app
46

57
if __name__ == "__main__":
@@ -8,4 +10,4 @@
810
except ValueError:
911
port = 5000
1012

11-
app.run("0.0.0.0", port=port)
13+
uvicorn.run(app, host="0.0.0.0", port=port)

docat/docat/app.py

Lines changed: 88 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -9,126 +9,148 @@
99
"""
1010
import os
1111
import secrets
12-
from http import HTTPStatus
12+
import shutil
13+
from dataclasses import dataclass
1314
from pathlib import Path
15+
from typing import Optional
1416

15-
from flask import Flask, request, send_from_directory
17+
from fastapi import Depends, FastAPI, File, Header, Response, UploadFile, status
18+
from fastapi.staticfiles import StaticFiles
19+
from pydantic import BaseModel
20+
from starlette.responses import JSONResponse
1621
from tinydb import Query, TinyDB
17-
from werkzeug.utils import secure_filename
1822

1923
from docat.utils import UPLOAD_FOLDER, calculate_token, create_nginx_config, create_symlink, extract_archive, remove_docs
2024

21-
app = Flask(__name__)
22-
app.config["UPLOAD_FOLDER"] = Path(os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER))
23-
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100M
24-
app.db = TinyDB("db.json")
25+
#: Holds the FastAPI application
26+
app = FastAPI(
27+
title="docat",
28+
description="API for docat, https://github.com/docat-org/docat",
29+
openapi_url="/api/v1/openapi.json",
30+
docs_url="/api/docs",
31+
redoc_url="/api/redoc",
32+
)
33+
#: Holds an instance to the TinyDB
34+
db = TinyDB("db.json")
35+
#: Holds the static base path where the uploaded documentation artifacts are stored
36+
DOCAT_UPLOAD_FOLDER = Path(os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER))
2537

2638

27-
@app.route("/api/<project>/<version>", methods=["POST"])
28-
def upload(project, version):
29-
if "file" not in request.files:
30-
return {"message": "No file part in the request"}, HTTPStatus.BAD_REQUEST
39+
def get_db():
40+
"""Return the cached TinyDB instance."""
41+
return db
3142

32-
uploaded_file = request.files["file"]
33-
if uploaded_file.filename == "":
34-
return {"message": "No file selected for uploading"}, HTTPStatus.BAD_REQUEST
3543

36-
project_base_path = app.config["UPLOAD_FOLDER"] / project
44+
@dataclass
45+
class TokenStatus:
46+
valid: bool
47+
reason: Optional[str] = None
48+
49+
50+
class ApiResponse(BaseModel):
51+
message: str
52+
53+
54+
class ClaimResponse(ApiResponse):
55+
token: str
56+
57+
58+
@app.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
59+
def upload(
60+
project: str,
61+
version: str,
62+
response: Response,
63+
file: UploadFile = File(...),
64+
docat_api_key: Optional[str] = Header(None),
65+
db: TinyDB = Depends(get_db),
66+
):
67+
project_base_path = DOCAT_UPLOAD_FOLDER / project
3768
base_path = project_base_path / version
38-
target_file = base_path / secure_filename(uploaded_file.filename)
69+
target_file = base_path / file.filename
3970

4071
if base_path.exists():
41-
token = request.headers.get("Docat-Api-Key")
42-
result = check_token_for_project(token, project)
43-
if result is True:
72+
token_status = check_token_for_project(db, docat_api_key, project)
73+
if token_status.valid:
4474
remove_docs(project, version)
4575
else:
46-
return result
76+
response.status_code = status.HTTP_401_UNAUTHORIZED
77+
return ApiResponse(message=token_status.reason)
4778

4879
# ensure directory for the uploaded doc exists
4980
base_path.mkdir(parents=True, exist_ok=True)
5081

5182
# save the uploaded documentation
52-
uploaded_file.save(str(target_file))
53-
extract_archive(target_file, base_path)
83+
file.file.seek(0)
84+
with target_file.open("wb") as buffer:
85+
shutil.copyfileobj(file.file, buffer)
5486

87+
extract_archive(target_file, base_path)
5588
create_nginx_config(project, project_base_path)
56-
57-
return {"message": "File successfully uploaded"}, HTTPStatus.CREATED
89+
return ApiResponse(message="File successfully uploaded")
5890

5991

60-
@app.route("/api/<project>/<version>/tags/<new_tag>", methods=["PUT"])
61-
def tag(project, version, new_tag):
62-
source = version
63-
destination = app.config["UPLOAD_FOLDER"] / project / new_tag
92+
@app.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
93+
def tag(project: str, version: str, new_tag: str, response: Response):
94+
destination = DOCAT_UPLOAD_FOLDER / project / new_tag
6495

65-
if create_symlink(source, destination):
66-
return (
67-
{"message": f"Tag {new_tag} -> {version} successfully created"},
68-
HTTPStatus.CREATED,
69-
)
96+
if create_symlink(version, destination):
97+
return ApiResponse(message=f"Tag {new_tag} -> {version} successfully created")
7098
else:
71-
return (
72-
{"message": f"Tag {new_tag} would overwrite an existing version!"},
73-
HTTPStatus.CONFLICT,
74-
)
99+
response.status_code = status.HTTP_409_CONFLICT
100+
return ApiResponse(message=f"Tag {new_tag} would overwrite an existing version!")
75101

76102

77-
@app.route("/api/<project>/claim", methods=["GET"])
78-
def claim(project):
103+
@app.get(
104+
"/api/{project}/claim",
105+
response_model=ClaimResponse,
106+
status_code=status.HTTP_201_CREATED,
107+
responses={status.HTTP_409_CONFLICT: {"model": ApiResponse}},
108+
)
109+
def claim(project: str, db: TinyDB = Depends(get_db)):
79110
Project = Query()
80-
table = app.db.table("claims")
111+
table = db.table("claims")
81112
result = table.search(Project.name == project)
82113
if result:
83-
return (
84-
{"message": f"Project {project} is already claimed!"},
85-
HTTPStatus.CONFLICT,
86-
)
114+
return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": f"Project {project} is already claimed!"})
87115

88116
token = secrets.token_hex(16)
89117
salt = os.urandom(32)
90118
token_hash = calculate_token(token, salt)
91119
table.insert({"name": project, "token": token_hash, "salt": salt.hex()})
92120

93-
return {"message": f"Project {project} successfully claimed", "token": token}, HTTPStatus.CREATED
121+
return ClaimResponse(message=f"Project {project} successfully claimed", token=token)
94122

95123

96-
@app.route("/api/<project>/<version>", methods=["DELETE"])
97-
def delete(project, version):
98-
token = request.headers.get("Docat-Api-Key")
99-
100-
result = check_token_for_project(token, project)
101-
if result is True:
124+
@app.delete("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
125+
def delete(project: str, version: str, response: Response, docat_api_key: str = Header(None), db: TinyDB = Depends(get_db)):
126+
token_status = check_token_for_project(db, docat_api_key, project)
127+
if token_status.valid:
102128
message = remove_docs(project, version)
103129
if message:
104-
return ({"message": message}, HTTPStatus.NOT_FOUND)
130+
response.status_code = status.HTTP_404_NOT_FOUND
131+
return ApiResponse(message=message)
105132
else:
106-
return (
107-
{"message": f"Successfully deleted version '{version}'"},
108-
HTTPStatus.OK,
109-
)
133+
return ApiResponse(message=f"Successfully deleted version '{version}'")
110134
else:
111-
return result
135+
response.status_code = status.HTTP_401_UNAUTHORIZED
136+
return ApiResponse(message=token_status.reason)
112137

113138

114-
def check_token_for_project(token, project):
139+
def check_token_for_project(db, token, project) -> TokenStatus:
115140
Project = Query()
116-
table = app.db.table("claims")
141+
table = db.table("claims")
117142
result = table.search(Project.name == project)
118143

119144
if result and token:
120145
token_hash = calculate_token(token, bytes.fromhex(result[0]["salt"]))
121146
if result[0]["token"] == token_hash:
122-
return True
147+
return TokenStatus(True)
123148
else:
124-
return ({"message": f"Docat-Api-Key token is not valid for {project}"}, HTTPStatus.UNAUTHORIZED)
149+
return TokenStatus(False, f"Docat-Api-Key token is not valid for {project}")
125150
else:
126-
return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED)
151+
return TokenStatus(False, f"Please provide a header with a valid Docat-Api-Key token for {project}")
127152

128153

129154
# serve_local_docs for local testing without a nginx
130155
if os.environ.get("DOCAT_SERVE_FILES"):
131-
132-
@app.route("/doc/<path:path>")
133-
def serve_local_docs(path):
134-
return send_from_directory(app.config["UPLOAD_FOLDER"], path)
156+
app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER), name="docs")

0 commit comments

Comments
 (0)