Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Settings module + Browsable API and debug settings #84

Merged
merged 1 commit into from
Dec 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ Table of contents
- [UpdateAPI](#updateapi)
- [Browsable API](#browsable-api)
- [Bundle loading](#bundle-loading)
- [Debugging](#debugging)
- [Field casing](#field-casing)
- [File uploads](#file-uploads)
- [Internal naming](#internal-naming)
- [Settings](#settings)
- [Credits](#credits)


Expand Down Expand Up @@ -217,8 +219,8 @@ It is not recommended to use this abstract view directly.
| Name | Type | Default | Description |
| -- | -- | -- | -- |
| filters | dict | {} | Pass key/value pairs that you wish to further filter the queryset beyond the `lookup_url_kwarg` |
| lookup_field | str | None | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_url_kwarg` is required if this is set. |
| lookup_url_kwarg | str | None | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_field` is required if this is set. |
| lookup_field | str | None | Use these two attributes in tandem in order to filter `get_queryset` based on a URL field. `lookup_url_kwarg` is required if this is set. |
| lookup_url_kwarg | str | None | Use these two attributes in tandem in order to filter `get_queryset` based on a URL field. `lookup_field` is required if this is set. |
| payload_key | str | verbose_name_plural | Use in order to rename the key for the results array |
| ordering | list | [] | Pass a list of fields to default the queryset order by. |
| filter_fields | list | [] | Pass a list of fields to support filtering via query params. |
Expand Down Expand Up @@ -292,20 +294,14 @@ 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`.
To override the default browser behaviour pass `?format=json`, or [disable the
feature entirely from settings](#settings).

### 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/ |
For quick and easy branding, there are a couple of [settings that tweak the navbar](#settings).

To customize the markup create a template called `worf/api.html` that extends from `worf/base.html`:

Expand Down Expand Up @@ -347,6 +343,13 @@ be changed during processing. You may also append or remove attributes to the
bundle before saving the object via `post`, `patch`, or other methods.


Debugging
---------

Worf exposes the parsed bundle, lookup kwargs, sql and skips some exception handling
[when in debug mode](#settings).


Field casing
------------

Expand Down Expand Up @@ -387,6 +390,18 @@ this codebase for clarity:
- `payload` is what the backend returns.


Settings
--------

| Name | Default | Description |
| ------------------ | -------------- | ----------------------------------- |
| WORF_API_NAME | Worf API | See [Browsable API](#browsable-api) |
| WORF_API_ROOT | /api/ | See [Browsable API](#browsable-api) |
| WORF_BROWSABLE_API | True | See [Browsable API](#browsable-api) |
| WORF_DEBUG | settings.DEBUG | See [Debugging](#debugging) |



Credits
-------

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 81
--cov-fail-under 82
--cov-report term:skip-covered
--cov-report html
--no-cov-on-fail
Expand Down
34 changes: 11 additions & 23 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,32 @@ def pytest_configure():
from django.conf import settings

settings.configure(
SECRET_KEY="secret",
DEBUG=True,
Copy link
Contributor Author

@stevelacey stevelacey Dec 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun. This was never actually True. Seems configure cannot override the default from global_settings.

INSTALLED_APPS=[
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"tests",
"worf",
],
MIDDLEWARE=[
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
],
ROOT_URLCONF="tests.urls",
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "db.sqlite3",
}
},
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
"tests",
"worf",
],
PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"],
ROOT_URLCONF="tests.urls",
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
},
],
TIME_ZONE="UTC",
USE_I18N=True,
USE_L10N=True,
USE_I18N=False,
USE_L10N=False,
Comment on lines -13 to +33
Copy link
Contributor Author

@stevelacey stevelacey Dec 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripped some of this down given it doesn't matter and/or saves on cpu.

USE_TZ=True,
STATIC_URL="/static/",
WORF_API_NAME="Test API"
WORF_API_NAME="Test API",
WORF_DEBUG=True,
)

django.setup()
Expand Down
8 changes: 8 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from worf.conf import settings


def test_settings():
assert settings.WORF_API_NAME == "Test API"
assert settings.WORF_API_ROOT == "/api/"
assert settings.WORF_BROWSABLE_API is True
assert settings.WORF_DEBUG is True
6 changes: 6 additions & 0 deletions worf/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from importlib import import_module

from django.utils.functional import SimpleLazyObject


settings = SimpleLazyObject(lambda: import_module("worf.settings"))
2 changes: 1 addition & 1 deletion worf/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import marshmallow

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db.models.fields.files import FieldFile

from worf import fields # noqa: F401
from worf.casing import snake_to_camel
from worf.conf import settings


class SerializerOptions(marshmallow.SchemaOpts):
Expand Down
9 changes: 9 additions & 0 deletions worf/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.conf import settings


WORF_API_NAME = getattr(settings, "WORF_API_NAME", "Worf API")
WORF_API_ROOT = getattr(settings, "WORF_API_ROOT", "/api/")

WORF_BROWSABLE_API = getattr(settings, "WORF_BROWSABLE_API", True)

WORF_DEBUG = getattr(settings, "WORF_DEBUG", settings.DEBUG)
6 changes: 3 additions & 3 deletions worf/templates/worf/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="robots" content="none,noarchive"/>
{% endblock %}

<title>{{ api_name }}: {{ request.get_full_path }}</title>
<title>{{ settings.WORF_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">
Expand Down Expand Up @@ -35,8 +35,8 @@
<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 href="{{ settings.WORF_API_ROOT }}" class="font-medium text-lg text-white" rel="nofollow">
{{ settings.WORF_API_NAME }}
</a>
{% endblock %}
</div>
Expand Down
6 changes: 3 additions & 3 deletions worf/validators.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from datetime import datetime
from uuid import UUID

from django.conf import settings
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.dateparse import parse_datetime

from worf.conf import settings
from worf.exceptions import NotImplementedInWorfYet


Expand Down Expand Up @@ -167,7 +167,7 @@ def validate_bundle(self, key):

if self.request.method in write_methods and key not in serializer.write():
message = f"{self.keymap[key]} is not editable"
if settings.DEBUG:
if settings.WORF_DEBUG:
message += f":: {serializer}"
raise ValidationError(message)

Expand Down Expand Up @@ -240,7 +240,7 @@ def validate_bundle(self, key):

else:
message = f"{field.get_internal_type()} has no validation method for {key}"
if settings.DEBUG:
if settings.WORF_DEBUG:
message += f":: Received {self.bundle[key]}"
raise NotImplementedInWorfYet(message)
# TODO
Expand Down
21 changes: 9 additions & 12 deletions worf/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from io import BytesIO
from urllib.parse import parse_qs

from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
ObjectDoesNotExist,
Expand All @@ -18,14 +17,12 @@
from django.views.decorators.cache import never_cache
from django.utils.decorators import method_decorator

from worf.conf import settings
from worf.casing import camel_to_snake, snake_to_camel
from worf.exceptions import HTTP_EXCEPTIONS, HTTP404, HTTP422, PermissionsException
from worf.serializers import LegacySerializer
from worf.validators import ValidationMixin

api_name = getattr(settings, "WORF_API_NAME", "Worf API")
api_root = getattr(settings, "WORF_API_ROOT", "/api/")


@method_decorator(never_cache, name="dispatch")
class APIResponse(View):
Expand All @@ -45,23 +42,23 @@ def render_to_response(self, data=None, status_code=200):
msg += "render_to_response, nor did its serializer method"
raise ImproperlyConfigured(msg)

is_html_request = (
"text/html" in self.request.headers.get("Accept", "")
is_browsable = (
settings.WORF_BROWSABLE_API
and "text/html" in self.request.headers.get("Accept", "")
and self.request.GET.get("format") != "json"
)

json_kwargs = dict(json_dumps_params=dict(indent=2)) if is_html_request else {}
json_kwargs = dict(json_dumps_params=dict(indent=2)) if is_browsable else {}

response = JsonResponse(data, **json_kwargs) if data != "" else HttpResponse()
response.status_code = status_code

if is_html_request:
if is_browsable:
template = "worf/api.html"
context = dict(
api_name=api_name,
api_root=api_root,
content=response.content.decode("utf-8"),
response=response,
settings=settings,
)
response = TemplateResponse(self.request, template, context=context)
response.status_code = status_code
Expand Down Expand Up @@ -118,9 +115,9 @@ def _check_permissions(self):
if response == 200:
continue

if settings.DEBUG:
if settings.WORF_DEBUG:
raise PermissionsException(
"Permissions function {}.{} returned {}. You'd normally see a 404 here but DEBUG=True.".format(
"Permissions function {}.{} returned {}. You'd normally see a 404 here but WORF_DEBUG=True.".format(
method.__module__, method.__name__, response
)
)
Expand Down
15 changes: 9 additions & 6 deletions worf/views/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from functools import reduce
import warnings

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import EmptyResultSet, ImproperlyConfigured
from django.core.paginator import Paginator, EmptyPage
from django.db.models import Q

from worf.casing import camel_to_snake
from worf.conf import settings
from worf.exceptions import HTTP420
from worf.filters import apply_filterset, generate_filterset
from worf.views.base import AbstractBaseAPI
Expand Down Expand Up @@ -165,7 +165,7 @@ def get_processed_queryset(self):
if order_by:
queryset = queryset.order_by(*order_by)
except TypeError as e:
if settings.DEBUG:
if settings.WORF_DEBUG:
raise HTTP420(f"Error, {self.lookup_kwargs}, {e.__cause__}")
raise e

Expand All @@ -181,8 +181,11 @@ def paginated_results(self):
queryset = self.get_processed_queryset()
request = self.request

if settings.DEBUG:
self.query = str(queryset.query)
if settings.WORF_DEBUG:
try:
self.query = str(queryset.query)
except EmptyResultSet:
self.query = None
Comment on lines +184 to +188
Copy link
Contributor Author

@stevelacey stevelacey Dec 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might have seen this exception when using filters that create an impossible query. Outside of debug mode lists will yield [] but when debug mode is on they'd fail here trying to output the sql for the debug.

The reason I've addressed this here is because now that debug mode is actually on during testing, one test was failing here given it exercises an impossible query.


default_per_page = getattr(self, "results_per_page", self.per_page)
per_page = max(int(request.GET.get("perPage") or default_per_page), 1)
Expand Down Expand Up @@ -235,7 +238,7 @@ def serialize(self):
}
)

if not settings.DEBUG:
if not settings.WORF_DEBUG:
return payload

if not hasattr(self, "lookup_kwargs"):
Expand Down