From 2f29b0980f1ac32673b497e6aebd88f18fd7d34b Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 25 Sep 2025 15:09:20 +0200 Subject: [PATCH 1/5] :memo: [open-api-workflows#30] add Api-Version header to api spec --- src/objects/api/v2/openapi.yaml | 48 +++++++++++++++++++++++++++++++++ src/objects/conf/api.py | 2 ++ src/objects/conf/base.py | 4 ++- src/objects/utils/autoschema.py | 22 ++++++++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index 3fc1b553..1dca77f4 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -70,6 +70,8 @@ info: `objecten` channel. contact: url: https://github.com/maykinmedia/objects-api + email: support@maykinmedia.nl + name: Maykin Media license: name: EUPL-1.2 paths: @@ -234,6 +236,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' X-Unauthorized-Fields: schema: type: string @@ -297,6 +304,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -345,6 +357,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' X-Unauthorized-Fields: schema: type: string @@ -415,6 +432,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -480,6 +502,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -543,6 +570,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -596,6 +628,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -763,6 +800,11 @@ paths: description: 'The ''Coordinate Reference System'' (CRS) of the request data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: @@ -792,6 +834,12 @@ paths: - {} responses: '200': + headers: + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' content: application/json: schema: diff --git a/src/objects/conf/api.py b/src/objects/conf/api.py index 30411899..69d54ec4 100644 --- a/src/objects/conf/api.py +++ b/src/objects/conf/api.py @@ -94,6 +94,8 @@ "SERVE_INCLUDE_SCHEMA": False, "CONTACT": { "url": "https://github.com/maykinmedia/objects-api", + "email": "support@maykinmedia.nl", + "name": "Maykin Media", }, "LICENSE": {"name": "EUPL-1.2"}, "EXTERNAL_DOCS": { diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index a72c460c..766df05d 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -43,7 +43,9 @@ "objects.utils", ] - +MIDDLEWARE += [ + "vng_api_common.middleware.APIVersionHeaderMiddleware", +] # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ diff --git a/src/objects/utils/autoschema.py b/src/objects/utils/autoschema.py index 13e39177..17687e5c 100644 --- a/src/objects/utils/autoschema.py +++ b/src/objects/utils/autoschema.py @@ -5,6 +5,7 @@ from drf_spectacular.openapi import AutoSchema as _AutoSchema from drf_spectacular.plumbing import build_parameter_type, get_view_model from drf_spectacular.utils import OpenApiParameter +from vng_api_common.constants import VERSION_HEADER from vng_api_common.geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT from vng_api_common.schema import HTTP_STATUS_CODE_TITLES @@ -29,10 +30,15 @@ def get_operation_id(self): def get_override_parameters(self): """Add request GEO headers""" + params = super().get_override_parameters() + geo_headers = self.get_geo_headers() content_type_headers = self.get_content_type_headers() + version_headers = self.get_version_headers() field_params = self.get_fields_params() - return geo_headers + content_type_headers + field_params + return ( + params + geo_headers + content_type_headers + version_headers + field_params + ) def _get_filter_parameters(self): """remove filter parameters from all actions except LIST""" @@ -121,6 +127,20 @@ def get_content_type_headers(self) -> list: ) ] + def get_version_headers(self) -> list[OpenApiParameter]: + return [ + OpenApiParameter( + name=VERSION_HEADER, + type=str, + location=OpenApiParameter.HEADER, + description=_( + "Geeft een specifieke API-versie aan in de context van " + "een specifieke aanroep. Voorbeeld: 1.2.1." + ), + response=True, + ) + ] + def get_fields_params(self) -> list[OpenApiParameter]: if self.method != "GET": return [] From 6458537596efb0e1498365a78daf95b2246911c4 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 25 Sep 2025 15:42:32 +0200 Subject: [PATCH 2/5] :memo: [open-api-workflows#30] add Api-Version header to 204 response --- .github/workflows/oas.yml | 1 + src/objects/api/v2/openapi.yaml | 6 ++++++ src/objects/utils/autoschema.py | 14 +++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/oas.yml b/.github/workflows/oas.yml index e0c22557..a020720a 100644 --- a/.github/workflows/oas.yml +++ b/.github/workflows/oas.yml @@ -26,3 +26,4 @@ jobs: openapi-to-postman-version: '^5.0.0' postman-artifact-name: objects-api-postman-collection openapi-generator-version: '^2.20.0' + spectral-ruleset: https://static.developer.overheid.nl/adr/2.1/ruleset.yaml diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index 1dca77f4..8ca8d0c0 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -529,6 +529,12 @@ paths: - tokenAuth: [] responses: '204': + headers: + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' description: No response body /objects/{uuid}/{index}: get: diff --git a/src/objects/utils/autoschema.py b/src/objects/utils/autoschema.py index 17687e5c..d3d8f991 100644 --- a/src/objects/utils/autoschema.py +++ b/src/objects/utils/autoschema.py @@ -1,10 +1,13 @@ +from typing import Optional, Dict, Type + from django.conf import settings from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiFilterExtension from drf_spectacular.openapi import AutoSchema as _AutoSchema from drf_spectacular.plumbing import build_parameter_type, get_view_model -from drf_spectacular.utils import OpenApiParameter +from drf_spectacular.utils import OpenApiParameter, _SerializerType +from rest_framework import serializers from vng_api_common.constants import VERSION_HEADER from vng_api_common.geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT from vng_api_common.schema import HTTP_STATUS_CODE_TITLES @@ -19,6 +22,14 @@ class AutoSchema(_AutoSchema): + def get_response_serializers( + self, + ): + if self.method == "DELETE": + return {204: None} + + return super().get_response_serializers() + def get_operation_id(self): """ Use model name as a base for operation_id @@ -128,6 +139,7 @@ def get_content_type_headers(self) -> list: ] def get_version_headers(self) -> list[OpenApiParameter]: + print("get_version_headers called for", self.view.__class__.__name__) return [ OpenApiParameter( name=VERSION_HEADER, From d7df9d4e6134d237f0aab44cf9e6356e0215dee2 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 25 Sep 2025 16:34:51 +0200 Subject: [PATCH 3/5] :recycle: [open-api-workflows#30] move yaml api spec url and add json api spec --- src/objects/api/v2/urls.py | 18 ++++++++++++------ src/objects/utils/autoschema.py | 5 +---- src/objects/utils/oas_extensions/views.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 src/objects/utils/oas_extensions/views.py diff --git a/src/objects/api/v2/urls.py b/src/objects/api/v2/urls.py index c93f41af..68d4ce03 100644 --- a/src/objects/api/v2/urls.py +++ b/src/objects/api/v2/urls.py @@ -1,14 +1,17 @@ from django.urls import include, path from drf_spectacular.views import ( - SpectacularJSONAPIView, SpectacularRedocView, - SpectacularYAMLAPIView, ) from rest_framework import routers from .views import ObjectViewSet, PermissionViewSet +from objects.utils.oas_extensions.views import ( + SpectacularYAMLAPIView, + SpectacularJSONAPIView, +) + router = routers.DefaultRouter(trailing_slash=False) router.register(r"objects", ObjectViewSet, basename="object") router.register(r"permissions", PermissionViewSet) @@ -16,16 +19,19 @@ app_name = "v2" urlpatterns = [ - path("", SpectacularJSONAPIView.as_view(), name="schema-json"), path( "/", include( [ - # schema path( - "schema/openapi.yaml", + "openapi.yaml", SpectacularYAMLAPIView.as_view(), - name="schema", + name="schema-yaml", + ), + path( + "openapi.json", + SpectacularJSONAPIView.as_view(), + name="schema-json", ), path( "schema/", diff --git a/src/objects/utils/autoschema.py b/src/objects/utils/autoschema.py index d3d8f991..c341def3 100644 --- a/src/objects/utils/autoschema.py +++ b/src/objects/utils/autoschema.py @@ -1,13 +1,10 @@ -from typing import Optional, Dict, Type - from django.conf import settings from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiFilterExtension from drf_spectacular.openapi import AutoSchema as _AutoSchema from drf_spectacular.plumbing import build_parameter_type, get_view_model -from drf_spectacular.utils import OpenApiParameter, _SerializerType -from rest_framework import serializers +from drf_spectacular.utils import OpenApiParameter from vng_api_common.constants import VERSION_HEADER from vng_api_common.geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT from vng_api_common.schema import HTTP_STATUS_CODE_TITLES diff --git a/src/objects/utils/oas_extensions/views.py b/src/objects/utils/oas_extensions/views.py new file mode 100644 index 00000000..34ec8566 --- /dev/null +++ b/src/objects/utils/oas_extensions/views.py @@ -0,0 +1,19 @@ +from drf_spectacular.views import ( + SpectacularJSONAPIView as _SpectacularJSONAPIView, + SpectacularYAMLAPIView as _SpectacularYAMLAPIView, +) + + +class AllowAllOriginsMixin: + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + response["Access-Control-Allow-Origin"] = "*" + return response + + +class SpectacularYAMLAPIView(AllowAllOriginsMixin, _SpectacularYAMLAPIView): + """Spectacular YAML API view with Access-Control-Allow-Origin set to allow all""" + + +class SpectacularJSONAPIView(AllowAllOriginsMixin, _SpectacularJSONAPIView): + """Spectacular JSON API view with Access-Control-Allow-Origin set to allow all""" From 836b52062f8c8decbdd2c9d8892d775ff51cf37b Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 25 Sep 2025 17:03:29 +0200 Subject: [PATCH 4/5] :recycle: [open-api-workflows#30] fix redoc url --- src/objects/api/v2/urls.py | 8 ++++---- src/objects/tests/v2/test_schema.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/objects/api/v2/urls.py b/src/objects/api/v2/urls.py index 68d4ce03..ba8cdb7e 100644 --- a/src/objects/api/v2/urls.py +++ b/src/objects/api/v2/urls.py @@ -5,13 +5,13 @@ ) from rest_framework import routers -from .views import ObjectViewSet, PermissionViewSet - from objects.utils.oas_extensions.views import ( - SpectacularYAMLAPIView, SpectacularJSONAPIView, + SpectacularYAMLAPIView, ) +from .views import ObjectViewSet, PermissionViewSet + router = routers.DefaultRouter(trailing_slash=False) router.register(r"objects", ObjectViewSet, basename="object") router.register(r"permissions", PermissionViewSet) @@ -35,7 +35,7 @@ ), path( "schema/", - SpectacularRedocView.as_view(url_name="schema"), + SpectacularRedocView.as_view(url_name="schema-yaml"), name="schema-redoc", ), # actual endpoints diff --git a/src/objects/tests/v2/test_schema.py b/src/objects/tests/v2/test_schema.py index e0b07335..88d00f4b 100644 --- a/src/objects/tests/v2/test_schema.py +++ b/src/objects/tests/v2/test_schema.py @@ -5,6 +5,6 @@ class APISchemaTest(APITestCase): - def test_schema_endoint(self): + def test_schema_endpoint(self): response = self.client.get(reverse("schema-redoc")) self.assertEqual(response.status_code, status.HTTP_200_OK) From d701293fea4d479ca22a7791491dd4209218eb72 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 1 Oct 2025 12:44:10 +0200 Subject: [PATCH 5/5] :recycle: [open-api-workflows#30] add old spec path back with deprecation warning --- docs/manual/logging.rst | 2 ++ src/objects/api/v2/urls.py | 5 +++++ src/objects/utils/autoschema.py | 1 - src/objects/utils/oas_extensions/views.py | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/manual/logging.rst b/docs/manual/logging.rst index bda4d9d4..3b8e3f64 100644 --- a/docs/manual/logging.rst +++ b/docs/manual/logging.rst @@ -63,6 +63,7 @@ Objects API * ``search_failed_for_datastore``: attempted to perform ``jsonpath`` search for a backend that does not support this operation. Additional context: ``exc_info``. * ``object_created``: created an ``Object`` via the API. Additional context: ``object_uuid``, ``objecttype_uuid``, ``objecttype_version``, ``token_identifier``, ``token_application``. * ``object_updated``: updated an ``Object`` via the API. Additional context: ``object_uuid``, ``objecttype_uuid``, ``objecttype_version``, ``token_identifier``, ``token_application``. +* ``deprecated_endpoint_called``: a deprecated endpoint was called. Additional context: ``endpoint``. Objecttypes API ~~~~~~~~~~~~~~~ @@ -73,6 +74,7 @@ Objecttypes API * ``object_version_created``: created an ``Object_version`` via the API. Additional context: ``version``, ``objecttype_uuid``, ``token_identifier``, ``token_application``. * ``object_version_updated``: updated an ``Object_version`` via the API. Additional context: ``version``, ``objecttype_uuid``, ``token_identifier``, ``token_application``. * ``object_version_deleted``: deleted an ``Object_version`` via the API. Additional context: ``version``, ``objecttype_uuid``, ``token_identifier``, ``token_application``. +* ``deprecated_endpoint_called``: a deprecated endpoint was called. Additional context: ``endpoint``. Setup configuration ~~~~~~~~~~~~~~~~~~~ diff --git a/src/objects/api/v2/urls.py b/src/objects/api/v2/urls.py index ba8cdb7e..d618a549 100644 --- a/src/objects/api/v2/urls.py +++ b/src/objects/api/v2/urls.py @@ -6,6 +6,7 @@ from rest_framework import routers from objects.utils.oas_extensions.views import ( + DeprecationRedirectView, SpectacularJSONAPIView, SpectacularYAMLAPIView, ) @@ -23,6 +24,10 @@ "/", include( [ + path( + "schema/openapi.yaml", + DeprecationRedirectView.as_view(pattern_name="v2:schema-yaml"), + ), path( "openapi.yaml", SpectacularYAMLAPIView.as_view(), diff --git a/src/objects/utils/autoschema.py b/src/objects/utils/autoschema.py index c341def3..1e9a2a97 100644 --- a/src/objects/utils/autoschema.py +++ b/src/objects/utils/autoschema.py @@ -136,7 +136,6 @@ def get_content_type_headers(self) -> list: ] def get_version_headers(self) -> list[OpenApiParameter]: - print("get_version_headers called for", self.view.__class__.__name__) return [ OpenApiParameter( name=VERSION_HEADER, diff --git a/src/objects/utils/oas_extensions/views.py b/src/objects/utils/oas_extensions/views.py index 34ec8566..78da619c 100644 --- a/src/objects/utils/oas_extensions/views.py +++ b/src/objects/utils/oas_extensions/views.py @@ -1,8 +1,13 @@ +from django.views.generic import RedirectView + +import structlog from drf_spectacular.views import ( SpectacularJSONAPIView as _SpectacularJSONAPIView, SpectacularYAMLAPIView as _SpectacularYAMLAPIView, ) +logger = structlog.stdlib.get_logger(__name__) + class AllowAllOriginsMixin: def dispatch(self, request, *args, **kwargs): @@ -17,3 +22,12 @@ class SpectacularYAMLAPIView(AllowAllOriginsMixin, _SpectacularYAMLAPIView): class SpectacularJSONAPIView(AllowAllOriginsMixin, _SpectacularJSONAPIView): """Spectacular JSON API view with Access-Control-Allow-Origin set to allow all""" + + +class DeprecationRedirectView(RedirectView): + def get(self, request, *args, **kwargs): + logger.warning( + "deprecated_endpoint_called", + endpoint=request.path, + ) + return super().get(request, *args, **kwargs)