From 4f9016d5b5ebd80106bbc5dc83322411a0ae544e Mon Sep 17 00:00:00 2001 From: Vasily Negrebetskiy Date: Wed, 3 Apr 2024 00:10:44 +0400 Subject: [PATCH] Split issue endpoint --- muckraker/main.py | 90 +++++++++++++++++++++++++++++------------------ static/index.html | 2 +- static/script.js | 50 +++++++++++++++++--------- tests/test_api.py | 71 ++++++++++++++++++++++++------------- 4 files changed, 137 insertions(+), 76 deletions(-) diff --git a/muckraker/main.py b/muckraker/main.py index ae580a3..63e812e 100644 --- a/muckraker/main.py +++ b/muckraker/main.py @@ -1,12 +1,14 @@ +import json +from io import BytesIO +from pathlib import Path +from shutil import rmtree +from tempfile import gettempdir, mkdtemp + import aiofiles -import asyncio -from fastapi import FastAPI, Response, UploadFile, Form +from fastapi import Depends, FastAPI, File, Response, UploadFile from fastapi.exceptions import HTTPException from fastapi.middleware.cors import CORSMiddleware -from io import BytesIO -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import List, IO +from fastapi.responses import JSONResponse from .models import Issue from .render import render_issue @@ -20,17 +22,39 @@ app.add_middleware( CORSMiddleware, allow_origins=origins, - allow_methods=["POST"] + allow_methods=["POST", "PATCH", "GET"] ) -async def process_image(image: IO, dir_path: Path): +@app.post("/issue/") +async def create_s_issue(issue: Issue): + dir_path = mkdtemp(prefix="muckraker") + issue_path = Path(dir_path) / "issue.json" + with open(issue_path, "w") as fd: + fd.write(issue.model_dump_json()) + return {"issue_id": dir_path.split("muckraker")[-1]} + + +async def dir_path(issue_id: str): + dir_path = Path(gettempdir()) / f"muckraker{issue_id}" + if not (dir_path.exists() and dir_path.is_dir()): + raise HTTPException(status_code=404, detail="No data") + return dir_path + + +@app.patch("/issue/{issue_id}") +async def patch_s_issue( + dir_path: Path = Depends(dir_path), + image: UploadFile = File() +): # Validate image if image.content_type not in ACCEPTED_FILE_TYPES: detail = f"Invalid file type: {image.filename}" + rmtree(dir_path) raise HTTPException(415, detail=detail) if image.size > MAX_IMAGE_SIZE: detail = f"File is too large: {image.filename}" + rmtree(dir_path) raise HTTPException(413, detail=detail) # Save image to the disk @@ -38,35 +62,31 @@ async def process_image(image: IO, dir_path: Path): async with aiofiles.open(image_path, "wb") as fd: while content := await image.read(IMAGE_BATCH): await fd.write(content) + return JSONResponse(content="Image uploaded") -@app.post("/issue/") -async def create_issue( - issue: Issue = Form(), - images: List[UploadFile] = [] -): - with TemporaryDirectory() as temp_dir: - temp_dir_path = Path(temp_dir) - - # Asynchronously process files - tasks = [process_image(image, temp_dir_path) for image in images] - await asyncio.gather(*tasks) - - # Render PDF and save it in the temp_dir - pdf_path = temp_dir_path / "out.pdf" - render_issue( - config=issue.config.model_dump(), - heading=issue.heading.model_dump(), - body=issue.body, - output=pdf_path, - image_dir=temp_dir_path - ) - - # Save PDF to the buffer - with open(pdf_path, "rb") as fd: - buf = BytesIO(fd.read()) - - # Get it from the buffer +@app.get("/issue/{issue_id}") +async def get_s_issue(dir_path: Path = Depends(dir_path)): + # Read issue data + with open(dir_path / "issue.json", "r") as fd: + issue_dict = json.load(fd) + + # Render PDF and write it to buffer + pdf_path = dir_path / "out.pdf" + render_issue( + config=issue_dict["config"], + heading=issue_dict["heading"], + body=issue_dict["body"], + output=pdf_path, + image_dir=dir_path + ) + with open(pdf_path, "rb") as fd: + buf = BytesIO(fd.read()) + + # Delete tempdir + rmtree(dir_path) + + # Get pdf from buffer pdf_bytes = buf.getvalue() buf.close() diff --git a/static/index.html b/static/index.html index 7fe2ac4..d20b1f0 100644 --- a/static/index.html +++ b/static/index.html @@ -78,7 +78,7 @@

Issue creation

- + diff --git a/static/script.js b/static/script.js index d1c3865..582156d 100644 --- a/static/script.js +++ b/static/script.js @@ -32,11 +32,8 @@ function updateImageList() { } } -async function requestPDF() { - document.getElementById("print-button").disabled = true; - var formData = new FormData(); - - const issue = { +function prepareIssue() { + return JSON.stringify({ config: { size: document.getElementById("size-select").value, bg: document.getElementById("bg-select").value @@ -49,25 +46,46 @@ async function requestPDF() { cost: document.getElementById("issue-cost-input").value }, body: document.getElementById("issue-body-textarea").value - } - formData.append("issue", JSON.stringify(issue)); + }); +} - const file = document.getElementById("image-input").files[0]; - if (file) formData.append("images", file); +async function generatePDF() { + document.getElementById("print-button").disabled = true; + + var resp; + var respJson; + const resourceUrl = "/api/issue/"; try { - const resp = await fetch("/api/issue/", { + resp = await fetch(resourceUrl, { method: "POST", - body: formData + headers: {"Content-Type": "application/json"}, + body: prepareIssue() }); + respJson = await resp.json(); + if (resp.ok) var issueId = respJson.issue_id + else throw new Error("Failed to send issue data"); - if (resp.ok) { - /* If everything is ok, open recieved PDF */ - resp.blob().then(blob => window.open(URL.createObjectURL(blob))); + const file = document.getElementById("image-input").files[0]; + if (file) { + var formData = new FormData(); + formData.append("image", file, file.name); + resp = await fetch(resourceUrl + issueId, { + method: "PATCH", + body: formData + }); + respJson = await resp.json(); + if (!resp.ok) throw new Error("Failed to send file"); } + + resp = await fetch(resourceUrl + issueId, {method: "GET"}); + if (resp.ok) resp.blob().then( + blob => window.open(URL.createObjectURL(blob)) + ); + else throw new Error("Failed to recieve PDF"); } catch { - console.error("Failed to get PDF"); - } + console.error("Failed to generate PDF"); + } document.getElementById("print-button").disabled = false; } diff --git a/tests/test_api.py b/tests/test_api.py index 1a5f7b1..2550a0a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,41 +1,64 @@ -import json from fastapi.testclient import TestClient from muckraker.main import app -# TODO: get rid off explicit header setting. I currently have to do that -# as POST /issue/ recieves a complicated multipart data. Maybe it would -# be better to move image uploading to another endpoint -FORM_HEADERS = {'Content-Type': 'application/x-www-form-urlencoded'} -# TODO: add image processing tests. For now TestClient doesn't send them, -# might be due to a mixed multipart data. - client = TestClient(app) -def test_issue_correct(issue_dict): - content = "issue=" + json.dumps(issue_dict) - resp = client.post( - "/issue/", - content=content, - headers=FORM_HEADERS - ) +def test_correct(issue_dict, good_image): + resp = client.post("/issue/", json=issue_dict) + assert resp.status_code == 200 + issue_id = resp.json()["issue_id"] + + files = {"image": good_image} + resp = client.patch(f"/issue/{issue_id}", files=files) assert resp.status_code == 200 + resp = client.get(f"/issue/{issue_id}") # Deletes tempdir + assert resp.status_code == 200 + with open("file.pdf", "wb") as fd: + fd.write(resp.content) -def test_issue_thick_body(issue_dict): - issue_dict["body"] = issue_dict["body"] * 100 - content = "issue=" + json.dumps(issue_dict) - resp = client.post("/issue/", content=content, headers=FORM_HEADERS) + +def test_thick_body(issue_dict): + # Won't create tempdir + issue_dict["body"] = "a" * 6001 + resp = client.post("/issue/", json=issue_dict) assert resp.status_code == 422 + detail = resp.json()["detail"][0] + assert detail["type"] == "string_too_long" + assert detail["loc"] == ["body", "body"] -def test_issue_thick_heading(issue_dict): +def test_thick_heading_field(issue_dict): + # Won't create tempdir any_field = list(issue_dict["heading"].keys())[0] - issue_dict["heading"][any_field] = "a" * 100 - content = "issue=" + json.dumps(issue_dict) - resp = client.post("/issue/", content=content, headers=FORM_HEADERS) + issue_dict["heading"][any_field] = "a" * 51 + resp = client.post("/issue/", json=issue_dict) assert resp.status_code == 422 detail = resp.json()["detail"][0] assert detail["type"] == "string_too_long" - assert detail["loc"] == ["body", "issue", "heading", any_field] + assert detail["loc"] == ["body", "heading", any_field] + + +def test_thick_image(issue_dict, thick_image): + resp = client.post("/issue/", json=issue_dict) + issue_id = resp.json()["issue_id"] + + files = {"image": thick_image} + # Deletes tempdir on 413 + resp = client.patch(f"/issue/{issue_id}", files=files) + assert resp.status_code == 413 + + +def test_issue_not_found(issue_dict, good_image): + resp = client.post("/issue/", json=issue_dict) + issue_id = resp.json()["issue_id"] # Deletes tempdir + client.get(f"/issue/{issue_id}") + + resp = client.get(f"/issue/{issue_id}") + assert resp.status_code == 404 + + files = {"image": good_image} + resp = client.patch(f"/issue/{issue_id}", files=files) + assert resp.status_code == 404