Skip to content

Commit

Permalink
Split issue endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
kompoth committed Apr 2, 2024
1 parent a5f6f11 commit 4f9016d
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 76 deletions.
90 changes: 55 additions & 35 deletions muckraker/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,53 +22,71 @@
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
image_path = dir_path / image.filename
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()

Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ <h2>Issue creation</h2>
</label>
</fieldset>

<button type="button" id="print-button" onclick="requestPDF();">Send to print</button>
<button type="button" id="print-button" onclick="generatePDF();">Send to print</button>
</form>
</section>

Expand Down
50 changes: 34 additions & 16 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
71 changes: 47 additions & 24 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 4f9016d

Please sign in to comment.