Skip to content

Commit

Permalink
Merge pull request #573 from jacebrowning/auto-images
Browse files Browse the repository at this point in the history
Add experimental route for automatic memes
  • Loading branch information
jacebrowning committed Feb 11, 2021
2 parents 93f3ebd + ab0e059 commit 3e21343
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 105 deletions.
37 changes: 35 additions & 2 deletions app/api/clients.py
@@ -1,7 +1,10 @@
import asyncio

from sanic import Blueprint, response
from sanic.log import logger
from sanic_openapi import doc

from .. import utils
from .. import models, utils

blueprint = Blueprint("Clients", url_prefix="/")

Expand All @@ -18,4 +21,34 @@ async def validate(request):
)


# TODO: Create a new `POST /images/auto` route
@blueprint.get("/images/preview.jpg")
@doc.summary("Display a preview of a custom meme")
@doc.produces(
doc.File(),
description="Successfully displayed a custom meme",
content_type="image/jpeg",
)
async def preview(request):
id = request.args.get("template", "_error")
lines = request.args.getlist("lines[]", [])
style = request.args.get("style")
return await preview_image(request, id, lines, style)


async def preview_image(request, id: str, lines: list[str], style: str):
id = utils.text.unquote(id)
if "://" in id:
template = await models.Template.create(id)
if not template.image.exists():
logger.error(f"Unable to download image URL: {id}")
template = models.Template.objects.get("_error")
else:
template = models.Template.objects.get_or_none(id)
if not template:
logger.error(f"No such template: {id}")
template = models.Template.objects.get("_error")

data, content_type = await asyncio.to_thread(
utils.images.preview, template, lines, style=style
)
return response.raw(data, content_type=content_type)
73 changes: 40 additions & 33 deletions app/api/memes.py
@@ -1,5 +1,6 @@
import asyncio
from contextlib import suppress
from urllib.parse import parse_qs, urlparse

from sanic import Blueprint, response
from sanic.log import logger
Expand All @@ -12,7 +13,7 @@

@blueprint.get("/")
@doc.summary("List example memes")
@doc.operation("images.list")
@doc.operation("Memes.list")
@doc.produces(
doc.List({"url": str, "template": str}),
description="Successfully returned a list of example memes",
Expand All @@ -27,7 +28,7 @@ async def index(request):

@blueprint.post("/")
@doc.summary("Create a meme from a template")
@doc.operation("images.create")
@doc.operation("Memes.create")
@doc.consumes(
doc.JsonBody(
{"template_id": str, "text_lines": [str], "extension": str, "redirect": bool}
Expand Down Expand Up @@ -78,19 +79,44 @@ async def create(request):
return response.json({"url": url}, status=status)


@blueprint.get("/preview.jpg")
@doc.tag("Clients")
@doc.summary("Display a preview of a custom meme")
@doc.produces(
doc.File(),
description="Successfully displayed a custom meme",
content_type="image/jpeg",
@blueprint.post("/automatic")
@doc.exclude(settings.DEPLOYED)
@doc.summary("Create a meme from word or phrase")
@doc.consumes(
doc.JsonBody({"text": str, "redirect": bool}),
content_type="application/json",
location="body",
)
@doc.response(201, {"url": str}, description="Successfully created a meme")
@doc.response(
400, {"error": str}, description='Required "text" missing in request body'
)
async def preview(request):
id = request.args.get("template", "_error")
lines = request.args.getlist("lines[]", [])
style = request.args.get("style")
return await preview_image(request, id, lines, style)
async def auto(request):
if request.form:
payload = dict(request.form)
else:
payload = request.json or {}

try:
text = payload["text"]
except KeyError:
return response.json({"error": '"text" is required'}, status=400)

results = await utils.meta.search(request, text)
logger.info(f"Found {len(results)} result(s)")
if not results:
return response.json({"message": f"No results matched: {text}"}, status=404)

parts = urlparse(results[0]["image_url"])
url = f"{settings.SCHEME}://{settings.SERVER_NAME}{parts.path}"
if "background" in parts.query:
url += "?background=" + parse_qs(parts.query)["background"][0]
logger.info(f"Top result: {url}")

if payload.get("redirect", False):
return response.redirect(url)

return response.json({"url": url}, status=201)


@blueprint.get("/<template_id>.png")
Expand Down Expand Up @@ -213,25 +239,6 @@ async def text_jpg(request, template_id, text_paths):
return await render_image(request, template_id, slug, watermark, ext="jpg")


async def preview_image(request, id: str, lines: list[str], style: str):
id = utils.text.unquote(id)
if "://" in id:
template = await models.Template.create(id)
if not template.image.exists():
logger.error(f"Unable to download image URL: {id}")
template = models.Template.objects.get("_error")
else:
template = models.Template.objects.get_or_none(id)
if not template:
logger.error(f"No such template: {id}")
template = models.Template.objects.get("_error")

data, content_type = await asyncio.to_thread(
utils.images.preview, template, lines, style=style
)
return response.raw(data, content_type=content_type)


async def render_image(
request,
id: str,
Expand Down
58 changes: 28 additions & 30 deletions app/api/templates.py
Expand Up @@ -36,7 +36,6 @@ async def index(request):

@blueprint.get("/<id>")
@doc.summary("View a specific template")
@doc.operation("Templates.detail")
@doc.produces(
{
"id": str,
Expand All @@ -58,21 +57,19 @@ async def detail(request, id):
abort(404)


# TODO: Deprecate this in favor of a new `POST /images/custom` route
@blueprint.post("/custom")
# TODO: Deprecate this in favor of the `POST /images` route
@blueprint.post("/<id>")
@doc.tag("Memes")
@doc.summary("Create a meme from any image")
@doc.summary("Create a meme from a template")
@doc.consumes(
doc.JsonBody(
{"image_url": str, "text_lines": [str], "extension": str, "redirect": bool}
),
doc.JsonBody({"text_lines": [str], "extension": str, "redirect": bool}),
content_type="application/json",
location="body",
)
@doc.response(
201, {"url": str}, description="Successfully created a meme from a custom image"
201, {"url": str}, description="Successfully created a meme from a template"
)
async def custom(request):
async def build(request, id):
if request.form:
payload = dict(request.form)
with suppress(KeyError):
Expand All @@ -86,33 +83,40 @@ async def custom(request):
with suppress(KeyError):
payload["text_lines"] = payload.pop("text_lines[]")

url = Template("_custom").build_custom_url(
template = Template.objects.get_or_create(id)
url = template.build_custom_url(
request.app,
payload.get("text_lines") or [],
background=payload.get("image_url", ""),
extension=payload.get("extension", ""),
extension=payload.get("extension"),
)

if payload.get("redirect", False):
return response.redirect(url)

return response.json({"url": url}, status=201)
if template.valid:
status = 201
else:
status = 404
template.delete()

return response.json({"url": url}, status=status)

# TODO: Deprecate this in favor of the `POST /images` route
@blueprint.post("/<id>")

# TODO: Deprecate this in favor of a new `POST /images/custom` route
@blueprint.post("/custom")
@doc.tag("Memes")
@doc.summary("Create a meme from a template")
@doc.operation("yemplates.create")
@doc.summary("Create a meme from any image")
@doc.consumes(
doc.JsonBody({"text_lines": [str], "extension": str, "redirect": bool}),
doc.JsonBody(
{"image_url": str, "text_lines": [str], "extension": str, "redirect": bool}
),
content_type="application/json",
location="body",
)
@doc.response(
201, {"url": str}, description="Successfully created a meme from a template"
201, {"url": str}, description="Successfully created a meme from a custom image"
)
async def build(request, id):
async def custom(request):
if request.form:
payload = dict(request.form)
with suppress(KeyError):
Expand All @@ -126,20 +130,14 @@ async def build(request, id):
with suppress(KeyError):
payload["text_lines"] = payload.pop("text_lines[]")

template = Template.objects.get_or_create(id)
url = template.build_custom_url(
url = Template("_custom").build_custom_url(
request.app,
payload.get("text_lines") or [],
extension=payload.get("extension"),
background=payload.get("image_url", ""),
extension=payload.get("extension", ""),
)

if payload.get("redirect", False):
return response.redirect(url)

if template.valid:
status = 201
else:
status = 404
template.delete()

return response.json({"url": url}, status=status)
return response.json({"url": url}, status=201)
6 changes: 0 additions & 6 deletions app/tests/test_apis_auth.py

This file was deleted.

37 changes: 37 additions & 0 deletions app/tests/test_apis_clients.py
@@ -0,0 +1,37 @@
import pytest


def describe_auth():
def describe_GET():
def it_returns_401_when_unauthenticated(expect, client):
request, response = client.get("/auth")
expect(response.status) == 401
expect(response.json) == {"message": "Your API key is invalid."}


def describe_image_preview():
@pytest.fixture
def path():
return "/images/preview.jpg"

def it_returns_an_image(expect, client, path):
request, response = client.get(path)
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_supports_custom_templates(expect, client, path):
request, response = client.get(
path + "?template=https://www.gstatic.com/webp/gallery/1.png"
)
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_handles_invalid_urls(expect, client, path):
request, response = client.get(path + "?template=http://example.com/foobar.jpg")
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_handles_invalid_keys(expect, client, path, unknown_template):
request, response = client.get(path + f"?template={unknown_template.id}")
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"
Expand Up @@ -243,3 +243,11 @@ def it_handles_missing_urls(expect, client):
)
expect(response.status) == 415
expect(response.headers["content-type"]) == "image/png"


def describe_automatic():
def describe_POST():
def it_requires_text(expect, client):
request, response = client.post("/images/automatic")
expect(response.status) == 400
expect(response.json) == {"error": '"text" is required'}
28 changes: 0 additions & 28 deletions app/tests/test_apis_extras.py → app/tests/test_apis_shortcuts.py
Expand Up @@ -3,34 +3,6 @@
from .. import settings


def describe_preview():
@pytest.fixture
def path():
return "/images/preview.jpg"

def it_returns_an_image(expect, client, path):
request, response = client.get(path)
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_supports_custom_templates(expect, client, path):
request, response = client.get(
path + "?template=https://www.gstatic.com/webp/gallery/1.png"
)
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_handles_invalid_urls(expect, client, path):
request, response = client.get(path + "?template=http://example.com/foobar.jpg")
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"

def it_handles_invalid_keys(expect, client, path, unknown_template):
request, response = client.get(path + f"?template={unknown_template.id}")
expect(response.status) == 200
expect(response.headers["content-type"]) == "image/jpeg"


def describe_redirects():
@pytest.mark.parametrize("ext", ["png", "jpg"])
def it_redirects_to_normalized_slug(expect, client, ext):
Expand Down
8 changes: 2 additions & 6 deletions app/tests/test_docs.py
Expand Up @@ -9,14 +9,10 @@ def it_contains_the_version(expect, client):
expect(response.json["info"]["version"]) == version

def describe_image_list():
# Only spot checking the POST /images route, not sure
# how valuable it would be to check the rest exhaustively?
def it_contains_the_operation_id(expect, client):
request, response = client.get("/docs/swagger.json")
# This is our custom operationId, the default was images.index
expect(
response.json["paths"]["/images"]["post"]["operationId"]
) == "images.create"
operation = response.json["paths"]["/images"]["post"]["operationId"]
expect(operation) == "Memes.create"

def it_contains_the_request_spec(expect, client):
request, response = client.get("/docs/swagger.json")
Expand Down

0 comments on commit 3e21343

Please sign in to comment.