From 2e23c4ea8590774c482f2d588f5c9bfae41fa2f4 Mon Sep 17 00:00:00 2001 From: Steve Lacey Date: Mon, 6 Dec 2021 21:04:49 +0700 Subject: [PATCH] Browsable API --- README.md | 65 ++++++++++++++++++++++++++-- pytest.ini | 2 +- tests/conftest.py | 7 ++++ tests/test_browsable_api.py | 18 ++++++++ worf/templates/worf/api.html | 3 ++ worf/templates/worf/base.html | 79 +++++++++++++++++++++++++++++++++++ worf/views/base.py | 37 +++++++++++----- 7 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 tests/test_browsable_api.py create mode 100644 worf/templates/worf/api.html create mode 100644 worf/templates/worf/base.html diff --git a/README.md b/README.md index 129cff0..1971546 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Table of contents - [DetailAPI](#detailapi) - [CreateAPI](#createapi) - [UpdateAPI](#updateapi) + - [Browsable API](#browsable-api) - [Bundle loading](#bundle-loading) - [Field casing](#field-casing) - [File uploads](#file-uploads) @@ -39,10 +40,21 @@ Table of contents Installation ------------ +Install using pip: + ```sh pip install worf ``` +Add `worf` to your `INSTALLED_APPS` setting: + +```py +INSTALLED_APPS = [ + ... + "worf", +] +``` + Requirements ------------ @@ -55,12 +67,13 @@ Roadmap ------- - [x] Abstracting serializers away from model methods +- [x] Browsable API - [x] Declarative marshmallow-based serialization -- [x] More support for different HTTP methods - [x] File upload support -- [ ] Support for user-generated validators +- [x] Support for PATCH/PUT methods - [ ] Better test coverage -- [ ] Browsable API docs +- [ ] Documentation generation +- [ ] Support for user-generated validators Usage @@ -273,6 +286,52 @@ writeable should be within the `fields` definition of the serializer, and not marked as `dump_only` (read-only). +Browsable API +------------- + +Similar to other popular REST frameworks; Worf exposes a browsable API which adds +syntax highlighting, linkified URLs and supports Django Debug Toolbar. + +### Format + +To override the default browser behaviour pass `?format=json`. + +### Theme + +The theme is built with [Tailwind](https://tailwindcss.com/), making it easy to customize the look-and-feel. + +For quick and easy branding, there are a couple of Django settings that tweak the navbar: + +| Name | Default | +| ------------- | -------- | +| WORF_API_NAME | Worf API | +| WORF_API_ROOT | /api/ | + +To customize the markup create a template called `worf/api.html` that extends from `worf/base.html`: + +```django +# templates/worf/api.html +{% extends "worf/base.html" %} + +{% block branding %} + {{ block.super }} +
A warrior's drink!
+{% endblock %} +``` + +All of the blocks available in the base template can be used in your `api.html`. + +| Name | Description | +| -------- | ------------------------------- | +| body | The entire html ``. | +| branding | Branding section of the navbar. | +| script | JavaScript files for the page. | +| style | CSS stylesheets for the page. | +| title | Title of the page. | + +For more advanced customization you can choose not to have `api.html` extend `base.html`. + + Bundle loading -------------- diff --git a/pytest.ini b/pytest.ini index 6fcf2a7..7029cc5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,7 @@ [pytest] addopts = --cov - --cov-fail-under 80 + --cov-fail-under 81 --cov-report term:skip-covered --cov-report html --no-cov-on-fail diff --git a/tests/conftest.py b/tests/conftest.py index d7a8a35..f29c5b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,11 +34,18 @@ def pytest_configure(): } }, PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + }, + ], TIME_ZONE="UTC", USE_I18N=True, USE_L10N=True, USE_TZ=True, STATIC_URL="/static/", + WORF_API_NAME="Test API" ) django.setup() diff --git a/tests/test_browsable_api.py b/tests/test_browsable_api.py new file mode 100644 index 0000000..05de56c --- /dev/null +++ b/tests/test_browsable_api.py @@ -0,0 +1,18 @@ +def test_browsable_api(client, db, profile, user): + response = client.get(f"/profiles/{profile.pk}/", HTTP_ACCEPT="text/html,image/png") + + content = response.content.decode("UTF-8") + + assert response.status_code == 200, content + assert "Test API" in content + assert "/api/" in content + assert "HTTP" in content + assert "200" in content + assert "Content-Type" in content + assert "application/json" in content + assert user.username in content + + assert client.get(f"/profiles/{profile.pk}/", HTTP_ACCEPT="").json() + assert client.get(f"/profiles/{profile.pk}/", HTTP_ACCEPT="application/json").json() + assert client.get(f"/profiles/{profile.pk}/", HTTP_ACCEPT="text/plain").json() + assert client.get(f"/profiles/{profile.pk}/?format=json", HTTP_ACCEPT="text/html").json() diff --git a/worf/templates/worf/api.html b/worf/templates/worf/api.html new file mode 100644 index 0000000..c43c3b0 --- /dev/null +++ b/worf/templates/worf/api.html @@ -0,0 +1,3 @@ +{% extends "worf/base.html" %} + +{# Override this template in your own templates directory to customize #} diff --git a/worf/templates/worf/base.html b/worf/templates/worf/base.html new file mode 100644 index 0000000..185b203 --- /dev/null +++ b/worf/templates/worf/base.html @@ -0,0 +1,79 @@ + + + + {% block head %} + {% block meta %} + + + {% endblock %} + + {{ api_name }}: {{ request.get_full_path }} + + {% block style %} + + + + + {% endblock %} + {% endblock %} + + + + {% block body %} + {% block nav %} + + {% endblock %} + +
+
+ {% block content %} + + +
+
HTTP {{ response.status_code }} {{ response.status_text }}{% for key, value in response.headers.items %}
+{{ key }}: {{ value }}{% endfor %}
+
+{{ content }}
+
+ {% endblock content %} +
+
+ + {% block script %} + + + + + + + {% endblock %} + {% endblock %} + + diff --git a/worf/views/base.py b/worf/views/base.py index 643c6a5..b353899 100644 --- a/worf/views/base.py +++ b/worf/views/base.py @@ -13,7 +13,7 @@ ) from django.db import models from django.http import HttpResponse, JsonResponse -from django.middleware.gzip import GZipMiddleware +from django.template.response import TemplateResponse from django.views import View from django.views.decorators.cache import never_cache from django.utils.decorators import method_decorator @@ -23,7 +23,8 @@ from worf.serializers import LegacySerializer from worf.validators import ValidationMixin -gzip_middleware = GZipMiddleware() +api_name = getattr(settings, "WORF_API_NAME", "Worf API") +api_root = getattr(settings, "WORF_API_ROOT", "/api/") @method_decorator(never_cache, name="dispatch") @@ -35,22 +36,38 @@ def __init__(self, *args, **kwargs): def serialize(self): raise NotImplementedError - def render_to_response(self, data=None, status_code=None): - payload = data if data is not None else self.serialize() + def render_to_response(self, data=None, status_code=200): + if data is None: + data = self.serialize() - if payload is None: + if data is None: msg = f"{self.codepath} did not pass an object to " msg += "render_to_response, nor did its serializer method" raise ImproperlyConfigured(msg) - response = JsonResponse(payload) if payload != "" else HttpResponse() - # except TypeError: - # TODO add something meaningful to the stack trace + is_html_request = ( + "text/html" in self.request.headers.get("Accept", "") + and self.request.GET.get("format") != "json" + ) - if status_code is not None: # needs tests + json_kwargs = dict(json_dumps_params=dict(indent=2)) if is_html_request else {} + + response = JsonResponse(data, **json_kwargs) if data != "" else HttpResponse() + response.status_code = status_code + + if is_html_request: + template = "worf/api.html" + context = dict( + api_name=api_name, + api_root=api_root, + content=response.content.decode("utf-8"), + response=response, + ) + response = TemplateResponse(self.request, template, context=context) response.status_code = status_code + response.render() - return gzip_middleware.process_response(self.request, response) + return response class AbstractBaseAPI(APIResponse, ValidationMixin):