mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-07-28 17:09:59 +03:00
added OpenAPI3 schemas factored out as components
- minimal subset of features from PR #7089 - adapted tests
This commit is contained in:
parent
27dbd6f580
commit
bb12651a52
|
@ -18,7 +18,20 @@ from .inspectors import ViewInspector
|
||||||
from .utils import get_pk_description, is_list_view
|
from .utils import get_pk_description, is_list_view
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.schemas = {}
|
||||||
|
|
||||||
|
def get_components(self):
|
||||||
|
return {
|
||||||
|
'schemas': self.schemas,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SchemaGenerator(BaseSchemaGenerator):
|
class SchemaGenerator(BaseSchemaGenerator):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.registry = ComponentRegistry()
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self):
|
||||||
# Title and version are required by openapi specification 3.x
|
# Title and version are required by openapi specification 3.x
|
||||||
|
@ -32,7 +45,7 @@ class SchemaGenerator(BaseSchemaGenerator):
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
def get_paths(self, request=None):
|
def parse(self, request=None):
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
paths, view_endpoints = self._get_paths_and_endpoints(request)
|
paths, view_endpoints = self._get_paths_and_endpoints(request)
|
||||||
|
@ -44,7 +57,10 @@ class SchemaGenerator(BaseSchemaGenerator):
|
||||||
for path, method, view in view_endpoints:
|
for path, method, view in view_endpoints:
|
||||||
if not self.has_view_permissions(path, method, view):
|
if not self.has_view_permissions(path, method, view):
|
||||||
continue
|
continue
|
||||||
operation = view.schema.get_operation(path, method)
|
# keep reference to schema as every access yields a fresh object (descriptor protocol)
|
||||||
|
schema = view.schema
|
||||||
|
schema.init(self.registry)
|
||||||
|
operation = schema.get_operation(path, method)
|
||||||
# Normalise path for any provided mount url.
|
# Normalise path for any provided mount url.
|
||||||
if path.startswith('/'):
|
if path.startswith('/'):
|
||||||
path = path[1:]
|
path = path[1:]
|
||||||
|
@ -61,7 +77,7 @@ class SchemaGenerator(BaseSchemaGenerator):
|
||||||
"""
|
"""
|
||||||
self._initialise_endpoints()
|
self._initialise_endpoints()
|
||||||
|
|
||||||
paths = self.get_paths(None if public else request)
|
paths = self.parse(None if public else request)
|
||||||
if not paths:
|
if not paths:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -69,6 +85,7 @@ class SchemaGenerator(BaseSchemaGenerator):
|
||||||
'openapi': '3.0.2',
|
'openapi': '3.0.2',
|
||||||
'info': self.get_info(),
|
'info': self.get_info(),
|
||||||
'paths': paths,
|
'paths': paths,
|
||||||
|
'components': self.registry.get_components(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
@ -89,6 +106,9 @@ class AutoSchema(ViewInspector):
|
||||||
'delete': 'Destroy',
|
'delete': 'Destroy',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def init(self, registry):
|
||||||
|
self.registry = registry
|
||||||
|
|
||||||
def get_operation(self, path, method):
|
def get_operation(self, path, method):
|
||||||
operation = {}
|
operation = {}
|
||||||
|
|
||||||
|
@ -104,10 +124,18 @@ class AutoSchema(ViewInspector):
|
||||||
request_body = self._get_request_body(path, method)
|
request_body = self._get_request_body(path, method)
|
||||||
if request_body:
|
if request_body:
|
||||||
operation['requestBody'] = request_body
|
operation['requestBody'] = request_body
|
||||||
operation['responses'] = self._get_responses(path, method)
|
operation['responses'] = self._get_response_bodies(path, method)
|
||||||
|
|
||||||
return operation
|
return operation
|
||||||
|
|
||||||
|
def get_request_serializer(self, path, method):
|
||||||
|
""" override this for custom behaviour """
|
||||||
|
return self._get_serializer(path, method)
|
||||||
|
|
||||||
|
def get_response_serializer(self, path, method):
|
||||||
|
""" override this for custom behaviour """
|
||||||
|
return self._get_serializer(path, method)
|
||||||
|
|
||||||
def _get_operation_id(self, path, method):
|
def _get_operation_id(self, path, method):
|
||||||
"""
|
"""
|
||||||
Compute an operation ID from the model, serializer or view name.
|
Compute an operation ID from the model, serializer or view name.
|
||||||
|
@ -218,16 +246,16 @@ class AutoSchema(ViewInspector):
|
||||||
|
|
||||||
return paginator.get_schema_operation_parameters(view)
|
return paginator.get_schema_operation_parameters(view)
|
||||||
|
|
||||||
def _map_field(self, field):
|
def _map_field(self, method, field):
|
||||||
|
|
||||||
# Nested Serializers, `many` or not.
|
# Nested Serializers, `many` or not.
|
||||||
if isinstance(field, serializers.ListSerializer):
|
if isinstance(field, serializers.ListSerializer):
|
||||||
return {
|
return {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': self._map_serializer(field.child)
|
'items': self.resolve_serializer(method, field.child)
|
||||||
}
|
}
|
||||||
if isinstance(field, serializers.Serializer):
|
if isinstance(field, serializers.Serializer):
|
||||||
data = self._map_serializer(field)
|
data = self.resolve_serializer(method, field)
|
||||||
data['type'] = 'object'
|
data['type'] = 'object'
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -268,7 +296,7 @@ class AutoSchema(ViewInspector):
|
||||||
'items': {},
|
'items': {},
|
||||||
}
|
}
|
||||||
if not isinstance(field.child, _UnvalidatedField):
|
if not isinstance(field.child, _UnvalidatedField):
|
||||||
map_field = self._map_field(field.child)
|
map_field = self._map_field(method, field.child)
|
||||||
items = {
|
items = {
|
||||||
"type": map_field.get('type')
|
"type": map_field.get('type')
|
||||||
}
|
}
|
||||||
|
@ -370,7 +398,7 @@ class AutoSchema(ViewInspector):
|
||||||
if field.min_value:
|
if field.min_value:
|
||||||
content['minimum'] = field.min_value
|
content['minimum'] = field.min_value
|
||||||
|
|
||||||
def _map_serializer(self, serializer):
|
def _map_serializer(self, method, serializer):
|
||||||
# Assuming we have a valid serializer instance.
|
# Assuming we have a valid serializer instance.
|
||||||
# TODO:
|
# TODO:
|
||||||
# - field is Nested or List serializer.
|
# - field is Nested or List serializer.
|
||||||
|
@ -386,7 +414,7 @@ class AutoSchema(ViewInspector):
|
||||||
if field.required:
|
if field.required:
|
||||||
required.append(field.field_name)
|
required.append(field.field_name)
|
||||||
|
|
||||||
schema = self._map_field(field)
|
schema = self._map_field(method, field)
|
||||||
if field.read_only:
|
if field.read_only:
|
||||||
schema['readOnly'] = True
|
schema['readOnly'] = True
|
||||||
if field.write_only:
|
if field.write_only:
|
||||||
|
@ -404,7 +432,7 @@ class AutoSchema(ViewInspector):
|
||||||
result = {
|
result = {
|
||||||
'properties': properties
|
'properties': properties
|
||||||
}
|
}
|
||||||
if required:
|
if required and method != 'PATCH':
|
||||||
result['required'] = required
|
result['required'] = required
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -485,70 +513,98 @@ class AutoSchema(ViewInspector):
|
||||||
|
|
||||||
self.request_media_types = self.map_parsers(path, method)
|
self.request_media_types = self.map_parsers(path, method)
|
||||||
|
|
||||||
serializer = self._get_serializer(path, method)
|
serializer = self.get_request_serializer(path, method)
|
||||||
|
|
||||||
if not isinstance(serializer, serializers.Serializer):
|
if isinstance(serializer, serializers.Serializer):
|
||||||
|
schema = self.resolve_serializer(method, serializer)
|
||||||
|
else:
|
||||||
|
schema = {
|
||||||
|
'type': 'object',
|
||||||
|
'additionalProperties': {}, # https://github.com/swagger-api/swagger-codegen/issues/1318
|
||||||
|
'description': 'Unspecified request body',
|
||||||
|
}
|
||||||
|
|
||||||
|
# serializer has no fields so skip content enumeration
|
||||||
|
if not schema:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
content = self._map_serializer(serializer)
|
|
||||||
# No required fields for PATCH
|
|
||||||
if method == 'PATCH':
|
|
||||||
content.pop('required', None)
|
|
||||||
# No read_only fields for request.
|
|
||||||
for name, schema in content['properties'].copy().items():
|
|
||||||
if 'readOnly' in schema:
|
|
||||||
del content['properties'][name]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'content': {
|
'content': {
|
||||||
ct: {'schema': content}
|
mt: {'schema': schema} for mt in self.request_media_types
|
||||||
for ct in self.request_media_types
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_responses(self, path, method):
|
def _get_response_bodies(self, path, method):
|
||||||
# TODO: Handle multiple codes and pagination classes.
|
serializer = self.get_response_serializer(path, method)
|
||||||
if method == 'DELETE':
|
|
||||||
return {
|
|
||||||
'204': {
|
|
||||||
'description': ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.response_media_types = self.map_renderers(path, method)
|
|
||||||
|
|
||||||
item_schema = {}
|
|
||||||
serializer = self._get_serializer(path, method)
|
|
||||||
|
|
||||||
if isinstance(serializer, serializers.Serializer):
|
if isinstance(serializer, serializers.Serializer):
|
||||||
item_schema = self._map_serializer(serializer)
|
if method == 'DELETE':
|
||||||
# No write_only fields for response.
|
return {'204': {'description': 'No response body'}}
|
||||||
for name, schema in item_schema['properties'].copy().items():
|
return {'200': self._get_response_for_code(path, method, serializer)}
|
||||||
if 'writeOnly' in schema:
|
else:
|
||||||
del item_schema['properties'][name]
|
schema = {
|
||||||
if 'required' in item_schema:
|
'type': 'object',
|
||||||
item_schema['required'] = [f for f in item_schema['required'] if f != name]
|
'description': 'Unspecified response body',
|
||||||
|
}
|
||||||
|
return {'200': self._get_response_for_code(path, method, schema)}
|
||||||
|
|
||||||
|
def _get_response_for_code(self, path, method, serializer):
|
||||||
|
# TODO: Handle multiple codes and pagination classes.
|
||||||
|
if not serializer:
|
||||||
|
return {'description': 'No response body'}
|
||||||
|
elif isinstance(serializer, serializers.Serializer):
|
||||||
|
schema = self.resolve_serializer(method, serializer)
|
||||||
|
if not schema:
|
||||||
|
return {'description': 'No response body'}
|
||||||
|
elif isinstance(serializer, dict):
|
||||||
|
# bypass processing and use given schema directly
|
||||||
|
schema = serializer
|
||||||
|
else:
|
||||||
|
raise ValueError('Serializer type unsupported')
|
||||||
|
|
||||||
if is_list_view(path, method, self.view):
|
if is_list_view(path, method, self.view):
|
||||||
response_schema = {
|
schema = {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': item_schema,
|
'items': schema,
|
||||||
}
|
}
|
||||||
paginator = self._get_paginator()
|
paginator = self._get_paginator()
|
||||||
if paginator:
|
if paginator:
|
||||||
response_schema = paginator.get_paginated_response_schema(response_schema)
|
schema = paginator.get_paginated_response_schema(schema)
|
||||||
else:
|
|
||||||
response_schema = item_schema
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'200': {
|
|
||||||
'content': {
|
'content': {
|
||||||
ct: {'schema': response_schema}
|
mt: {'schema': schema} for mt in self.map_renderers(path, method)
|
||||||
for ct in self.response_media_types
|
|
||||||
},
|
},
|
||||||
# description is a mandatory property,
|
# description is a mandatory property,
|
||||||
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#responseObject
|
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#responseObject
|
||||||
# TODO: put something meaningful into it
|
# TODO: put something meaningful into it
|
||||||
'description': ""
|
'description': ""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
def _get_serializer_name(self, method, serializer):
|
||||||
|
name = serializer.__class__.__name__
|
||||||
|
|
||||||
|
if name.endswith('Serializer'):
|
||||||
|
name = name[:-10]
|
||||||
|
if method == 'PATCH' and not serializer.read_only:
|
||||||
|
name = 'Patched' + name
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def resolve_serializer(self, method, serializer):
|
||||||
|
name = self._get_serializer_name(method, serializer)
|
||||||
|
|
||||||
|
if name not in self.registry.schemas:
|
||||||
|
# add placeholder to prevent recursion loop
|
||||||
|
self.registry.schemas[name] = None
|
||||||
|
|
||||||
|
mapped = self._map_serializer(method, serializer)
|
||||||
|
# empty serializer - usually a transactional serializer.
|
||||||
|
# no need to put it explicitly in the spec
|
||||||
|
if not mapped['properties']:
|
||||||
|
del self.registry.schemas[name]
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
self.registry.schemas[name] = mapped
|
||||||
|
|
||||||
|
return {'$ref': '#/components/schemas/{}'.format(name)}
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf.urls import url
|
from django.conf.urls import include, url
|
||||||
|
from django.db import models
|
||||||
from django.test import RequestFactory, TestCase, override_settings
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import filters, generics, pagination, routers, serializers
|
from rest_framework import (
|
||||||
|
filters, generics, pagination, routers, serializers, viewsets
|
||||||
|
)
|
||||||
from rest_framework.compat import uritemplate
|
from rest_framework.compat import uritemplate
|
||||||
from rest_framework.parsers import JSONParser, MultiPartParser
|
from rest_framework.parsers import JSONParser, MultiPartParser
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator
|
from rest_framework.schemas.openapi import (
|
||||||
|
AutoSchema, ComponentRegistry, SchemaGenerator
|
||||||
|
)
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
@ -57,7 +62,7 @@ class TestFieldMapping(TestCase):
|
||||||
]
|
]
|
||||||
for field, mapping in cases:
|
for field, mapping in cases:
|
||||||
with self.subTest(field=field):
|
with self.subTest(field=field):
|
||||||
assert inspector._map_field(field) == mapping
|
assert inspector._map_field('GET', field) == mapping
|
||||||
|
|
||||||
def test_lazy_string_field(self):
|
def test_lazy_string_field(self):
|
||||||
class Serializer(serializers.Serializer):
|
class Serializer(serializers.Serializer):
|
||||||
|
@ -65,7 +70,7 @@ class TestFieldMapping(TestCase):
|
||||||
|
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
|
|
||||||
data = inspector._map_serializer(Serializer())
|
data = inspector._map_serializer('GET', Serializer())
|
||||||
assert isinstance(data['properties']['text']['description'], str), "description must be str"
|
assert isinstance(data['properties']['text']['description'], str), "description must be str"
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,6 +88,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
|
|
||||||
operation = inspector.get_operation(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert operation == {
|
assert operation == {
|
||||||
|
@ -91,18 +97,21 @@ class TestOperationIntrospection(TestCase):
|
||||||
'parameters': [],
|
'parameters': [],
|
||||||
'responses': {
|
'responses': {
|
||||||
'200': {
|
'200': {
|
||||||
'description': '',
|
|
||||||
'content': {
|
'content': {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
'schema': {
|
'schema': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {},
|
'items': {
|
||||||
},
|
'type': 'object',
|
||||||
},
|
'description': 'Unspecified response body'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'description': ''
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def test_path_with_id_parameter(self):
|
def test_path_with_id_parameter(self):
|
||||||
path = '/example/{id}/'
|
path = '/example/{id}/'
|
||||||
|
@ -114,131 +123,154 @@ class TestOperationIntrospection(TestCase):
|
||||||
create_request(path)
|
create_request(path)
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
|
||||||
operation = inspector.get_operation(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert operation == {
|
assert operation == {
|
||||||
'operationId': 'RetrieveDocStringExampleDetail',
|
'operationId': 'RetrieveDocStringExampleDetail',
|
||||||
'description': 'A description of my GET operation.',
|
'description': 'A description of my GET operation.',
|
||||||
'parameters': [{
|
'parameters': [
|
||||||
'description': '',
|
{
|
||||||
'in': 'path',
|
|
||||||
'name': 'id',
|
'name': 'id',
|
||||||
|
'in': 'path',
|
||||||
'required': True,
|
'required': True,
|
||||||
|
'description': '',
|
||||||
'schema': {
|
'schema': {
|
||||||
'type': 'string',
|
'type': 'string'
|
||||||
},
|
}
|
||||||
}],
|
}
|
||||||
|
],
|
||||||
'responses': {
|
'responses': {
|
||||||
'200': {
|
'200': {
|
||||||
'description': '',
|
|
||||||
'content': {
|
'content': {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
'schema': {
|
'schema': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Unspecified response body'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
'description': ''
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_request_body(self):
|
def test_request_body(self):
|
||||||
path = '/'
|
path = '/'
|
||||||
method = 'POST'
|
method = 'POST'
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
read_only = serializers.CharField(read_only=True)
|
read_only = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.CreateAPIView):
|
||||||
serializer_class = Serializer
|
serializer_class = ExampleSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
View,
|
View,
|
||||||
method,
|
method,
|
||||||
create_request(path)
|
create_request(path)
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
request_body = inspector._get_request_body(path, method)
|
schema = registry.schemas['Example']
|
||||||
assert request_body['content']['application/json']['schema']['required'] == ['text']
|
assert schema['required'] == ['text']
|
||||||
assert list(request_body['content']['application/json']['schema']['properties'].keys()) == ['text']
|
assert schema['properties']['read_only']['readOnly'] is True
|
||||||
|
|
||||||
def test_empty_required(self):
|
def test_empty_required(self):
|
||||||
path = '/'
|
path = '/'
|
||||||
method = 'POST'
|
method = 'POST'
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
read_only = serializers.CharField(read_only=True)
|
read_only = serializers.CharField(read_only=True)
|
||||||
write_only = serializers.CharField(write_only=True, required=False)
|
write_only = serializers.CharField(write_only=True, required=False)
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.CreateAPIView):
|
||||||
serializer_class = Serializer
|
serializer_class = ExampleSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
View,
|
View,
|
||||||
method,
|
method,
|
||||||
create_request(path)
|
create_request(path)
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
request_body = inspector._get_request_body(path, method)
|
schema = registry.schemas['Example']
|
||||||
# there should be no empty 'required' property, see #6834
|
# there should be no empty 'required' property, see #6834
|
||||||
assert 'required' not in request_body['content']['application/json']['schema']
|
assert 'required' not in schema
|
||||||
|
|
||||||
for response in inspector._get_responses(path, method).values():
|
|
||||||
assert 'required' not in response['content']['application/json']['schema']
|
|
||||||
|
|
||||||
def test_empty_required_with_patch_method(self):
|
def test_empty_required_with_patch_method(self):
|
||||||
path = '/'
|
path = '/'
|
||||||
method = 'PATCH'
|
method = 'PATCH'
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
read_only = serializers.CharField(read_only=True)
|
read_only = serializers.CharField(read_only=True)
|
||||||
write_only = serializers.CharField(write_only=True, required=False)
|
write_only = serializers.CharField(write_only=True, required=False)
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.UpdateAPIView):
|
||||||
serializer_class = Serializer
|
serializer_class = ExampleSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
View,
|
View,
|
||||||
method,
|
method,
|
||||||
create_request(path)
|
create_request(path)
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
request_body = inspector._get_request_body(path, method)
|
schema = registry.schemas['PatchedExample']
|
||||||
# there should be no empty 'required' property, see #6834
|
# there should be no empty 'required' property, see #6834
|
||||||
assert 'required' not in request_body['content']['application/json']['schema']
|
assert 'required' not in schema
|
||||||
for response in inspector._get_responses(path, method).values():
|
for field_schema in schema['properties']:
|
||||||
assert 'required' not in response['content']['application/json']['schema']
|
assert 'required' not in field_schema
|
||||||
|
|
||||||
def test_response_body_generation(self):
|
def test_response_body_generation(self):
|
||||||
path = '/'
|
path = '/'
|
||||||
method = 'POST'
|
method = 'POST'
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
write_only = serializers.CharField(write_only=True)
|
write_only = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.CreateAPIView):
|
||||||
serializer_class = Serializer
|
serializer_class = ExampleSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
View,
|
View,
|
||||||
method,
|
method,
|
||||||
create_request(path)
|
create_request(path)
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert responses['200']['content']['application/json']['schema']['required'] == ['text']
|
|
||||||
assert list(responses['200']['content']['application/json']['schema']['properties'].keys()) == ['text']
|
assert operation['responses'] == {
|
||||||
assert 'description' in responses['200']
|
'200': {
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': {'$ref': '#/components/schemas/Example'}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'description': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert sorted(registry.schemas['Example']['required']) == ['text', 'write_only']
|
||||||
|
assert sorted(registry.schemas['Example']['properties'].keys()) == ['text', 'write_only']
|
||||||
|
|
||||||
def test_response_body_nested_serializer(self):
|
def test_response_body_nested_serializer(self):
|
||||||
path = '/'
|
path = '/'
|
||||||
|
@ -247,28 +279,31 @@ class TestOperationIntrospection(TestCase):
|
||||||
class NestedSerializer(serializers.Serializer):
|
class NestedSerializer(serializers.Serializer):
|
||||||
number = serializers.IntegerField()
|
number = serializers.IntegerField()
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
nested = NestedSerializer()
|
nested = NestedSerializer()
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.CreateAPIView):
|
||||||
serializer_class = Serializer
|
serializer_class = ExampleSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
View,
|
View,
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
example_schema = registry.schemas['Example']
|
||||||
|
nested_schema = registry.schemas['Nested']
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
assert sorted(example_schema['required']) == ['nested', 'text']
|
||||||
schema = responses['200']['content']['application/json']['schema']
|
assert sorted(example_schema['properties'].keys()) == ['nested', 'text']
|
||||||
assert sorted(schema['required']) == ['nested', 'text']
|
assert example_schema['properties']['nested']['type'] == 'object'
|
||||||
assert sorted(list(schema['properties'].keys())) == ['nested', 'text']
|
assert sorted(nested_schema['properties'].keys()) == ['number']
|
||||||
assert schema['properties']['nested']['type'] == 'object'
|
assert nested_schema['required'] == ['number']
|
||||||
assert list(schema['properties']['nested']['properties'].keys()) == ['number']
|
|
||||||
assert schema['properties']['nested']['required'] == ['number']
|
|
||||||
|
|
||||||
def test_list_response_body_generation(self):
|
def test_list_response_body_generation(self):
|
||||||
"""Test that an array schema is returned for list views."""
|
"""Test that an array schema is returned for list views."""
|
||||||
|
@ -278,7 +313,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
class ItemSerializer(serializers.Serializer):
|
class ItemSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.ListAPIView):
|
||||||
serializer_class = ItemSerializer
|
serializer_class = ItemSerializer
|
||||||
|
|
||||||
view = create_view(
|
view = create_view(
|
||||||
|
@ -286,29 +321,25 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert responses == {
|
|
||||||
|
assert operation['responses'] == {
|
||||||
'200': {
|
'200': {
|
||||||
'description': '',
|
|
||||||
'content': {
|
'content': {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
'schema': {
|
'schema': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {
|
'items': {'$ref': '#/components/schemas/Item'},
|
||||||
'properties': {
|
}
|
||||||
'text': {
|
}
|
||||||
'type': 'string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'required': ['text'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
'description': ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_paginated_list_response_body_generation(self):
|
def test_paginated_list_response_body_generation(self):
|
||||||
|
@ -326,7 +357,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
class ItemSerializer(serializers.Serializer):
|
class ItemSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.ListAPIView):
|
||||||
serializer_class = ItemSerializer
|
serializer_class = ItemSerializer
|
||||||
pagination_class = Pagination
|
pagination_class = Pagination
|
||||||
|
|
||||||
|
@ -337,9 +368,10 @@ class TestOperationIntrospection(TestCase):
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert responses == {
|
assert operation['responses'] == {
|
||||||
'200': {
|
'200': {
|
||||||
'description': '',
|
'description': '',
|
||||||
'content': {
|
'content': {
|
||||||
|
@ -348,14 +380,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'item': {
|
'item': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {
|
'items': {'$ref': '#/components/schemas/Item'},
|
||||||
'properties': {
|
|
||||||
'text': {
|
|
||||||
'type': 'string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'required': ['text'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -378,11 +403,12 @@ class TestOperationIntrospection(TestCase):
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert responses == {
|
assert operation['responses'] == {
|
||||||
'204': {
|
'204': {
|
||||||
'description': '',
|
'description': 'No response body',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -402,19 +428,20 @@ class TestOperationIntrospection(TestCase):
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
|
|
||||||
request_body = inspector._get_request_body(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
|
content = operation['requestBody']['content']
|
||||||
assert len(request_body['content'].keys()) == 2
|
assert len(content.keys()) == 2
|
||||||
assert 'multipart/form-data' in request_body['content']
|
assert 'multipart/form-data' in content
|
||||||
assert 'application/json' in request_body['content']
|
assert 'application/json' in content
|
||||||
|
|
||||||
def test_renderer_mapping(self):
|
def test_renderer_mapping(self):
|
||||||
"""Test that view's renderers are mapped to OA media types"""
|
"""Test that view's renderers are mapped to OA media types"""
|
||||||
path = '/{id}/'
|
path = '/{id}/'
|
||||||
method = 'GET'
|
method = 'GET'
|
||||||
|
|
||||||
class View(generics.CreateAPIView):
|
class View(generics.ListCreateAPIView):
|
||||||
serializer_class = views.ExampleSerializer
|
serializer_class = views.ExampleSerializer
|
||||||
renderer_classes = [JSONRenderer]
|
renderer_classes = [JSONRenderer]
|
||||||
|
|
||||||
|
@ -423,13 +450,15 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
# TODO this should be changed once the multiple response
|
# TODO this should be changed once the multiple response
|
||||||
# schema support is there
|
# schema support is there
|
||||||
success_response = responses['200']
|
success_response = operation['responses']['200']
|
||||||
|
|
||||||
assert len(success_response['content'].keys()) == 1
|
assert len(success_response['content'].keys()) == 1
|
||||||
assert 'application/json' in success_response['content']
|
assert 'application/json' in success_response['content']
|
||||||
|
@ -449,13 +478,15 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
|
||||||
request_body = inspector._get_request_body(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
mp_media = request_body['content']['multipart/form-data']
|
|
||||||
attachment = mp_media['schema']['properties']['attachment']
|
assert 'multipart/form-data' in operation['requestBody']['content']
|
||||||
assert attachment['format'] == 'binary'
|
assert registry.schemas['Item']['properties']['attachment']['format'] == 'binary'
|
||||||
|
|
||||||
def test_retrieve_response_body_generation(self):
|
def test_retrieve_response_body_generation(self):
|
||||||
"""
|
"""
|
||||||
|
@ -476,7 +507,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
class ItemSerializer(serializers.Serializer):
|
class ItemSerializer(serializers.Serializer):
|
||||||
text = serializers.CharField()
|
text = serializers.CharField()
|
||||||
|
|
||||||
class View(generics.GenericAPIView):
|
class View(generics.RetrieveAPIView):
|
||||||
serializer_class = ItemSerializer
|
serializer_class = ItemSerializer
|
||||||
pagination_class = Pagination
|
pagination_class = Pagination
|
||||||
|
|
||||||
|
@ -485,26 +516,30 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
operation = inspector.get_operation(path, method)
|
||||||
assert responses == {
|
|
||||||
|
assert operation['responses'] == {
|
||||||
'200': {
|
'200': {
|
||||||
'description': '',
|
|
||||||
'content': {
|
'content': {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
'schema': {
|
'schema': {'$ref': '#/components/schemas/Item'}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'description': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert registry.schemas['Item'] == {
|
||||||
'properties': {
|
'properties': {
|
||||||
'text': {
|
'text': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'required': ['text'],
|
'required': ['text'],
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_operation_id_generation(self):
|
def test_operation_id_generation(self):
|
||||||
|
@ -518,6 +553,7 @@ class TestOperationIntrospection(TestCase):
|
||||||
)
|
)
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(ComponentRegistry())
|
||||||
|
|
||||||
operationId = inspector._get_operation_id(path, method)
|
operationId = inspector._get_operation_id(path, method)
|
||||||
assert operationId == 'listExamples'
|
assert operationId == 'listExamples'
|
||||||
|
@ -532,7 +568,6 @@ class TestOperationIntrospection(TestCase):
|
||||||
request = create_request('/')
|
request = create_request('/')
|
||||||
schema = generator.get_schema(request=request)
|
schema = generator.get_schema(request=request)
|
||||||
schema_str = str(schema)
|
schema_str = str(schema)
|
||||||
print(schema_str)
|
|
||||||
assert schema_str.count("operationId") == 2
|
assert schema_str.count("operationId") == 2
|
||||||
assert schema_str.count("newExample") == 1
|
assert schema_str.count("newExample") == 1
|
||||||
assert schema_str.count("oldExample") == 1
|
assert schema_str.count("oldExample") == 1
|
||||||
|
@ -545,12 +580,13 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
properties = registry.schemas['Example']['properties']
|
||||||
response_schema = responses['200']['content']['application/json']['schema']
|
|
||||||
properties = response_schema['items']['properties']
|
|
||||||
assert properties['date']['type'] == properties['datetime']['type'] == 'string'
|
assert properties['date']['type'] == properties['datetime']['type'] == 'string'
|
||||||
assert properties['date']['format'] == 'date'
|
assert properties['date']['format'] == 'date'
|
||||||
assert properties['datetime']['format'] == 'date-time'
|
assert properties['datetime']['format'] == 'date-time'
|
||||||
|
@ -563,12 +599,13 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
properties = registry.schemas['Example']['properties']
|
||||||
response_schema = responses['200']['content']['application/json']['schema']
|
|
||||||
properties = response_schema['items']['properties']
|
|
||||||
assert properties['hstore']['type'] == 'object'
|
assert properties['hstore']['type'] == 'object'
|
||||||
|
|
||||||
def test_serializer_callable_default(self):
|
def test_serializer_callable_default(self):
|
||||||
|
@ -595,12 +632,13 @@ class TestOperationIntrospection(TestCase):
|
||||||
method,
|
method,
|
||||||
create_request(path),
|
create_request(path),
|
||||||
)
|
)
|
||||||
|
registry = ComponentRegistry()
|
||||||
inspector = AutoSchema()
|
inspector = AutoSchema()
|
||||||
inspector.view = view
|
inspector.view = view
|
||||||
|
inspector.init(registry)
|
||||||
|
inspector.get_operation(path, method)
|
||||||
|
|
||||||
responses = inspector._get_responses(path, method)
|
properties = registry.schemas['ExampleValidated']['properties']
|
||||||
response_schema = responses['200']['content']['application/json']['schema']
|
|
||||||
properties = response_schema['items']['properties']
|
|
||||||
|
|
||||||
assert properties['integer']['type'] == 'integer'
|
assert properties['integer']['type'] == 'integer'
|
||||||
assert properties['integer']['maximum'] == 99
|
assert properties['integer']['maximum'] == 99
|
||||||
|
@ -643,6 +681,34 @@ class TestOperationIntrospection(TestCase):
|
||||||
assert properties['ip']['type'] == 'string'
|
assert properties['ip']['type'] == 'string'
|
||||||
assert 'format' not in properties['ip']
|
assert 'format' not in properties['ip']
|
||||||
|
|
||||||
|
def test_modelviewset(self):
|
||||||
|
class ExampleModel(models.Model):
|
||||||
|
text = models.TextField()
|
||||||
|
|
||||||
|
class ExampleSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ExampleModel
|
||||||
|
fields = ['id', 'text']
|
||||||
|
|
||||||
|
class ExampleViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = ExampleSerializer
|
||||||
|
queryset = ExampleModel.objects.none()
|
||||||
|
|
||||||
|
router = routers.DefaultRouter()
|
||||||
|
router.register(r'example', ExampleViewSet)
|
||||||
|
|
||||||
|
generator = SchemaGenerator(patterns=[
|
||||||
|
url(r'api/', include(router.urls))
|
||||||
|
])
|
||||||
|
generator._initialise_endpoints()
|
||||||
|
|
||||||
|
schema = generator.get_schema(request=None, public=True)
|
||||||
|
|
||||||
|
assert sorted(schema['paths']['/api/example/'].keys()) == ['get', 'post']
|
||||||
|
assert sorted(schema['paths']['/api/example/{id}/'].keys()) == ['delete', 'get', 'patch', 'put']
|
||||||
|
assert sorted(schema['components']['schemas'].keys()) == ['Example', 'PatchedExample']
|
||||||
|
# TODO do more checks
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
|
@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
|
||||||
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})
|
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})
|
||||||
|
@ -659,7 +725,7 @@ class TestGenerator(TestCase):
|
||||||
generator = SchemaGenerator(patterns=patterns)
|
generator = SchemaGenerator(patterns=patterns)
|
||||||
generator._initialise_endpoints()
|
generator._initialise_endpoints()
|
||||||
|
|
||||||
paths = generator.get_paths()
|
paths = generator.parse()
|
||||||
|
|
||||||
assert '/example/' in paths
|
assert '/example/' in paths
|
||||||
example_operations = paths['/example/']
|
example_operations = paths['/example/']
|
||||||
|
@ -676,7 +742,7 @@ class TestGenerator(TestCase):
|
||||||
generator = SchemaGenerator(patterns=patterns)
|
generator = SchemaGenerator(patterns=patterns)
|
||||||
generator._initialise_endpoints()
|
generator._initialise_endpoints()
|
||||||
|
|
||||||
paths = generator.get_paths()
|
paths = generator.parse()
|
||||||
|
|
||||||
assert '/v1/example/' in paths
|
assert '/v1/example/' in paths
|
||||||
assert '/v1/example/{id}/' in paths
|
assert '/v1/example/{id}/' in paths
|
||||||
|
@ -689,7 +755,7 @@ class TestGenerator(TestCase):
|
||||||
generator = SchemaGenerator(patterns=patterns, url='/api')
|
generator = SchemaGenerator(patterns=patterns, url='/api')
|
||||||
generator._initialise_endpoints()
|
generator._initialise_endpoints()
|
||||||
|
|
||||||
paths = generator.get_paths()
|
paths = generator.parse()
|
||||||
|
|
||||||
assert '/api/example/' in paths
|
assert '/api/example/' in paths
|
||||||
assert '/api/example/{id}/' in paths
|
assert '/api/example/{id}/' in paths
|
||||||
|
|
Loading…
Reference in New Issue
Block a user