From 2357148b124fbf99cc25f74c141ac69c4a54b352 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Fri, 28 Feb 2020 18:04:45 +0100 Subject: [PATCH 1/6] allow custom name for operation_id --- docs/api-guide/schemas.md | 17 +++++++++++++++++ rest_framework/schemas/openapi.py | 15 +++++++++++++-- tests/schemas/test_openapi.py | 15 +++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 2e5ffc79b8..7f802d3105 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -288,8 +288,25 @@ class MyView(APIView): ... ``` +### OperationId + +The schema generator generates an [operationid](openapi-operationid) for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "ListItems", "RetrieveItem", "UpdateItem", etc.. + +If you have several views with the same model, the generator may generate duplicate operationId. +In order to work around this, you can override the second part of the operationId: operation name. + +```python +from rest_framework.schemas.openapi import AutoSchema + +class ExampleView(APIView): + """APIView subclass with custom schema introspection.""" + schema = AutoSchema(operation_name="Custom") +``` + +The previous example will generate the following operationid: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", `DestroyCustom`. [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject [openapi-tags]: https://swagger.io/specification/#tagObject +[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17 diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 5277f17a61..1680dcfe9e 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -88,6 +88,13 @@ def __init__(self, tags=None): 'delete': 'Destroy', } + def __init__(self, operation_name=None): + """ + :param operation_name: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + """ + super().__init__() + self.operation_name = operation_name + def get_operation(self, path, method): operation = {} @@ -120,9 +127,13 @@ def _get_operation_id(self, path, method): else: action = self.method_mapping[method.lower()] - # Try to deduce the ID from the view's model model = getattr(getattr(self.view, 'queryset', None), 'model', None) - if model is not None: + + if self.operation_name is not None: + name = self.operation_name + + # Try to deduce the ID from the view's model + elif model is not None: name = model.__name__ # Try with the serializer class name diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 7f73c8c300..c5f9d32a43 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -577,6 +577,21 @@ def test_operation_id_generation(self): operationId = inspector._get_operation_id(path, method) assert operationId == 'listExamples' + def test_operation_id_custom_name(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_name="Ulysse") + inspector.view = view + + operationId = inspector._get_operation_id(path, method) + assert operationId == 'listUlysses' + def test_repeat_operation_ids(self): router = routers.SimpleRouter() router.register('account', views.ExampleGenericViewSet, basename="account") From 2951c3a6d3e28dd63c5245d2992e9b26892b807c Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Thu, 13 Feb 2020 18:29:27 +0100 Subject: [PATCH 2/6] fix typo --- docs/api-guide/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 7f802d3105..bc364a0b3c 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -303,7 +303,7 @@ class ExampleView(APIView): schema = AutoSchema(operation_name="Custom") ``` -The previous example will generate the following operationid: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", `DestroyCustom`. +The previous example will generate the following operationid: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions From bb7f43b7b5c4dccc252502cff44b938e19fa029f Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Thu, 20 Feb 2020 15:42:26 +0100 Subject: [PATCH 3/6] Update accoriding to PR's requests * rename operation_name with operation_id_base * add get_operation_id_base * get_operation_id not private * update docs * update tests --- docs/api-guide/schemas.md | 19 ++++++++++- rest_framework/schemas/openapi.py | 40 +++++++++++++--------- tests/schemas/test_openapi.py | 57 +++++++++++++++++++++++++++++-- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index bc364a0b3c..55d49b2c25 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -300,11 +300,28 @@ from rest_framework.schemas.openapi import AutoSchema class ExampleView(APIView): """APIView subclass with custom schema introspection.""" - schema = AutoSchema(operation_name="Custom") + schema = AutoSchema(operation_id_base="Custom") ``` The previous example will generate the following operationid: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". +You need to provide the singular form of he operation name. For the list operation, a "s" will be append at the end of the name. + +If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class. + +```python +class CustomSchema(AutoSchema): + def get_operation_id_base(self, action): + pass + + def get_operation_id(self, path, method): + pass + +class CustomView(APIView): + """APIView subclass with custom schema introspection.""" + schema = CustomSchema() +``` + [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 1680dcfe9e..b00fceb676 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -88,17 +88,17 @@ def __init__(self, tags=None): 'delete': 'Destroy', } - def __init__(self, operation_name=None): + def __init__(self, operation_id_base=None): """ - :param operation_name: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. """ super().__init__() - self.operation_name = operation_name + self.operation_id_base = operation_id_base def get_operation(self, path, method): operation = {} - operation['operationId'] = self._get_operation_id(path, method) + operation['operationId'] = self.get_operation_id(path, method) operation['description'] = self.get_description(path, method) parameters = [] @@ -115,22 +115,14 @@ def get_operation(self, path, method): return operation - def _get_operation_id(self, path, method): + def get_operation_id_base(self, action): """ - Compute an operation ID from the model, serializer or view name. + Compute the base part for operation ID from the model, serializer or view name. """ - method_name = getattr(self.view, 'action', method.lower()) - if is_list_view(path, method, self.view): - action = 'list' - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] - model = getattr(getattr(self.view, 'queryset', None), 'model', None) - if self.operation_name is not None: - name = self.operation_name + if self.operation_id_base is not None: + name = self.operation_id_base # Try to deduce the ID from the view's model elif model is not None: @@ -158,6 +150,22 @@ def _get_operation_id(self, path, method): if action == 'list' and not name.endswith('s'): # listThings instead of listThing name += 's' + return name + + def get_operation_id(self, path, method): + """ + Compute an operation ID from the view type and get_operation_id_base method. + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'list' + elif method_name not in self.method_mapping: + action = method_name + else: + action = self.method_mapping[method.lower()] + + name = self.get_operation_id_base(action) + return action + name def _get_path_parameters(self, path, method): diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index c5f9d32a43..a764f6bfd3 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -574,9 +574,24 @@ def test_operation_id_generation(self): inspector = AutoSchema() inspector.view = view - operationId = inspector._get_operation_id(path, method) + operationId = inspector.get_operation_id(path, method) assert operationId == 'listExamples' + def test_operation_id_custom_operation_id_base(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_id_base="Ulysse") + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listUlysses' + def test_operation_id_custom_name(self): path = '/' method = 'GET' @@ -586,12 +601,48 @@ def test_operation_id_custom_name(self): method, create_request(path), ) - inspector = AutoSchema(operation_name="Ulysse") + inspector = AutoSchema(operation_id_base='Ulysse') inspector.view = view - operationId = inspector._get_operation_id(path, method) + operationId = inspector.get_operation_id(path, method) assert operationId == 'listUlysses' + def test_operation_id_override_get(self): + class CustomSchema(AutoSchema): + def get_operation_id(self, path, method): + return 'myCustomOperationId' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'myCustomOperationId' + + def test_operation_id_override_base(self): + class CustomSchema(AutoSchema): + def get_operation_id_base(self, action): + return 'Item' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listItem' + def test_repeat_operation_ids(self): router = routers.SimpleRouter() router.register('account', views.ExampleGenericViewSet, basename="account") From 6e072dbb45a102f325fd2de8a0b3ed16cd3688b2 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Thu, 20 Feb 2020 16:14:31 +0100 Subject: [PATCH 4/6] docs --- docs/api-guide/schemas.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 55d49b2c25..8dea709029 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -303,11 +303,10 @@ class ExampleView(APIView): schema = AutoSchema(operation_id_base="Custom") ``` -The previous example will generate the following operationid: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". +The previous example will generate the following operationId: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". +You need to provide the singular form of he operation name. For the list operation, a "s" will be appended at the end of the operation. -You need to provide the singular form of he operation name. For the list operation, a "s" will be append at the end of the name. - -If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class. +If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class: ```python class CustomSchema(AutoSchema): From 03d3406734e1531e2bceb730ff4e3859039051cd Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Thu, 20 Feb 2020 16:20:14 +0100 Subject: [PATCH 5/6] rebased on master --- docs/api-guide/schemas.md | 2 +- rest_framework/schemas/openapi.py | 4 ++-- tests/schemas/test_openapi.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 8dea709029..5766a6a61c 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -310,7 +310,7 @@ If you need more configuration over the `operationId` field, you can override th ```python class CustomSchema(AutoSchema): - def get_operation_id_base(self, action): + def get_operation_id_base(self, path, method, action): pass def get_operation_id(self, path, method): diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index b00fceb676..ac6482b265 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -115,7 +115,7 @@ def get_operation(self, path, method): return operation - def get_operation_id_base(self, action): + def get_operation_id_base(self, path, method, action): """ Compute the base part for operation ID from the model, serializer or view name. """ @@ -164,7 +164,7 @@ def get_operation_id(self, path, method): else: action = self.method_mapping[method.lower()] - name = self.get_operation_id_base(action) + name = self.get_operation_id_base(path, method, action) return action + name diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index a764f6bfd3..ab206198ed 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -627,7 +627,7 @@ def get_operation_id(self, path, method): def test_operation_id_override_base(self): class CustomSchema(AutoSchema): - def get_operation_id_base(self, action): + def get_operation_id_base(self, path, method, action): return 'Item' path = '/' From c87837b025da33fd8ccca9b33837067c05700cc8 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux Date: Fri, 28 Feb 2020 18:11:45 +0100 Subject: [PATCH 6/6] fix(openapi): remove duplicatde __init__ --- rest_framework/schemas/openapi.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index ac6482b265..5cbba1f9a3 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -71,10 +71,14 @@ def get_schema(self, request=None, public=False): class AutoSchema(ViewInspector): - def __init__(self, tags=None): + def __init__(self, operation_id_base=None, tags=None): + """ + :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + """ if tags and not all(isinstance(tag, str) for tag in tags): raise ValueError('tags must be a list or tuple of string.') self._tags = tags + self.operation_id_base = operation_id_base super().__init__() request_media_types = [] @@ -88,13 +92,6 @@ def __init__(self, tags=None): 'delete': 'Destroy', } - def __init__(self, operation_id_base=None): - """ - :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. - """ - super().__init__() - self.operation_id_base = operation_id_base - def get_operation(self, path, method): operation = {}