Skip to content

Commit

Permalink
Browsable API
Browse files Browse the repository at this point in the history
  • Loading branch information
stevelacey committed Dec 8, 2021
1 parent 50390c3 commit 2e23c4e
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 14 deletions.
65 changes: 62 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
------------
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
<div>A warrior's drink!</div>
{% endblock %}
```

All of the blocks available in the base template can be used in your `api.html`.

| Name | Description |
| -------- | ------------------------------- |
| body | The entire html `<body>`. |
| 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
--------------

Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions tests/test_browsable_api.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions worf/templates/worf/api.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% extends "worf/base.html" %}

{# Override this template in your own templates directory to customize #}
79 changes: 79 additions & 0 deletions worf/templates/worf/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
{% block head %}
{% block meta %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="robots" content="none,noarchive"/>
{% endblock %}

<title>{{ api_name }}: {{ request.get_full_path }}</title>

{% block style %}
<link href="https://unpkg.com/[email protected]/dist/tailwind.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" rel="stylesheet">
<link href="https://unpkg.com/[email protected]/themes/prism-atom-dark.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" rel="stylesheet">
<link href="https://unpkg.com/[email protected]/prism-js-fold.css" crossorigin="anonymous" referrerpolicy="no-referrer" rel="stylesheet">
<style>
body { background: #131417; color: white; }
a { color: #58A6FF; }
a:hover { text-decoration: underline; }
nav, pre { background: #202126 !important; }
strong { font-weight: 500; }
pre[class*="language-"], :not(pre) > code[class*="language-"] { border-radius: 0; }
.token a { color: #58A6FF; }
.token.boolean { color: #FBBF24; }
.token.property { color: white; }
.token.string { color: #69F5AB; white-space: normal; word-break: break-all; }
</style>
{% endblock %}
{% endblock %}
</head>

<body>
{% block body %}
{% block nav %}
<nav class="p-4">
<div class="max-w-5xl mx-auto">
{% block branding %}
<a href="{{ api_root }}" class="font-medium text-lg text-white" rel="nofollow">
{{ api_name }}
</a>
{% endblock %}
</div>
</nav>
{% endblock %}

<div class="max-w-5xl mx-auto">
<main>
{% block content %}
<nav class="font-mono break-all my-4 px-4 py-2">
<strong>{{ request.method }}</strong> {{ request.get_full_path }}
</nav>

<div class="my-4">
<pre class="w-full p-4 overflow-auto"><strong>HTTP {{ response.status_code }} {{ response.status_text }}</strong>{% for key, value in response.headers.items %}
<strong>{{ key }}:</strong> {{ value }}{% endfor %}

<code class="language-json">{{ content }}</code></pre>
</div>
{% endblock content %}
</main>
</div>

{% block script %}
<script src="https://unpkg.com/[email protected]" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/[email protected]/components/prism-json.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/[email protected]/plugins/keep-markup/prism-keep-markup.js"></script>
<script src="https://unpkg.com/[email protected]/plugins/autolinker/prism-autolinker.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/[email protected]" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
Prism.hooks.add("complete", () => {
for (const details of document.getElementsByTagName("details")) {
details.open = true
}
})
</script>
{% endblock %}
{% endblock %}
</body>
</html>
37 changes: 27 additions & 10 deletions worf/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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):
Expand Down

0 comments on commit 2e23c4e

Please sign in to comment.