Repository: jacebrowning/memegen
Issue: Document image routes in Swagger

The routes displaying images are not showing up in the Swagger UI:

https://github.com/jacebrowning/memegen/blob/faeaff2d659fe4279879fc1e13b710d446e47d46/app/views/memes.py#L142

Changed files:
app/helpers.py, app/views/shortcuts.py, app/views/templates.py, app/views/clients.py, app/views/memes.py, app/config.py, app/tests/test_docs.py, app/models/template.py

diff --git a/app/config.py b/app/config.py
index 2086956a..e870c5c9 100644
--- a/app/config.py
+++ b/app/config.py
@@ -52,7 +52,7 @@ def init(app):
     app.blueprint(views.clients.blueprint)
     app.blueprint(views.memes.blueprint)
     app.blueprint(views.templates.blueprint)
-    app.blueprint(views.shortcuts.blueprint)  # registered last to avoid collisions
+    app.blueprint(views.shortcuts.blueprint)
 
     app.error_handler = BugsnagErrorHandler()
     bugsnag.configure(
diff --git a/app/helpers.py b/app/helpers.py
index e86545ce..3f8518ca 100644
--- a/app/helpers.py
+++ b/app/helpers.py
@@ -49,9 +49,9 @@ def get_example_images(
 def get_test_images(request: Request) -> list[str]:
     return [
         request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=id,
-            text_paths=utils.text.encode(lines) + "." + extension,
+            text_filepath=utils.text.encode(lines) + "." + extension,
         )
         for id, lines, extension in settings.TEST_IMAGES
     ]
diff --git a/app/models/template.py b/app/models/template.py
index 3f8d157c..2aa70ab4 100644
--- a/app/models/template.py
+++ b/app/models/template.py
@@ -102,8 +102,8 @@ class Template:
             "overlays": len(self.overlay) if self.styles else 0,
             "styles": self.styles,
             "blank": request.app.url_for(
-                "Memes.blank",
-                template_id=self.id + "." + settings.DEFAULT_EXTENSION,
+                "memes.blank",
+                template_filename=self.id + "." + settings.DEFAULT_EXTENSION,
                 _external=True,
                 _scheme=settings.SCHEME,
             ),
@@ -117,7 +117,7 @@ class Template:
 
     def build_self_url(self, request: Request) -> str:
         return request.app.url_for(
-            "Templates.detail",
+            "templates.detail",
             id=self.id,
             _external=True,
             _scheme=settings.SCHEME,
@@ -132,12 +132,12 @@ class Template:
     ) -> str:
         kwargs = {
             "template_id": self.id,
-            "text_paths": utils.text.encode(self.example) + "." + extension,
+            "text_filepath": utils.text.encode(self.example) + "." + extension,
             "_external": external,
         }
         if external:
             kwargs["_scheme"] = settings.SCHEME
-        url = request.app.url_for("Memes.text", **kwargs)
+        url = request.app.url_for("memes.text", **kwargs)
         return utils.urls.clean(url)
 
     def build_custom_url(
@@ -155,9 +155,9 @@ class Template:
         if style == settings.DEFAULT_STYLE:
             style = ""
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id="custom" if self.id == "_custom" else self.id,
-            text_paths=utils.text.encode(text_lines) + "." + extension,
+            text_filepath=utils.text.encode(text_lines) + "." + extension,
             _external=True,
             _scheme=settings.SCHEME,
             **utils.urls.params(background=background, style=style, font=font),
diff --git a/app/tests/test_docs.py b/app/tests/test_docs.py
index 20abdcd9..9cd1c07d 100644
--- a/app/tests/test_docs.py
+++ b/app/tests/test_docs.py
@@ -1,3 +1,4 @@
+import pytest
 from pkg_resources import get_distribution
 
 
@@ -7,3 +8,16 @@ def describe_spec():
         request, response = client.get("/docs/openapi.json")
         expect(response.status) == 200
         expect(response.json["info"]["version"]) == version
+
+
+@pytest.mark.xfail(reason="Requires JavaScript")
+def describe_ui():
+    def it_contains_image_routes(expect, client):
+        request, response = client.get("/docs")
+        expect(response.status) == 200
+        expect(response.text).contains("Display a custom meme")
+
+    def it_contains_redirect_routes(expect, client):
+        request, response = client.get("/docs")
+        expect(response.status) == 200
+        expect(response.text).contains("Redirect to a custom image")
diff --git a/app/views/clients.py b/app/views/clients.py
index f6bb2a60..faedd6ec 100644
--- a/app/views/clients.py
+++ b/app/views/clients.py
@@ -5,7 +5,7 @@ from .. import models, utils
 from .helpers import preview_image
 from .schemas import AuthResponse, ErrorResponse, FontResponse
 
-blueprint = Blueprint("Clients", url_prefix="/")
+blueprint = Blueprint("clients", url_prefix="/")
 
 
 @blueprint.post("/auth")
diff --git a/app/views/memes.py b/app/views/memes.py
index 0234cf92..ef27cf2a 100644
--- a/app/views/memes.py
+++ b/app/views/memes.py
@@ -16,12 +16,11 @@ from .schemas import (
 )
 from .templates import generate_url
 
-blueprint = Blueprint("Memes", url_prefix="/images")
+blueprint = Blueprint("memes", url_prefix="/images")
 
 
 @blueprint.get("/")
 @openapi.summary("List example memes")
-@openapi.operation("Memes.list")
 @openapi.parameter(
     "filter", str, "query", description="Part of the template name or example to match"
 )
@@ -40,7 +39,6 @@ async def index(request):
 
 @blueprint.post("/")
 @openapi.summary("Create a meme from a template")
-@openapi.operation("Memes.create")
 @openapi.body({"application/json": MemeRequest})
 @openapi.response(
     201, {"application/json": MemeResponse}, "Successfully created a meme"
@@ -111,7 +109,6 @@ async def custom(request):
 
 @blueprint.get("/custom")
 @openapi.summary("List popular custom memes")
-@openapi.operation("Memes.list_custom")
 @openapi.parameter("safe", bool, "query", description="Exclude NSFW results")
 @openapi.parameter(
     "filter", str, "query", description="Part of the meme's text to match"
@@ -139,10 +136,10 @@ async def list_custom(request):
     return response.json(items, status=200)
 
 
-@blueprint.get(r"/<template_id:.+\\.\\w+>")
-@openapi.tag("Templates")
+@blueprint.get(r"/<template_filename:.+\\.\\w+>")
+@openapi.tag("templates")
 @openapi.summary("Display a template background")
-@openapi.parameter("template_id", str, "path")
+@openapi.parameter("template_filename", str, "path")
 @openapi.response(
     200, {"image/*": bytes}, "Successfully displayed a template background"
 )
@@ -153,15 +150,15 @@ async def list_custom(request):
     {"image/*": bytes},
     "Invalid style for template or no image URL specified for custom template",
 )
-async def blank(request, template_id):
-    template_id, extension = template_id.rsplit(".", 1)
+async def blank(request, template_filename):
+    template_id, extension = template_filename.rsplit(".", 1)
 
     if request.args.get("style") == "animated" and extension != "gif":
         # TODO: Move this pattern to utils
         params = {k: v for k, v in request.args.items() if k != "style"}
         url = request.app.url_for(
-            "Memes.blank",
-            template_id=template_id + ".gif",
+            "memes.blank",
+            template_filename=template_id + ".gif",
             **params,
         )
         return response.redirect(utils.urls.clean(url), status=301)
@@ -169,9 +166,9 @@ async def blank(request, template_id):
     return await render_image(request, template_id, extension=extension)
 
 
-@blueprint.get(r"/<template_id:slug>/<text_paths:[^/].*\\.\\w+>")
+@blueprint.get(r"/<template_id:slug>/<text_filepath:[^/].*\\.\\w+>")
 @openapi.summary("Display a custom meme")
-@openapi.parameter("text_paths", str, "path")
+@openapi.parameter("text_filepath", str, "path")
 @openapi.parameter("template_id", str, "path")
 @openapi.response(200, {"image/*": bytes}, "Successfully displayed a custom meme")
 @openapi.response(404, {"image/*": bytes}, "Template not found")
@@ -182,16 +179,16 @@ async def blank(request, template_id):
     {"image/*": bytes},
     "Invalid style for template or no image URL specified for custom template",
 )
-async def text(request, template_id, text_paths):
-    text_paths, extension = text_paths.rsplit(".", 1)
+async def text(request, template_id, text_filepath):
+    text_paths, extension = text_filepath.rsplit(".", 1)
 
     if request.args.get("style") == "animated" and extension != "gif":
         # TODO: Move this pattern to utils
         params = {k: v for k, v in request.args.items() if k != "style"}
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=template_id,
-            text_paths=text_paths + ".gif",
+            text_filepath=text_paths + ".gif",
             **params,
         )
         return response.redirect(utils.urls.clean(url), status=301)
@@ -199,9 +196,9 @@ async def text(request, template_id, text_paths):
     slug, updated = utils.text.normalize(text_paths)
     if updated:
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=template_id,
-            text_paths=slug + "." + extension,
+            text_filepath=slug + "." + extension,
             **request.args,
         )
         return response.redirect(utils.urls.clean(url), status=301)
@@ -215,9 +212,9 @@ async def text(request, template_id, text_paths):
         # TODO: Move this pattern to utils
         params = {k: v for k, v in request.args.items() if k != "watermark"}
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=template_id,
-            text_paths=slug + "." + extension,
+            text_filepath=slug + "." + extension,
             **params,
         )
         return response.redirect(utils.urls.clean(url), status=302)
diff --git a/app/views/shortcuts.py b/app/views/shortcuts.py
index 64adfcfe..bd83594c 100644
--- a/app/views/shortcuts.py
+++ b/app/views/shortcuts.py
@@ -4,7 +4,7 @@ from sanic_ext import openapi
 
 from .. import models, settings, utils
 
-blueprint = Blueprint("Shortcuts", url_prefix="/")
+blueprint = Blueprint("shortcuts", url_prefix="/")
 
 
 @blueprint.get(r"/images/<template_id:[^.]+>")
@@ -58,7 +58,7 @@ async def legacy_example_image(request, template_id):
     raise exceptions.NotFound(f"Template not found: {template_id}")
 
 
-@blueprint.get(r"/<template_id:slug>")
+@blueprint.get("/<template_id:slug>")
 @openapi.exclude(settings.DEPLOYED)
 @openapi.summary("Redirect to an example image" + settings.SUFFIX)
 @openapi.parameter("template_id", str, "path")
@@ -81,9 +81,11 @@ async def custom_path(request, template_id, text_paths):
 
     if not settings.DEBUG:
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=template_id,
-            text_paths=utils.urls.clean(text_paths) + "." + settings.DEFAULT_EXTENSION,
+            text_filepath=utils.urls.clean(text_paths)
+            + "."
+            + settings.DEFAULT_EXTENSION,
         )
         return response.redirect(url)
 
@@ -112,9 +114,9 @@ async def legacy_custom_image(request, template_id, text_paths):
     template = models.Template.objects.get_or_none(template_id)
     if template:
         url = request.app.url_for(
-            "Memes.text",
+            "memes.text",
             template_id=template_id,
-            text_paths=text_paths + "." + extension,
+            text_filepath=text_paths + "." + extension,
         )
         return response.redirect(url)
     raise exceptions.NotFound(f"Template not found: {template_id}")
diff --git a/app/views/templates.py b/app/views/templates.py
index 55089a69..5b2475a5 100644
--- a/app/views/templates.py
+++ b/app/views/templates.py
@@ -8,7 +8,7 @@ from ..models import Template
 from .helpers import generate_url
 from .schemas import CustomRequest, MemeRequest, MemeResponse, TemplateResponse
 
-blueprint = Blueprint("Templates", url_prefix="/templates")
+blueprint = Blueprint("templates", url_prefix="/templates")
 
 
 @blueprint.get("/")
@@ -53,8 +53,7 @@ async def detail(request, id):
 
 
 @blueprint.post("/<id:slug>")
-@openapi.tag("Memes")
-@openapi.operation("Memes.create_from_template")
+@openapi.tag("memes")
 @openapi.exclude(settings.DEPLOYED)
 @openapi.summary("Create a meme from a template" + settings.SUFFIX)
 @openapi.parameter("id", str, "path")
@@ -69,7 +68,7 @@ async def build(request, id):
 
 
 @blueprint.post("/custom")
-@openapi.tag("Memes")
+@openapi.tag("memes")
 @openapi.exclude(settings.DEPLOYED)
 @openapi.summary("Create a meme from any image" + settings.SUFFIX)
 @openapi.body({"application/json": CustomRequest})