Skip to content

Commit

Permalink
Merge pull request #121 from tfranzel/i18n
Browse files Browse the repository at this point in the history
i18n #109
  • Loading branch information
tfranzel authored Jul 13, 2020
2 parents 2f9eb31 + f09228a commit 7600cbe
Show file tree
Hide file tree
Showing 16 changed files with 373 additions and 28 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Features
- Authentication support (DRF natives included, easily extendable)
- Custom serializer class support (easily extendable)
- ``MethodSerializerField()`` type via type hinting or ``@extend_schema_field``
- i18n support
- Tags extraction
- Description extraction from ``docstrings``
- Sane fallbacks
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from django.conf import settings # noqa: E402

settings.configure()
settings.configure(USE_I18N=False, USE_L10N=False)

sys.path.insert(0, os.path.abspath('../'))

Expand Down
26 changes: 26 additions & 0 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ You can easily specify a custom authentication with
Have a look at :ref:`customization` on how to use ``Extensions``


How can I i18n/internationalize my schema and UI?
----------------------------------------------------

You can use the Django internationalization as you would normally do. The workflow is as one
would expect: ``USE_I18N=True``, settings the languages, ``makemessages``, and ``compilemessages``.

The CLI tool accepts a language parameter (``./manage.py spectacular --lang="de-de"``) for offline
generation. The schema view as well as the UI views accept a ``lang`` query parameter for
explicitly requesting a language (``example.com/api/schema?lang=de``). If i18n is enabled and there
is no query parameter provided, the ``ACCEPT_LANGUAGE`` header is used. Otherwise the translation
falls back to the default language.

.. code-block:: python
from django.utils.translation import gettext_lazy as _
class PersonView(viewsets.GenericViewSet):
__doc__ = _("""
More lengthy explanation of the view
""")
@extend_schema(summary=_('Main endpoint for creating person'))
def retrieve(self, request, *args, **kwargs)
pass
FileField (ImageField) is not handled properly in the schema
------------------------------------------------------------
In contrast to most other fields, ``FileField`` behaves differently for requests and responses.
Expand Down
5 changes: 3 additions & 2 deletions drf_spectacular/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from drf_spectacular.extensions import OpenApiViewExtension
from drf_spectacular.plumbing import (
ComponentRegistry, alpha_operation_sorter, build_root_object, error, is_versioning_supported,
modify_for_versioning, operation_matches_version, reset_generator_stats, warn,
modify_for_versioning, normalize_result_object, operation_matches_version,
reset_generator_stats, warn,
)
from drf_spectacular.settings import spectacular_settings

Expand Down Expand Up @@ -163,4 +164,4 @@ def get_schema(self, request=None, public=False):
)
for hook in spectacular_settings.POSTPROCESSING_HOOKS:
result = hook(result=result, generator=self, request=request, public=public)
return result
return normalize_result_object(result)
8 changes: 7 additions & 1 deletion drf_spectacular/management/commands/spectacular.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from textwrap import dedent

from django.core.management.base import BaseCommand
from django.utils import translation
from django.utils.module_loading import import_string

from drf_spectacular.plumbing import GENERATOR_STATS
Expand Down Expand Up @@ -30,6 +31,7 @@ def add_arguments(self, parser):
parser.add_argument('--fail-on-warn', dest="fail_on_warn", default=False, action='store_true')
parser.add_argument('--validate', dest="validate", default=False, action='store_true')
parser.add_argument('--api-version', dest="api_version", default=None, type=str)
parser.add_argument('--lang', dest="lang", default=None, type=str)

def handle(self, *args, **options):
if options['generator_class']:
Expand All @@ -41,7 +43,11 @@ def handle(self, *args, **options):
urlconf=options['urlconf'],
api_version=options['api_version'],
)
schema = generator.get_schema(request=None, public=True)
if options['lang']:
with translation.override(options['lang']):
schema = generator.get_schema(request=None, public=True)
else:
schema = generator.get_schema(request=None, public=True)

GENERATOR_STATS.emit_summary()

Expand Down
11 changes: 6 additions & 5 deletions drf_spectacular/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.core import exceptions as django_exceptions
from django.core import validators
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions, renderers, serializers
from rest_framework.fields import _UnvalidatedField, empty
from rest_framework.generics import GenericAPIView
Expand Down Expand Up @@ -818,7 +819,7 @@ def _get_response_bodies(self):

if is_serializer(response_serializers) or is_basic_type(response_serializers):
if self.method == 'DELETE':
return {'204': {'description': 'No response body'}}
return {'204': {'description': _('No response body')}}
return {'200': self._get_response_for_code(response_serializers)}
elif isinstance(response_serializers, dict):
# custom handling for overriding default return codes with @extend_schema
Expand All @@ -833,20 +834,20 @@ def _get_response_bodies(self):
f'defaulting to generic free-form object.'
)
schema = build_basic_type(OpenApiTypes.OBJECT)
schema['description'] = 'Unspecified response body'
schema['description'] = _('Unspecified response body')
return {'200': self._get_response_for_code(schema)}

def _get_response_for_code(self, serializer):
serializer = force_instance(serializer)

if not serializer:
return {'description': 'No response body'}
return {'description': _('No response body')}
elif isinstance(serializer, serializers.ListSerializer):
schema = self.resolve_serializer(serializer.child, 'response').ref
elif is_serializer(serializer):
component = self.resolve_serializer(serializer, 'response')
if not component.schema:
return {'description': 'No response body'}
return {'description': _('No response body')}
schema = component.ref
elif is_basic_type(serializer):
schema = build_basic_type(serializer)
Expand All @@ -860,7 +861,7 @@ def _get_response_for_code(self, serializer):
f'generic free-form object.'
)
schema = build_basic_type(OpenApiTypes.OBJECT)
schema['description'] = 'Unspecified response body'
schema['description'] = _('Unspecified response body')

if self._is_list_view(serializer) and not get_override(serializer, 'many') is False:
schema = build_array_type(schema)
Expand Down
12 changes: 12 additions & 0 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.urls.resolvers import _PATH_PARAMETER_COMPONENT_RE, get_resolver # type: ignore
from django.utils.functional import Promise
from django.utils.module_loading import import_string
from rest_framework import exceptions, fields, mixins, serializers, versioning
from uritemplate import URITemplate
Expand Down Expand Up @@ -702,3 +703,14 @@ def modify_for_versioning(patterns, method, path, view, requested_version):
mocked_request.resolver_match = get_resolver(tuple(patterns)).resolve(path)

return path


def normalize_result_object(result):
""" resolve non-serializable objects like lazy translation strings and OrderedDict """
if isinstance(result, dict) or isinstance(result, OrderedDict):
return {k: normalize_result_object(v) for k, v in result.items()}
if isinstance(result, list) or isinstance(result, tuple):
return [normalize_result_object(v) for v in result]
if isinstance(result, Promise):
return str(result)
return result
8 changes: 1 addition & 7 deletions drf_spectacular/renderers.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
from collections import OrderedDict

import yaml
from rest_framework.exceptions import ErrorDetail
from rest_framework.renderers import BaseRenderer, JSONRenderer


class OpenApiYamlRenderer(BaseRenderer):
media_type = 'application/vnd.oai.openapi'
charset = None
format = 'openapi'

def render(self, data, media_type=None, renderer_context=None):
Expand All @@ -26,14 +23,11 @@ def multiline_str_representer(dumper, data):
return scalar
Dumper.add_representer(str, multiline_str_representer)

def ordered_dict_representer(dumper, data):
return dumper.represent_dict(data)
Dumper.add_representer(OrderedDict, ordered_dict_representer)

return yaml.dump(
data,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
Dumper=Dumper
).encode('utf-8')

Expand Down
2 changes: 1 addition & 1 deletion drf_spectacular/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def get_description(self):

def get_summary(self):
if summary and is_in_scope(self):
return summary
return str(summary)
return super().get_summary()

def is_deprecated(self):
Expand Down
36 changes: 29 additions & 7 deletions drf_spectacular/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from collections import namedtuple
from typing import Any, Dict

from django.conf import settings
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.response import Response
from rest_framework.reverse import reverse
Expand All @@ -11,21 +14,28 @@
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema

if spectacular_settings.SERVE_INCLUDE_SCHEMA:
SCHEMA_KWARGS: Dict[str, Any] = {'responses': {200: OpenApiTypes.OBJECT}}

if settings.USE_I18N:
SCHEMA_KWARGS['parameters'] = [
OpenApiParameter(
'lang', str, OpenApiParameter.QUERY, enum=list(dict(settings.LANGUAGES).keys())
)
]
else:
SCHEMA_KWARGS = {'exclude': True}


class SpectacularAPIView(APIView):
"""
__doc__ = _("""
OpenApi3 schema for this API. Format can be selected via content negotiation.
- YAML: application/vnd.oai.openapi
- JSON: application/vnd.oai.openapi+json
"""
""")
renderer_classes = [
OpenApiYamlRenderer, OpenApiYamlRenderer2, OpenApiJsonRenderer, OpenApiJsonRenderer2
]
Expand All @@ -42,9 +52,15 @@ def get(self, request, *args, **kwargs):
ModuleWrapper = namedtuple('ModuleWrapper', ['urlpatterns'])
self.urlconf = ModuleWrapper(tuple(self.urlconf))

if settings.USE_I18N and request.GET.get('lang'):
with translation.override(request.GET.get('lang')):
return self._get_schema_response(request)
else:
return self._get_schema_response(request)

def _get_schema_response(self, request):
generator = self.generator_class(urlconf=self.urlconf, api_version=self.api_version)
schema = generator.get_schema(request=request, public=self.serve_public)
return Response(schema)
return Response(generator.get_schema(request=request, public=self.serve_public))


class SpectacularYAMLAPIView(SpectacularAPIView):
Expand All @@ -64,8 +80,11 @@ class SpectacularSwaggerView(APIView):

@extend_schema(exclude=True)
def get(self, request, *args, **kwargs):
schema_url = self.url or reverse(self.url_name, request=request)
if request.GET.get('lang'):
schema_url += f'{"&" if "?" in schema_url else "?"}lang={request.GET.get("lang")}'
return Response(
{'schema_url': self.url or reverse(self.url_name, request=request)},
{'schema_url': schema_url},
template_name=self.template_name
)

Expand All @@ -79,7 +98,10 @@ class SpectacularRedocView(APIView):

@extend_schema(exclude=True)
def get(self, request, *args, **kwargs):
schema_url = self.url or reverse(self.url_name, request=request)
if request.GET.get('lang'):
schema_url += f'{"&" if "?" in schema_url else "?"}lang={request.GET.get("lang")}'
return Response(
{'schema_url': self.url or reverse(self.url_name, request=request)},
{'schema_url': schema_url},
template_name=self.template_name
)
17 changes: 13 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from importlib import import_module

import django
Expand All @@ -20,6 +21,8 @@ def pytest_configure(config):
# 'polymorphic',
]

base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

settings.configure(
DEBUG_PROPAGATE_EXCEPTIONS=True,
DATABASES={'default': {
Expand All @@ -30,6 +33,13 @@ def pytest_configure(config):
SECRET_KEY='not very secret in tests',
USE_I18N=True,
USE_L10N=True,
LANGUAGES=[
('de-de', 'German'),
('en-us', 'English'),
],
LOCALE_PATHS=[
base_dir + '/locale/'
],
STATIC_URL='/static/',
ROOT_URLCONF='tests.urls',
TEMPLATES=[
Expand All @@ -44,12 +54,11 @@ def pytest_configure(config):
},
},
],
MIDDLEWARE_CLASSES=(
'django.middleware.common.CommonMiddleware',
MIDDLEWARE=(
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.locale.LocaleMiddleware',
),
INSTALLED_APPS=(
'django.contrib.auth',
Expand Down
Binary file added tests/locale/de/LC_MESSAGES/django.mo
Binary file not shown.
42 changes: 42 additions & 0 deletions tests/locale/de/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-07-11 13:28+0200\n"
"PO-Revision-Date: 2020-07-04 14:39+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.3\n"

msgid ""
"\n"
" More lengthy explanation of the view\n"
" "
msgstr ""
"\n"
" Eine laengere Erklaerung des Views"

msgid "Main endpoint for creating X"
msgstr "Hauptendpunkt fuer die Erstellung von X"

msgid "No response body"
msgstr "Kein Inhalt"

msgid "Unspecified response body"
msgstr "Unspezifizierte Antwort"

msgid "Internationalization"
msgstr "Internätiönalisierung"

msgid "Internationalizations"
msgstr "Internätiönalisierungen"
Empty file added tests/settings.py
Empty file.
Loading

0 comments on commit 7600cbe

Please sign in to comment.