diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0bddd277..0e6dd984 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -8,3 +8,5 @@ current_version = 2.2.0 [bumpversion:file:package.json] [bumpversion:file:vng_api_common/__init__.py] + +[bumpversion:file:docs/conf.py] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 295de77f..568a43d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,15 @@ jobs: django: ['4.2'] services: postgres: - image: postgres:14 + image: postgis/postgis:14-3.2 env: POSTGRES_HOST_AUTH_METHOD: trust - ports: - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}) steps: @@ -39,9 +41,16 @@ jobs: with: python-version: ${{ matrix.python }} - - name: Install dependencies + - name: Install pip dependencies run: pip install tox tox-gh-actions + - name: Install system dependencies + run: | + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends \ + libgdal-dev \ + gdal-bin + - name: Run tests run: tox env: diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 8aecd141..c7949ff6 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -29,8 +29,16 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.10' - - name: Install dependencies + + - name: Install pip dependencies run: pip install tox tox-gh-actions + + - name: Install system dependencies + run: | + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends \ + libgdal-dev \ + gdal-bin - run: tox env: TOXENV: ${{ matrix.toxenv }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0cb18a13..54bcf5ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Change history ============== +2.3.0 (WIP) +------------ + +* [#29] Replaced drf-yasg with drf-spectacular +* [#29] Removed management commands to generate markdown files for scopes and notifications channels: + * ``generate_autorisaties`` + * ``generate_notificaties`` + + 2.2.0 (2024-12-10) ------------------ diff --git a/bin/generate_notifications_api_spec.sh b/bin/generate_notifications_api_spec.sh index 48045fd3..b110f25b 100755 --- a/bin/generate_notifications_api_spec.sh +++ b/bin/generate_notifications_api_spec.sh @@ -7,15 +7,4 @@ if [[ -z "$VIRTUAL_ENV" ]]; then exit 1 fi -toplevel=$(git rev-parse --show-toplevel) -cd $toplevel - -./manage.py generate_swagger \ - --overwrite \ - -f yaml \ - notifications-webhook-2.0.yaml - -echo "Converting Swagger to OpenAPI 3.0..." -npm run convert - -MANAGE=manage.py ./bin/patch_content_types notifications-webhook.yaml +DJANGO_SETTINGS_MODULE=notifications_webhook.settings ./manage.py spectacular --file notifications-webhook.yaml --validate diff --git a/bin/generate_schema b/bin/generate_schema index 927397cc..164bbd4a 100755 --- a/bin/generate_schema +++ b/bin/generate_schema @@ -9,31 +9,5 @@ if [[ -z "$VIRTUAL_ENV" ]]; then exit 1 fi -echo "Generating Swagger schema" -src/manage.py generate_swagger \ - ./src/swagger2.0.json \ - --overwrite \ - --format=json \ - --mock-request \ - --url https://example.com/api/v1 - -echo "Converting Swagger to OpenAPI 3.0..." -npm run convert -patch_content_types - -echo "Generating unresolved OpenAPI 3.0 schema" -use_external_components - -echo "Generating resources document" -src/manage.py generate_swagger \ - ./src/resources.md \ - --overwrite \ - --mock-request \ - --url https://example.com/api/v1 \ - --to-markdown-table - -echo "Generating autorisaties.md" -src/manage.py generate_autorisaties --output-file ./src/autorisaties.md - -echo "Generating notificaties.md" -src/manage.py generate_notificaties --output-file ./src/notificaties.md +echo "generate schema" +src/manage.py spectacular --file src/openapi.yaml --validate diff --git a/bin/patch_content_types b/bin/patch_content_types deleted file mode 100755 index 9c654b60..00000000 --- a/bin/patch_content_types +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -# Run this script from the root of the repository - -set -e - -if [[ -z "$VIRTUAL_ENV" ]]; then - echo "You need to activate your virtual env before running this script" - exit 1 -fi - -source_file=${1:-./src/openapi.yaml} -manage=${MANAGE:-src/manage.py} - -python $manage patch_error_contenttypes $source_file diff --git a/bin/use_external_components b/bin/use_external_components deleted file mode 100755 index 88ae6f33..00000000 --- a/bin/use_external_components +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -# Run this script from the root of the repository - -set -e - -if [[ -z "$VIRTUAL_ENV" ]]; then - echo "You need to activate your virtual env before running this script" - exit 1 -fi - -source_file=${1:-./src/openapi.yaml} -output=${2:-./src/openapi_unresolved.yaml} -manage=${MANAGE:-src/manage.py} - -python $manage use_external_components $source_file $output diff --git a/docs/conf.py b/docs/conf.py index cbd36184..3d6b0e38 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,36 +12,14 @@ # import os import sys +from pathlib import Path import django -from django.conf import settings - -sys.path.insert(0, os.path.abspath("..")) - -from vng_api_common import __version__ # noqa isort:skip -from vng_api_common.conf import api as api_settings # noqa isort:skip - -settings.configure( - INSTALLED_APPS=[ - "django.contrib.sites", - "rest_framework", - "django_filters", - "vng_api_common", - "vng_api_common.notifications", - "drf_yasg", - "solo", - ], - DATABASES={ - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "docs", - "USER": "docs", - "PASSWORD": "docs", - } - }, - BASE_DIR=sys.path[0], - **{name: getattr(api_settings, name) for name in api_settings.__all__} -) + +_root_dir = Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(_root_dir)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") + django.setup() # -- Project information ----------------------------------------------------- @@ -51,7 +29,7 @@ author = "VNG-Realisatie, Maykin Media" # The full version, including alpha/beta/rc tags -release = __version__ +release = "1.13.2" # -- General configuration --------------------------------------------------- diff --git a/docs/quickstart.rst b/docs/quickstart.rst index a170c605..c03a6d63 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -8,7 +8,7 @@ Installation Pre-requisites -------------- -* Python 3.6 or higher +* Python 3.10 or higher * Setuptools 30.3.0 or higher * Only the PostgreSQL database is supported @@ -21,12 +21,6 @@ Install from PyPI with pip: pip install vng-api-common -You will also need the NPM package ``swagger2openapi``: - -.. code-block:: bash - - npm install swagger2openapi - Configure the Django settings ----------------------------- @@ -39,12 +33,13 @@ Configure the Django settings 'django.contrib.sites', # required if using the notifications 'django_filters', - 'vng_api_common', # before drf_yasg to override the management command + 'vng_api_common', 'vng_api_common.authorizations', 'vng_api_common.notifications', # optional 'vng_api_common.audittrails', # optional - 'drf_yasg', + 'drf_spectacular', 'rest_framework', + 'rest_framework_gis', 'solo', # required for authorizations and notifications ... ] @@ -80,18 +75,6 @@ Configure the Django settings 4. See ``vng_api_common/conf/api.py`` for a list of available settings. -Configure the Node tooling --------------------------- - -In the ``package.json`` of your project, add the scripts entry for ``convert``: - -.. code-block:: json - - { - "scripts": { - "convert": "swagger2openapi src/swagger2.0.json -o src/openapi.yaml" - } - } Usage ===== @@ -107,9 +90,7 @@ To generate the API spec, run: This will output: -* ``src/swagger2.0.json``: the OAS 2 specification * ``src/openapi.yaml``: the OAS 3 specification -* ``src/resources.md``: a list of the exposed resources See the reference implementations of `ZRC`_, `DRC`_, `BRC`_ en `ZTC`_ to see it in action. diff --git a/notifications-webhook-2.0.yaml b/notifications-webhook-2.0.yaml deleted file mode 100755 index a93a3250..00000000 --- a/notifications-webhook-2.0.yaml +++ /dev/null @@ -1,260 +0,0 @@ -swagger: '2.0' -info: - title: Notifications webhook receiver - description: API Specification to be able to receive notifications from the NRC - contact: - url: https://github.com/VNG-Realisatie/gemma-zaken - version: '1' -basePath: / -consumes: - - application/json -produces: - - application/json -securityDefinitions: {} -paths: - /{webhooks_path}: - post: - operationId: notification_receive - description: Ontvang notificaties via webhook - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/Notificatie' - responses: - '204': - description: '' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '400': - description: '' - schema: - $ref: '#/definitions/ValidatieFout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '401': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '403': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '429': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '500': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '502': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - '503': - description: '' - schema: - $ref: '#/definitions/Fout' - headers: - API-version: - schema: - type: string - description: 'Geeft een specifieke API-versie aan in de context van - een specifieke aanroep. Voorbeeld: 1.2.1.' - tags: - - Notificaties - parameters: - - name: webhooks_path - in: path - required: true - type: string -definitions: - Notificatie: - required: - - kanaal - - hoofdObject - - resource - - resourceUrl - - actie - - aanmaakdatum - type: object - properties: - kanaal: - title: kanaal - type: string - maxLength: 50 - minLength: 1 - hoofdObject: - title: URL naar het hoofdobject - type: string - format: uri - minLength: 1 - resource: - title: resource - type: string - maxLength: 100 - minLength: 1 - resourceUrl: - title: URL naar de resource waarover de notificatie gaat - type: string - format: uri - minLength: 1 - actie: - title: actie - type: string - maxLength: 100 - minLength: 1 - aanmaakdatum: - title: aanmaakdatum - type: string - format: date-time - kenmerken: - title: Kenmerken - type: object - additionalProperties: - type: string - maxLength: 1000 - minLength: 1 - FieldValidationError: - required: - - name - - code - - reason - type: object - properties: - name: - title: Name - description: Naam van het veld met ongeldige gegevens - type: string - minLength: 1 - code: - title: Code - description: Systeemcode die het type fout aangeeft - type: string - minLength: 1 - reason: - title: Reason - description: Uitleg wat er precies fout is met de gegevens - type: string - minLength: 1 - ValidatieFout: - required: - - code - - title - - status - - detail - - instance - - invalid-params - type: object - properties: - type: - title: Type - description: URI referentie naar het type fout, bedoeld voor developers - type: string - code: - title: Code - description: Systeemcode die het type fout aangeeft - type: string - minLength: 1 - title: - title: Title - description: Generieke titel voor het type fout - type: string - minLength: 1 - status: - title: Status - description: De HTTP status code - type: integer - detail: - title: Detail - description: Extra informatie bij de fout, indien beschikbaar - type: string - minLength: 1 - instance: - title: Instance - description: URI met referentie naar dit specifiek voorkomen van de fout. - Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld. - type: string - minLength: 1 - invalid-params: - type: array - items: - $ref: '#/definitions/FieldValidationError' - Fout: - required: - - code - - title - - status - - detail - - instance - type: object - properties: - type: - title: Type - description: URI referentie naar het type fout, bedoeld voor developers - type: string - code: - title: Code - description: Systeemcode die het type fout aangeeft - type: string - minLength: 1 - title: - title: Title - description: Generieke titel voor het type fout - type: string - minLength: 1 - status: - title: Status - description: De HTTP status code - type: integer - detail: - title: Detail - description: Extra informatie bij de fout, indien beschikbaar - type: string - minLength: 1 - instance: - title: Instance - description: URI met referentie naar dit specifiek voorkomen van de fout. - Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld. - type: string - minLength: 1 diff --git a/notifications-webhook.yaml b/notifications-webhook.yaml index 35337ede..ea107223 100644 --- a/notifications-webhook.yaml +++ b/notifications-webhook.yaml @@ -1,32 +1,51 @@ -openapi: 3.0.0 +openapi: 3.0.3 info: title: Notifications webhook receiver + version: v1 description: API Specification to be able to receive notifications from the NRC contact: + name: VNG Realisatie url: https://github.com/VNG-Realisatie/gemma-zaken - version: '1' paths: /{webhooks_path}: post: operationId: notification_receive description: Ontvang notificaties via webhook + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: webhooks_path + schema: + type: string + required: true + tags: + - Notificaties requestBody: content: application/json: schema: $ref: '#/components/schemas/Notificatie' required: true + security: + - JWT-Claims: + - notificaties.publiceren responses: '204': - description: '' 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 '400': - description: '' headers: API-version: schema: @@ -37,8 +56,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/ValidatieFout' + description: Bad request '401': - description: '' headers: API-version: schema: @@ -49,8 +68,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' + description: Unauthorized '403': - description: '' headers: API-version: schema: @@ -61,8 +80,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' + description: Forbidden '429': - description: '' headers: API-version: schema: @@ -73,8 +92,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' + description: Too many requests '500': - description: '' headers: API-version: schema: @@ -85,8 +104,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' + description: Internal server error '502': - description: '' headers: API-version: schema: @@ -97,8 +116,8 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' + description: Bad gateway '503': - description: '' headers: API-version: schema: @@ -109,164 +128,132 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Fout' - tags: - - Notificaties - parameters: - - name: webhooks_path - in: path - required: true - schema: - type: string -servers: -- url: / + description: Service unavailable components: schemas: - Notificatie: + FieldValidationError: + type: object + description: Formaat van validatiefouten. + properties: + name: + type: string + description: Naam van het veld met ongeldige gegevens + code: + type: string + description: Systeemcode die het type fout aangeeft + reason: + type: string + description: Uitleg wat er precies fout is met de gegevens required: - - kanaal - - hoofdObject - - resource - - resourceUrl - - actie - - aanmaakdatum + - code + - name + - reason + Fout: + type: object + description: Formaat van HTTP 4xx en 5xx fouten. + properties: + type: + type: string + description: URI referentie naar het type fout, bedoeld voor developers + code: + type: string + description: Systeemcode die het type fout aangeeft + title: + type: string + description: Generieke titel voor het type fout + status: + type: integer + description: De HTTP status code + detail: + type: string + description: Extra informatie bij de fout, indien beschikbaar + instance: + type: string + description: URI met referentie naar dit specifiek voorkomen van de fout. + Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld. + required: + - code + - detail + - instance + - status + - title + Notificatie: type: object properties: kanaal: - title: kanaal type: string + description: De naam van het kanaal (`KANAAL.naam`) waar het bericht op + moet worden gepubliceerd. maxLength: 50 - minLength: 1 hoofdObject: - title: URL naar het hoofdobject type: string format: uri - minLength: 1 + description: URL-referentie naar het hoofd object van de publicerende API + die betrekking heeft op de `resource`. resource: - title: resource type: string + description: De resourcenaam waar de notificatie over gaat. maxLength: 100 - minLength: 1 resourceUrl: - title: URL naar de resource waarover de notificatie gaat type: string format: uri - minLength: 1 + description: URL-referentie naar de `resource` van de publicerende API. actie: - title: actie type: string + description: De actie die door de publicerende API is gedaan. De publicerende + API specificeert de toegestane acties. maxLength: 100 - minLength: 1 aanmaakdatum: - title: aanmaakdatum type: string format: date-time + description: Datum en tijd waarop de actie heeft plaatsgevonden. kenmerken: - title: Kenmerken type: object additionalProperties: type: string + title: kenmerk + description: Een waarde behorende bij de sleutel. maxLength: 1000 - minLength: 1 - FieldValidationError: + description: Mapping van kenmerken (sleutel/waarde) van de notificatie. + De publicerende API specificeert de toegestane kenmerken. required: - - name - - code - - reason - type: object - properties: - name: - title: Name - description: Naam van het veld met ongeldige gegevens - type: string - minLength: 1 - code: - title: Code - description: Systeemcode die het type fout aangeeft - type: string - minLength: 1 - reason: - title: Reason - description: Uitleg wat er precies fout is met de gegevens - type: string - minLength: 1 + - aanmaakdatum + - actie + - hoofdObject + - kanaal + - resource + - resourceUrl ValidatieFout: - required: - - code - - title - - status - - detail - - instance - - invalid-params type: object + description: Formaat van HTTP 4xx en 5xx fouten. properties: type: - title: Type - description: URI referentie naar het type fout, bedoeld voor developers type: string + description: URI referentie naar het type fout, bedoeld voor developers code: - title: Code - description: Systeemcode die het type fout aangeeft type: string - minLength: 1 + description: Systeemcode die het type fout aangeeft title: - title: Title - description: Generieke titel voor het type fout type: string - minLength: 1 + description: Generieke titel voor het type fout status: - title: Status - description: De HTTP status code type: integer + description: De HTTP status code detail: - title: Detail - description: Extra informatie bij de fout, indien beschikbaar type: string - minLength: 1 + description: Extra informatie bij de fout, indien beschikbaar instance: - title: Instance + type: string description: URI met referentie naar dit specifiek voorkomen van de fout. Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld. - type: string - minLength: 1 - invalid-params: + invalidParams: type: array items: $ref: '#/components/schemas/FieldValidationError' - Fout: required: - code - - title - - status - detail - instance - type: object - properties: - type: - title: Type - description: URI referentie naar het type fout, bedoeld voor developers - type: string - code: - title: Code - description: Systeemcode die het type fout aangeeft - type: string - minLength: 1 - title: - title: Title - description: Generieke titel voor het type fout - type: string - minLength: 1 - status: - title: Status - description: De HTTP status code - type: integer - detail: - title: Detail - description: Extra informatie bij de fout, indien beschikbaar - type: string - minLength: 1 - instance: - title: Instance - description: URI met referentie naar dit specifiek voorkomen van de fout. - Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld. - type: string - minLength: 1 + - invalidParams + - status + - title diff --git a/notifications_webhook/__init__.py b/notifications_webhook/__init__.py new file mode 100644 index 00000000..2f06269f --- /dev/null +++ b/notifications_webhook/__init__.py @@ -0,0 +1,7 @@ +""" +App to generate OAS for endpoint for notification consumers. + +This app is used in the bin/generate_notifications_api_spec.sh +command to generate notifications-webhook.yaml +It was originally in the testapp but moved to the separate app. +""" diff --git a/notifications_webhook/settings.py b/notifications_webhook/settings.py new file mode 100644 index 00000000..b05c27c6 --- /dev/null +++ b/notifications_webhook/settings.py @@ -0,0 +1,59 @@ +import os + +from vng_api_common.conf.api import * # noqa + +DEBUG = os.getenv("DEBUG", "no").lower() in ["yes", "true", "1"] + +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + +SECRET_KEY = "so-secret-i-cant-believe-you-are-looking-at-this" + +ALLOWED_HOSTS = ["*"] + +USE_TZ = True + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "notifications_webhook", + } +} + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "django.contrib.auth", + "rest_framework", + "drf_spectacular", + "vng_api_common", + "vng_api_common.notifications", + "notifications_webhook", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "vng_api_common.middleware.AuthMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "notifications_webhook.urls" + +# API settings +REST_FRAMEWORK = BASE_REST_FRAMEWORK.copy() + +SPECTACULAR_SETTINGS = BASE_SPECTACULAR_SETTINGS.copy() +SPECTACULAR_SETTINGS.update( + { + "TITLE": "Notifications webhook receiver", + "VERSION": "v1", + "DESCRIPTION": "API Specification to be able to receive notifications from the NRC", + "CONTACT": { + "name": "VNG Realisatie", + "url": "https://github.com/VNG-Realisatie/gemma-zaken", + }, + } +) diff --git a/notifications_webhook/urls.py b/notifications_webhook/urls.py new file mode 100644 index 00000000..dfa10e64 --- /dev/null +++ b/notifications_webhook/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import NotificationView + +urlpatterns = [ + path("", NotificationView.as_view()), +] diff --git a/testapp/views.py b/notifications_webhook/views.py similarity index 61% rename from testapp/views.py rename to notifications_webhook/views.py index 199dbf31..0fff65ee 100644 --- a/testapp/views.py +++ b/notifications_webhook/views.py @@ -1,6 +1,5 @@ -from drf_yasg.app_settings import swagger_settings - from vng_api_common.notifications.api.views import NotificationView as _NotificationView +from vng_api_common.schema import AutoSchema class NotificationView(_NotificationView): @@ -8,4 +7,4 @@ class NotificationView(_NotificationView): Ontvang notificaties via webhook """ - swagger_schema = swagger_settings.DEFAULT_AUTO_SCHEMA_CLASS + schema = AutoSchema() diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 21ab3a7c..00000000 --- a/package-lock.json +++ /dev/null @@ -1,720 +0,0 @@ -{ - "name": "vng-api-common", - "version": "2.2.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "vng-api-common", - "version": "2.2.0", - "license": "EUPL-1.2", - "dependencies": { - "swagger2openapi": "^7.0.8" - } - }, - "node_modules/@exodus/schemasafe": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.0.0-rc.6.tgz", - "integrity": "sha512-dDnQizD94EdBwEj/fh3zPRa/HWCS9O5au2PuHhZBbuM3xWHxuaKzPBOEWze7Nn0xW68MIpZ7Xdyn1CoCpjKCuQ==" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/call-me-maybe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", - "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=" - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=" - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/http2-client": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", - "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch-h2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", - "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", - "dependencies": { - "http2-client": "^1.2.5" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/node-readfiles": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", - "integrity": "sha1-271K8SE04uY1wkXvk//Pb2BnOl0=", - "dependencies": { - "es6-promise": "^3.2.1" - } - }, - "node_modules/oas-kit-common": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", - "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", - "dependencies": { - "fast-safe-stringify": "^2.0.7" - } - }, - "node_modules/oas-linter": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", - "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", - "dependencies": { - "@exodus/schemasafe": "^1.0.0-rc.2", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-resolver": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", - "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", - "dependencies": { - "node-fetch-h2": "^2.3.0", - "oas-kit-common": "^1.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "resolve": "resolve.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-schema-walker": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", - "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-validator": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", - "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", - "dependencies": { - "call-me-maybe": "^1.0.1", - "oas-kit-common": "^1.0.8", - "oas-linter": "^3.2.2", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "reftools": "^1.1.9", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/reftools": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", - "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/should": { - "version": "13.2.3", - "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", - "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", - "dependencies": { - "should-equal": "^2.0.0", - "should-format": "^3.0.3", - "should-type": "^1.4.0", - "should-type-adaptors": "^1.0.1", - "should-util": "^1.0.0" - } - }, - "node_modules/should-equal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", - "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", - "dependencies": { - "should-type": "^1.4.0" - } - }, - "node_modules/should-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", - "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", - "dependencies": { - "should-type": "^1.3.0", - "should-type-adaptors": "^1.0.1" - } - }, - "node_modules/should-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", - "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=" - }, - "node_modules/should-type-adaptors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", - "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", - "dependencies": { - "should-type": "^1.3.0", - "should-util": "^1.0.0" - } - }, - "node_modules/should-util": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", - "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/swagger2openapi": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", - "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", - "dependencies": { - "call-me-maybe": "^1.0.1", - "node-fetch": "^2.6.1", - "node-fetch-h2": "^2.3.0", - "node-readfiles": "^0.2.0", - "oas-kit-common": "^1.0.8", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "oas-validator": "^5.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "boast": "boast.js", - "oas-validate": "oas-validate.js", - "swagger2openapi": "swagger2openapi.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yargs": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", - "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", - "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==", - "engines": { - "node": ">=12" - } - } - }, - "dependencies": { - "@exodus/schemasafe": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.0.0-rc.6.tgz", - "integrity": "sha512-dDnQizD94EdBwEj/fh3zPRa/HWCS9O5au2PuHhZBbuM3xWHxuaKzPBOEWze7Nn0xW68MIpZ7Xdyn1CoCpjKCuQ==" - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "call-me-maybe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", - "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=" - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=" - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "http2-client": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", - "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-fetch-h2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", - "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", - "requires": { - "http2-client": "^1.2.5" - } - }, - "node-readfiles": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", - "integrity": "sha1-271K8SE04uY1wkXvk//Pb2BnOl0=", - "requires": { - "es6-promise": "^3.2.1" - } - }, - "oas-kit-common": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", - "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", - "requires": { - "fast-safe-stringify": "^2.0.7" - } - }, - "oas-linter": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", - "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", - "requires": { - "@exodus/schemasafe": "^1.0.0-rc.2", - "should": "^13.2.1", - "yaml": "^1.10.0" - } - }, - "oas-resolver": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", - "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", - "requires": { - "node-fetch-h2": "^2.3.0", - "oas-kit-common": "^1.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - } - }, - "oas-schema-walker": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", - "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==" - }, - "oas-validator": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", - "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", - "requires": { - "call-me-maybe": "^1.0.1", - "oas-kit-common": "^1.0.8", - "oas-linter": "^3.2.2", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "reftools": "^1.1.9", - "should": "^13.2.1", - "yaml": "^1.10.0" - } - }, - "reftools": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", - "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==" - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" - }, - "should": { - "version": "13.2.3", - "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", - "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", - "requires": { - "should-equal": "^2.0.0", - "should-format": "^3.0.3", - "should-type": "^1.4.0", - "should-type-adaptors": "^1.0.1", - "should-util": "^1.0.0" - } - }, - "should-equal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", - "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", - "requires": { - "should-type": "^1.4.0" - } - }, - "should-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", - "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", - "requires": { - "should-type": "^1.3.0", - "should-type-adaptors": "^1.0.1" - } - }, - "should-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", - "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=" - }, - "should-type-adaptors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", - "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", - "requires": { - "should-type": "^1.3.0", - "should-util": "^1.0.0" - } - }, - "should-util": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", - "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==" - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "swagger2openapi": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", - "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", - "requires": { - "call-me-maybe": "^1.0.1", - "node-fetch": "^2.6.1", - "node-fetch-h2": "^2.3.0", - "node-readfiles": "^0.2.0", - "oas-kit-common": "^1.0.8", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "oas-validator": "^5.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - }, - "yargs": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", - "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - } - }, - "yargs-parser": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", - "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 7b4a003c..00000000 --- a/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "vng-api-common", - "version": "2.2.0", - "description": "NodeJS build tooling for vng-api-common", - "main": "index.js", - "directories": { - "doc": "docs", - "test": "tests" - }, - "scripts": { - "convert": "swagger2openapi notifications-webhook-2.0.yaml -o notifications-webhook.yaml", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/vng-Realisatie/gemma-zaken-common.git" - }, - "author": "VNG-Realisatie, Maykin Media", - "license": "EUPL-1.2", - "bugs": { - "url": "https://github.com/vng-Realisatie/gemma-zaken-common/issues" - }, - "homepage": "https://github.com/vng-Realisatie/gemma-zaken-common#readme", - "dependencies": { - "swagger2openapi": "^7.0.8" - } -} diff --git a/setup.cfg b/setup.cfg index e291cbf0..aba703a5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,8 +30,6 @@ include_package_data = True packages = find: scripts = bin/generate_schema - bin/patch_content_types - bin/use_external_components install_requires = django>=4.2.0 django-filter>=23.1,<=25.1 @@ -39,8 +37,8 @@ install_requires = djangorestframework>=3.15.0 djangorestframework_camel_case>=1.2.0 django-rest-framework-condition - drf-yasg>=1.20.0 # TODO: remove usage? drf-nested-routers>=0.94.1 + drf-spectacular iso-639 isodate notifications-api-common>=0.3.1 @@ -54,6 +52,7 @@ install_requires = tests_require = pytest pytest-django + pytest-dotenv pytest-factoryboy tox isort @@ -70,10 +69,15 @@ include = markdown_docs = django-markup<=1.3 markdown +djangorestframework_gis = + djangorestframework-gis>=1.0 +drf_extra_fields = + drf-extra-fields>=3.7.0 tests = psycopg2 pytest pytest-django + pytest-dotenv pytest-factoryboy tox isort @@ -81,6 +85,8 @@ tests = requests-mock freezegun zgw-consumers-oas + djangorestframework-gis + drf-extra-fields testutils = zgw-consumers-oas setup-configuration = diff --git a/testapp/migrations/0008_poly_alter_record_create_date_fkmodel.py b/testapp/migrations/0008_poly_alter_record_create_date_fkmodel.py new file mode 100644 index 00000000..376eda79 --- /dev/null +++ b/testapp/migrations/0008_poly_alter_record_create_date_fkmodel.py @@ -0,0 +1,69 @@ +# Generated by Django 4.1.13 on 2024-07-30 03:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("testapp", "0007_record"), + ] + + operations = [ + migrations.CreateModel( + name="Poly", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="name")), + ( + "choice", + models.CharField( + choices=[("hobby", "Hobby."), ("record", "Record")], + max_length=6, + verbose_name="choice", + ), + ), + ], + ), + migrations.AlterField( + model_name="record", + name="create_date", + field=models.DateField(verbose_name="create date"), + ), + migrations.CreateModel( + name="FkModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="name")), + ( + "field_with_underscores", + models.CharField(max_length=100, verbose_name="name"), + ), + ( + "poly", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="testapp.poly", + verbose_name="poly", + ), + ), + ], + ), + ] diff --git a/testapp/migrations/0009_geometrymodel_mediafilemodel.py b/testapp/migrations/0009_geometrymodel_mediafilemodel.py new file mode 100644 index 00000000..7daa158b --- /dev/null +++ b/testapp/migrations/0009_geometrymodel_mediafilemodel.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.25 on 2024-11-26 14:23 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("testapp", "0008_poly_alter_record_create_date_fkmodel"), + ] + + operations = [ + migrations.CreateModel( + name="GeometryModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="name")), + ( + "zaakgeometrie", + django.contrib.gis.db.models.fields.GeometryField( + blank=True, + help_text="Punt, lijn of (multi-)vlak geometrie-informatie.", + null=True, + srid=4326, + ), + ), + ], + ), + migrations.CreateModel( + name="MediaFileModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="name")), + ("file", models.FileField(upload_to="uploads/", verbose_name="file")), + ], + ), + ] diff --git a/testapp/models.py b/testapp/models.py index 785e07c4..59bce5ec 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -1,3 +1,4 @@ +from django.contrib.gis.db.models import GeometryField from django.db import models from django.utils.translation import gettext_lazy as _ @@ -39,3 +40,40 @@ class Hobby(ETagMixin, models.Model): class Record(models.Model): identificatie = models.CharField(_("identificatie"), max_length=50, unique=True) create_date = models.DateField(_("create date")) + + +class PolyChoice(models.TextChoices): + hobby = "hobby", _("Hobby.") + record = "record", _("Record") + + +class Poly(models.Model): + name = models.CharField(_("name"), max_length=100) + choice = models.CharField(_("choice"), choices=PolyChoice.choices, max_length=6) + + +class FkModel(models.Model): + name = models.CharField(_("name"), max_length=100) + field_with_underscores = models.CharField(_("name"), max_length=100) + poly = models.ForeignKey( + verbose_name=_("poly"), + on_delete=models.deletion.CASCADE, + to=Poly, + ) + + +class MediaFileModel(models.Model): + name = models.CharField(_("name"), max_length=100) + file = models.FileField( + _("file"), + upload_to="uploads/", + ) + + +class GeometryModel(models.Model): + name = models.CharField(_("name"), max_length=100) + zaakgeometrie = GeometryField( + blank=True, + null=True, + help_text="Punt, lijn of (multi-)vlak geometrie-informatie.", + ) diff --git a/testapp/schema.py b/testapp/schema.py deleted file mode 100644 index e021b8fb..00000000 --- a/testapp/schema.py +++ /dev/null @@ -1,13 +0,0 @@ -from drf_yasg import openapi -from drf_yasg.views import get_schema_view -from rest_framework import permissions - -info = openapi.Info( - title="Notifications webhook receiver", - default_version="v1", - description="API Specification to be able to receive notifications from the NRC", - contact=openapi.Contact(url="https://github.com/VNG-Realisatie/gemma-zaken"), -) - - -SchemaView = get_schema_view(public=True, permission_classes=(permissions.AllowAny,)) diff --git a/testapp/serializers.py b/testapp/serializers.py index a2f07a58..cece1078 100644 --- a/testapp/serializers.py +++ b/testapp/serializers.py @@ -1,6 +1,18 @@ +from drf_extra_fields.fields import Base64FileField from rest_framework import serializers +from rest_framework_gis.fields import GeometryField -from testapp.models import Group, Hobby, Person +from testapp.models import ( + GeometryModel, + Group, + Hobby, + MediaFileModel, + Person, + Poly, + PolyChoice, + Record, +) +from vng_api_common.polymorphism import Discriminator, PolymorphicSerializer from vng_api_common.serializers import GegevensGroepSerializer @@ -38,7 +50,48 @@ class Meta: fields = ("person",) +class MediaFileModelSerializer(serializers.ModelSerializer): + file = Base64FileField( + required=False, + allow_null=True, + ) + + class Meta: + model = MediaFileModel + fields = ("file",) + + +class GeometryModelSerializer(serializers.ModelSerializer): + geometry = GeometryField(required=False) + + class Meta: + model = GeometryModel + fields = ("geometry",) + + +# polymorphic serializer class HobbySerializer(serializers.ModelSerializer): class Meta: model = Hobby fields = ("name", "people") + + +class RecordSerializer(serializers.ModelSerializer): + class Meta: + model = Record + fields = ("identificatie", "create_date") + + +class PolySerializer(PolymorphicSerializer): + discriminator = Discriminator( + discriminator_field="choice", + mapping={ + PolyChoice.hobby: HobbySerializer(), + PolyChoice.record: RecordSerializer(), + }, + group_field="poly", + ) + + class Meta: + model = Poly + fields = ("name",) diff --git a/testapp/settings.py b/testapp/settings.py index e1abcd85..1478106e 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -1,6 +1,7 @@ import os from vng_api_common.conf.api import * # noqa +from vng_api_common.conf.api import JWT_SPECTACULAR_SETTINGS # noqa SITE_ID = 1 @@ -17,7 +18,7 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql", + "ENGINE": "django.contrib.gis.db.backends.postgis", "NAME": os.getenv("PGDATABASE", "vng_api_common"), "USER": os.getenv("DB_USER", "postgres"), "PASSWORD": os.getenv("DB_PASSWORD", ""), @@ -26,6 +27,10 @@ } } +# Geospatial libraries +GEOS_LIBRARY_PATH = None +GDAL_LIBRARY_PATH = None + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" INSTALLED_APPS = [ @@ -38,7 +43,7 @@ "django.contrib.postgres", "django.contrib.messages", "rest_framework", - "drf_yasg", + "drf_spectacular", "simple_certmanager", "zgw_consumers", "notifications_api_common", @@ -85,16 +90,18 @@ SECURITY_DEFINITION_NAME = "JWT-Claims" -SWAGGER_SETTINGS = BASE_SWAGGER_SETTINGS.copy() - -SWAGGER_SETTINGS["DEFAULT_FIELD_INSPECTORS"] = SWAGGER_SETTINGS[ - "DEFAULT_FIELD_INSPECTORS" -][1:] - -SWAGGER_SETTINGS.update( +SPECTACULAR_SETTINGS = BASE_SPECTACULAR_SETTINGS.copy() +SPECTACULAR_SETTINGS.update( { - "DEFAULT_INFO": "testapp.schema.info", "SECURITY_DEFINITIONS": {SECURITY_DEFINITION_NAME: {}}, + "TAGS": BASE_SPECTACULAR_SETTINGS.get("TAGS", []) + + [ + { + "name": "moloko_milk_bar", + "description": "Global tag description via settings", + }, + ], + **JWT_SPECTACULAR_SETTINGS, } ) diff --git a/testapp/urls.py b/testapp/urls.py index c20ccd9e..b8de03ed 100644 --- a/testapp/urls.py +++ b/testapp/urls.py @@ -1,13 +1,16 @@ from django.contrib import admin -from django.urls import include, path, re_path +from django.urls import include, path from django.views.generic import RedirectView +from drf_spectacular.views import ( + SpectacularJSONAPIView, + SpectacularRedocView, + SpectacularYAMLAPIView, +) from rest_framework import routers from vng_api_common.views import ViewConfigView -from .schema import SchemaView -from .views import NotificationView from .viewsets import GroupViewSet, HobbyViewSet, PaginateHobbyViewSet, PersonViewSet router = routers.DefaultRouter(trailing_slash=False) @@ -23,14 +26,19 @@ include( [ # API documentation - re_path( - r"^schema/openapi(?P\.json|\.yaml)$", - SchemaView.without_ui(cache_timeout=None), + path( + "schema/openapi.json", + SpectacularJSONAPIView.as_view(), name="schema-json", ), - re_path( - r"^schema/$", - SchemaView.with_ui("redoc", cache_timeout=None), + path( + "schema/openapi.yaml", + SpectacularYAMLAPIView.as_view(), + name="schema-yaml", + ), + path( + "schema/", + SpectacularRedocView.as_view(url_name="schema-yaml"), name="schema-redoc", ), ] @@ -41,8 +49,5 @@ path("api/", include("vng_api_common.api.urls")), path("ref/", include("vng_api_common.urls")), path("view-config/", ViewConfigView.as_view(), name="view-config"), - # this is a hack to get the parameter to show up in the API spec - # this effectively makes this a wildcard URL, so it should be LAST - path("", NotificationView.as_view()), path("", RedirectView.as_view(url="/api/")), ] diff --git a/testapp/viewsets.py b/testapp/viewsets.py index bb6190f6..33d193d7 100644 --- a/testapp/viewsets.py +++ b/testapp/viewsets.py @@ -1,25 +1,29 @@ +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import viewsets from vng_api_common.caching import conditional_retrieve +from vng_api_common.geo import GeoMixin from vng_api_common.pagination import DynamicPageSizePagination -from .models import Group, Hobby, Person -from .serializers import GroupSerializer, HobbySerializer, PersonSerializer - - +from .models import GeometryModel, Group, Hobby, MediaFileModel, Person, Poly +from .serializers import ( + GeometryModelSerializer, + GroupSerializer, + HobbySerializer, + MediaFileModelSerializer, + PersonSerializer, + PolySerializer, +) + + +@extend_schema_view( + list=extend_schema( + description="Summary\n\nMore summary", + ), + retrieve=extend_schema(description="Some description"), +) @conditional_retrieve(extra_depends_on={"group"}) class PersonViewSet(viewsets.ReadOnlyModelViewSet): - """ - Title - - Summary - - More summary - - retrieve: - Some description - """ - queryset = Person.objects.all() serializer_class = PersonSerializer @@ -36,6 +40,21 @@ class GroupViewSet(viewsets.ModelViewSet): class PaginateHobbyViewSet(viewsets.ModelViewSet): - queryset = Hobby.objects.all() + queryset = Hobby.objects.all().order_by("id") serializer_class = HobbySerializer pagination_class = DynamicPageSizePagination + + +class PolyViewSet(viewsets.ModelViewSet): + queryset = Poly.objects.all() + serializer_class = PolySerializer + + +class MediaFileViewSet(viewsets.ModelViewSet): + queryset = MediaFileModel.objects.all() + serializer_class = MediaFileModelSerializer + + +class GeometryViewSet(GeoMixin, viewsets.ModelViewSet): + queryset = GeometryModel.objects.all() + serializer_class = GeometryModelSerializer diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..df13580e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +from vng_api_common.generators import OpenAPISchemaGenerator + + +def generate_schema(patterns, request=None): + generator = OpenAPISchemaGenerator(patterns=patterns) + return generator.get_schema(request=request) diff --git a/tests/test_cache_headers.py b/tests/test_cache_headers.py index 6649724c..3af0bf9b 100644 --- a/tests/test_cache_headers.py +++ b/tests/test_cache_headers.py @@ -1,21 +1,18 @@ from unittest.mock import patch from django.db import transaction +from django.urls import path import pytest -from drf_yasg import openapi -from drf_yasg.generators import SchemaGenerator from rest_framework import status, viewsets from rest_framework.reverse import reverse -from rest_framework.test import APIRequestFactory -from rest_framework.views import APIView from testapp.factories import GroupFactory, HobbyFactory, PersonFactory from testapp.models import Hobby, Person from testapp.serializers import HobbySerializer from testapp.viewsets import PersonViewSet +from tests import generate_schema from vng_api_common.caching.decorators import conditional_retrieve -from vng_api_common.inspectors.cache import get_cache_headers pytestmark = pytest.mark.django_db(transaction=True) @@ -51,17 +48,20 @@ def test_200_on_stale_resource(api_client, person): def test_cache_headers_detected(): - request = APIRequestFactory().get("/api/persons/1") - request = APIView().initialize_request(request) - callback = PersonViewSet.as_view({"get": "retrieve"}, detail=True) - generator = SchemaGenerator() - - view = generator.create_view(callback, "GET", request=request) + urlpatterns = [ + path( + "person//", + PersonViewSet.as_view({"get": "retrieve"}, detail=True), + name="person-detail", + ), + ] - headers = get_cache_headers(view) + schema = generate_schema(urlpatterns) - assert "ETag" in headers - assert isinstance(headers["ETag"], openapi.Schema) + parameters = schema["paths"]["/person/{id}/"]["get"]["parameters"] + headers = [param for param in parameters if param["in"] == "header"] + assert len(headers) == 1 + assert headers[0]["name"] == "If-None-Match" @pytest.mark.django_db(transaction=False) diff --git a/tests/test_content_type_headers.py b/tests/test_content_type_headers.py index 3b779ee8..d964010e 100644 --- a/tests/test_content_type_headers.py +++ b/tests/test_content_type_headers.py @@ -3,15 +3,13 @@ """ from django.urls import path -from django.utils.translation import gettext_lazy as _ -from drf_yasg import openapi from rest_framework.parsers import JSONParser, MultiPartParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView -from vng_api_common.generators import OpenAPISchemaGenerator +from tests import generate_schema class DummyView(APIView): @@ -39,46 +37,40 @@ class MultiPartView(DummyView): def _generate_schema(): - generator = OpenAPISchemaGenerator( - info=openapi.Info("dummy", ""), - patterns=urlpatterns, - ) - return generator.get_schema() + return generate_schema(urlpatterns) def test_json_content_type(): schema = _generate_schema() - get_operation = schema.paths["/json"]["get"] - post_operation = schema.paths["/json"]["post"] + get_operation = schema["paths"]["/json"]["get"] + post_operation = schema["paths"]["/json"]["post"] - assert get_operation["parameters"] == [] + assert get_operation.get("parameters", []) == [] assert post_operation["parameters"] == [ - openapi.Parameter( - name="Content-Type", - in_=openapi.IN_HEADER, - type=openapi.TYPE_STRING, - required=True, - enum=["application/json"], - description=_("Content type of the request body."), - ) + { + "description": "Content type of the request body.", + "in": "header", + "name": "Content-Type", + "required": True, + "schema": {"enum": ["application/json"], "type": "string"}, + } ] def test_multipart_content_type(): schema = _generate_schema() - get_operation = schema.paths["/multipart"]["get"] - post_operation = schema.paths["/multipart"]["post"] + get_operation = schema["paths"]["/multipart"]["get"] + post_operation = schema["paths"]["/multipart"]["post"] - assert get_operation["parameters"] == [] + assert get_operation.get("parameters", []) == [] assert post_operation["parameters"] == [ - openapi.Parameter( - name="Content-Type", - in_=openapi.IN_HEADER, - type=openapi.TYPE_STRING, - required=True, - enum=["multipart/form-data"], - description=_("Content type of the request body."), - ) + { + "in": "header", + "name": "Content-Type", + "schema": {"type": "string", "enum": ["multipart/form-data"]}, + "description": "Content type of the request body.", + "required": True, + } ] diff --git a/tests/test_field_extensions.py b/tests/test_field_extensions.py new file mode 100644 index 00000000..bc1e7c91 --- /dev/null +++ b/tests/test_field_extensions.py @@ -0,0 +1,302 @@ +from django.urls import include, path +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers, viewsets + +from testapp.models import FkModel, Poly +from testapp.viewsets import GeometryViewSet, MediaFileViewSet, PolyViewSet +from tests import generate_schema +from vng_api_common import routers +from vng_api_common.serializers import LengthHyperlinkedRelatedField + + +class LengthHyperLinkedSerializer(serializers.ModelSerializer): + poly = LengthHyperlinkedRelatedField( + view_name="field_extention_poly-detail", + lookup_field="uuid", + queryset=Poly.objects, + min_length=20, + max_length=500, + help_text=_("test123"), + ) + + class Meta: + model = FkModel + fields = ("poly",) + + +class HyperlinkedIdentityFieldSerializer(serializers.ModelSerializer): + poly = serializers.HyperlinkedIdentityField( + view_name="poly-detail", + lookup_field="uuid", + ) + + class Meta: + model = FkModel + fields = ("poly",) + + +class LengthHyperLinkedViewSet(viewsets.ModelViewSet): + queryset = Poly.objects.all() + serializer_class = LengthHyperLinkedSerializer + + +class HyperlinkedIdentityViewSet(viewsets.ModelViewSet): + queryset = FkModel.objects.all() + serializer_class = HyperlinkedIdentityFieldSerializer + + +app_name = "field_extensions" + +router = routers.DefaultRouter(trailing_slash=False) +router.register("base64", MediaFileViewSet, basename="field_extensions_base64") +router.register("geo", GeometryViewSet, basename="field_extensions_geometry") +router.register("length", LengthHyperLinkedViewSet, basename="field_extensions_length") +router.register( + "identity", HyperlinkedIdentityViewSet, basename="field_extensions_identity" +) +router.register("poly", PolyViewSet, basename="field_extensions_poly") + +urlpatterns = [ + path("api/", include(router.urls)), +] + + +def _generate_schema(): + return generate_schema(urlpatterns) + + +def test_base64(): + schema = _generate_schema() + path = schema["components"]["schemas"] + + assert path["MediaFileModel"]["properties"]["file"] == { + "type": "string", + "format": "uri", + "description": "Download URL of the binary content.", + "nullable": True, + } + + assert path["PatchedMediaFileModel"]["properties"]["file"] == { + "type": "string", + "format": "byte", + "description": "Base64 encoded binary content.", + "nullable": True, + } + + +def test_hyper_link_related_field(): + schema = _generate_schema() + + assert schema["components"]["schemas"]["LengthHyperLinked"]["properties"][ + "poly" + ] == { + "type": "string", + "format": "uri", + "minLength": 20, + "maxLength": 500, + "description": "test123", + } + + +def test_hyper_link_identity_field(): + schema = _generate_schema() + + assert schema["components"]["schemas"]["HyperlinkedIdentityField"]["properties"][ + "poly" + ] == { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 1000, + "description": "URL-referentie naar dit object. Dit is de unieke identificatie en locatie van dit object.", + "readOnly": True, + } + + +def test_geometry_field(): + schema = _generate_schema() + schemas = schema["components"]["schemas"] + + assert schemas["GeoJSONGeometry"] == { + "title": "GeoJSONGeometry", + "type": "object", + "oneOf": [ + {"$ref": "#/components/schemas/Point"}, + {"$ref": "#/components/schemas/MultiPoint"}, + {"$ref": "#/components/schemas/LineString"}, + {"$ref": "#/components/schemas/MultiLineString"}, + {"$ref": "#/components/schemas/Polygon"}, + {"$ref": "#/components/schemas/MultiPolygon"}, + {"$ref": "#/components/schemas/GeometryCollection"}, + ], + "discriminator": {"propertyName": "type"}, + } + + assert schemas["Geometry"] == { + "type": "object", + "title": "Geometry", + "description": "GeoJSON geometry", + "required": ["type"], + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1"}, + "properties": { + "type": { + "allOf": [{"$ref": "#/components/schemas/TypeEnum"}], + "description": "The geometry type", + }, + }, + } + + assert schemas["GeometryCollection"] == { + "type": "object", + "description": "GeoJSON geometry collection", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.8"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["geometries"], + "properties": { + "geometries": { + "type": "array", + "items": {"$ref": "#/components/schemas/Geometry"}, + }, + }, + }, + ], + } + + assert schemas["LineString"] == { + "type": "object", + "description": "GeoJSON line-string geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.4"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": {"$ref": "#/components/schemas/Point2D"}, + "minItems": 2, + }, + }, + }, + ], + } + + assert schemas["MultiLineString"] == { + "type": "object", + "description": "GeoJSON multi-line-string geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.5"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/Point2D"}, + }, + }, + }, + }, + ], + } + + assert schemas["MultiPoint"] == { + "type": "object", + "description": "GeoJSON multi-point geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.3"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": {"$ref": "#/components/schemas/Point2D"}, + }, + }, + }, + ], + } + + assert schemas["MultiPolygon"] == { + "type": "object", + "description": "GeoJSON multi-polygon geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.7"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Point2D", + }, + }, + }, + }, + }, + }, + ], + } + + assert schemas["Point"] == { + "type": "object", + "description": "GeoJSON point geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.2"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": {"$ref": "#/components/schemas/Point2D"}, + }, + }, + ], + } + + assert schemas["Point2D"] == { + "type": "array", + "title": "Point2D", + "description": "A 2D point", + "items": {"type": "number"}, + "maxItems": 2, + "minItems": 2, + } + + assert schemas["Polygon"] == { + "type": "object", + "description": "GeoJSON polygon geometry", + "externalDocs": {"url": "https://tools.ietf.org/html/rfc7946#section-3.1.6"}, + "allOf": [ + {"$ref": "#/components/schemas/Geometry"}, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/Point2D"}, + }, + }, + }, + }, + ], + } diff --git a/tests/test_filter_extension.py b/tests/test_filter_extension.py new file mode 100644 index 00000000..b644ac16 --- /dev/null +++ b/tests/test_filter_extension.py @@ -0,0 +1,53 @@ +from django.urls import include, path + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import serializers, viewsets + +from testapp.models import FkModel +from testapp.serializers import PolySerializer +from tests import generate_schema +from vng_api_common import routers + + +class FkModelSerializer(serializers.ModelSerializer): + poly = PolySerializer( + required=False, + allow_null=True, + ) + + class Meta: + model = FkModel + fields = ("name", "poly") + + +class FkModelViewSet(viewsets.ModelViewSet): + """Iets dat of iemand die voor de gemeente werkzaamheden uitvoert.""" + + queryset = FkModel.objects.all() + serializer_class = FkModelSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = [ + "name", + "field_with_underscores", + "poly__name", + ] + + +app_name = "filter_extensions" + +router = routers.DefaultRouter(trailing_slash=False) +router.register("camilize", FkModelViewSet, basename="filter_extensions_camilize") + + +urlpatterns = [ + path("api/", include(router.urls)), +] + + +def test_camilize(): + schema = generate_schema(urlpatterns) + parameters = schema["paths"]["/api/camilize"]["get"]["parameters"] + + assert parameters[0]["name"] == "fieldWithUnderscores" + assert parameters[1]["name"] == "name" + assert parameters[2]["name"] == "poly__name" diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 00000000..f4c5622e --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,238 @@ +from django.urls import include, path + +from testapp.viewsets import GeometryViewSet, MediaFileViewSet +from tests import generate_schema +from vng_api_common import routers + +app_name = "schema" + +router = routers.DefaultRouter(trailing_slash=False) +router.register("base64", MediaFileViewSet, basename="schema_base64") +router.register("geo", GeometryViewSet, basename="schema_geometry") + +urlpatterns = [ + path("api/", include(router.urls)), +] + + +def _generate_schema(): + return generate_schema(urlpatterns) + + +def test_schema_root_tags(): + schema = _generate_schema() + + assert schema["paths"]["/api/base64"]["post"]["tags"] == ["api"] + assert schema["paths"]["/api/base64"]["get"]["tags"] == ["api"] + + assert schema["paths"]["/api/base64/{id}"]["get"]["tags"] == ["api"] + assert schema["paths"]["/api/base64/{id}"]["put"]["tags"] == ["api"] + assert schema["paths"]["/api/base64/{id}"]["patch"]["tags"] == ["api"] + assert schema["paths"]["/api/base64/{id}"]["delete"]["tags"] == ["api"] + + # global tag from settings + assert { + "name": "moloko_milk_bar", + "description": "Global tag description via settings", + } in schema["tags"] + + +def test_error_response(): + schema = _generate_schema() + + status_code_with_schema = { + "400": "#/components/schemas/ValidatieFout", + "401": "#/components/schemas/Fout", + "403": "#/components/schemas/Fout", + "406": "#/components/schemas/Fout", + "409": "#/components/schemas/Fout", + "410": "#/components/schemas/Fout", + "412": "#/components/schemas/Fout", + "415": "#/components/schemas/Fout", + "429": "#/components/schemas/Fout", + "500": "#/components/schemas/Fout", + } + + for status_code, ref_schema in status_code_with_schema.items(): + for method in ["get", "post"]: + assert schema["paths"]["/api/geo"][method]["responses"][status_code][ + "content" + ] == { + "application/problem+json": { + "schema": {"$ref": ref_schema}, + } + } + + assert schema["components"]["schemas"]["Fout"] == { + "type": "object", + "description": "Formaat van HTTP 4xx en 5xx fouten.", + "properties": { + "type": { + "type": "string", + "description": "URI referentie naar het type fout, bedoeld voor developers", + }, + "code": { + "type": "string", + "description": "Systeemcode die het type fout aangeeft", + }, + "title": { + "type": "string", + "description": "Generieke titel voor het type fout", + }, + "status": {"type": "integer", "description": "De HTTP status code"}, + "detail": { + "type": "string", + "description": "Extra informatie bij de fout, indien beschikbaar", + }, + "instance": { + "type": "string", + "description": "URI met referentie naar dit specifiek voorkomen van de fout. Deze kan gebruikt worden in combinatie met server logs, bijvoorbeeld.", + }, + }, + "required": ["code", "detail", "instance", "status", "title"], + } + + assert schema["components"]["schemas"]["FieldValidationError"] == { + "type": "object", + "description": "Formaat van validatiefouten.", + "properties": { + "name": { + "type": "string", + "description": "Naam van het veld met ongeldige gegevens", + }, + "code": { + "type": "string", + "description": "Systeemcode die het type fout aangeeft", + }, + "reason": { + "type": "string", + "description": "Uitleg wat er precies fout is met de gegevens", + }, + }, + "required": ["code", "name", "reason"], + } + + +def test_operation_id(): + schema = _generate_schema() + + assert ( + schema["paths"]["/api/base64/{id}"]["get"]["operationId"] + == "schema_base64_read" + ) + assert ( + schema["paths"]["/api/base64"]["post"]["operationId"] == "schema_base64_create" + ) + assert ( + schema["paths"]["/api/base64/{id}"]["put"]["operationId"] + == "schema_base64_update" + ) + assert ( + schema["paths"]["/api/base64/{id}"]["patch"]["operationId"] + == "schema_base64_partial_update" + ) + assert ( + schema["paths"]["/api/base64/{id}"]["delete"]["operationId"] + == "schema_base64_delete" + ) + + +def test_content_type_headers(): + schema = _generate_schema() + + assert { + "in": "header", + "name": "Content-Type", + "schema": {"type": "string", "enum": ["application/json"]}, + "description": "Content type of the request body.", + "required": True, + } in schema["paths"]["/api/base64"]["post"]["parameters"] + + assert { + "in": "path", + "name": "id", + "schema": {"type": "integer"}, + "description": "A unique integer value identifying this media file model.", + "required": True, + } in schema["paths"]["/api/base64/{id}"]["put"]["parameters"] + assert { + "in": "path", + "name": "id", + "schema": {"type": "integer"}, + "description": "A unique integer value identifying this media file model.", + "required": True, + } in schema["paths"]["/api/base64/{id}"]["patch"]["parameters"] + + +def test_geo_headers(): + schema = _generate_schema() + + # headers + assert schema["paths"]["/api/geo"]["get"]["responses"]["200"]["headers"][ + "Content-Crs" + ] == { + "schema": {"type": "string", "enum": ["EPSG:4326"]}, + "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).", + "required": True, + } + + # parameters + for method, path in { + "post": "/api/geo", + "put": "/api/geo/{id}", + "patch": "/api/geo/{id}", + }.items(): + assert { + "in": "header", + "name": "Accept-Crs", + "schema": {"type": "string", "enum": ["EPSG:4326"]}, + "description": "The desired 'Coordinate Reference System' (CRS) of the response data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).", + } in schema["paths"][path][method]["parameters"] + assert { + "in": "header", + "name": "Content-Crs", + "schema": {"type": "string", "enum": ["EPSG:4326"]}, + "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).", + "required": True, + } in schema["paths"][path][method]["parameters"] + + assert { + "in": "header", + "name": "Accept-Crs", + "schema": {"type": "string", "enum": ["EPSG:4326"]}, + "description": "The desired 'Coordinate Reference System' (CRS) of the response data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).", + } in schema["paths"]["/api/geo/{id}"]["put"]["parameters"] + + +def test_version_headers(): + schema = _generate_schema() + + for status_code in [ + "400", + "401", + "403", + "406", + "409", + "410", + "412", + "415", + "429", + "500", + ]: + for method in ["get", "post"]: + assert schema["paths"]["/api/geo"][method]["responses"][status_code][ + "headers" + ]["API-version"] == { + "schema": {"type": "string"}, + "description": "Geeft een specifieke API-versie aan in de context van een specifieke aanroep. Voorbeeld: 1.2.1.", + } + + +def test_location_headers(): + schema = _generate_schema() + assert schema["paths"]["/api/base64"]["post"]["responses"]["201"]["headers"][ + "Location" + ] == { + "schema": {"type": "string", "format": "uri"}, + "description": "URL waar de resource leeft.", + } diff --git a/tests/test_schema_root_tags.py b/tests/test_schema_root_tags.py deleted file mode 100644 index 4b4fea46..00000000 --- a/tests/test_schema_root_tags.py +++ /dev/null @@ -1,33 +0,0 @@ -from unittest import mock - -import pytest -from rest_framework.test import APIRequestFactory -from rest_framework.views import APIView - -from testapp.viewsets import PersonViewSet -from vng_api_common.generators import OpenAPISchemaGenerator -from vng_api_common.utils import get_view_summary - -pytestmark = pytest.mark.django_db(transaction=True) - - -def test_schema_root_tags(): - request = APIRequestFactory().get("/api/persons/1") - request = APIView().initialize_request(request) - request._request.jwt_auth = mock.Mock() - - generator = OpenAPISchemaGenerator(info=mock.Mock()) - - schema = generator.get_schema(request) - assert hasattr(schema, "tags") - - # Convert list of ordereddicts to simple dict. - tags = dict([dict(od).values() for od in schema.tags]) - assert "persons" in tags - assert tags["persons"] == "Summary\n\nMore summary" - - -def test_view_summary(): - summary = get_view_summary(PersonViewSet) - - assert summary == "Summary\n\nMore summary" diff --git a/tests/test_serializer_extensions.py b/tests/test_serializer_extensions.py new file mode 100644 index 00000000..c36e3356 --- /dev/null +++ b/tests/test_serializer_extensions.py @@ -0,0 +1,75 @@ +from django.urls import include, path + +from rest_framework import serializers, viewsets + +from testapp.models import Group +from testapp.viewsets import PolyViewSet +from tests import generate_schema +from vng_api_common import routers +from vng_api_common.serializers import GegevensGroepSerializer, NestedGegevensGroepMixin + + +class SubgroupSerializer(GegevensGroepSerializer): + class Meta: + model = Group + gegevensgroep = "subgroup" + + +class GroupSerializer(NestedGegevensGroepMixin, serializers.ModelSerializer): + subgroup = SubgroupSerializer( + required=False, + allow_null=True, + ) + + class Meta: + model = Group + fields = ( + "name", + "subgroup", + ) + + +class GroupView(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer + + +app_name = "serializer_extensions" + +router = routers.DefaultRouter(trailing_slash=False) +router.register("group", GroupView, basename="serializer_extensions_group") +router.register("poly", PolyViewSet, basename="serializer_extensions_poly") + +urlpatterns = [ + path("api/", include(router.urls)), +] + + +def _generate_schema(): + return generate_schema(urlpatterns) + + +def test_gegevensgroup(): + schema = _generate_schema() + gegevensgroup_path = schema["components"]["schemas"]["Subgroup"] + + assert "description" not in gegevensgroup_path + assert gegevensgroup_path["type"] == "object" + assert list(gegevensgroup_path["properties"].keys()) == ["field1", "field2"] + assert gegevensgroup_path["required"] == ["field1"] + + +def test_polymorphic(): + schema = _generate_schema() + gegevensgroup_path = schema["components"]["schemas"]["Poly"] + hobby = "#/components/schemas/hobby_PolySerializer" + record = "#/components/schemas/record_PolySerializer" + + assert "oneOf" in gegevensgroup_path + assert gegevensgroup_path["oneOf"][0]["$ref"] == hobby + assert gegevensgroup_path["oneOf"][1]["$ref"] == record + + assert "discriminator" in gegevensgroup_path + assert gegevensgroup_path["discriminator"]["propertyName"] == "choice" + assert gegevensgroup_path["discriminator"]["mapping"]["hobby"] == hobby + assert gegevensgroup_path["discriminator"]["mapping"]["record"] == record diff --git a/vng_api_common/api/views.py b/vng_api_common/api/views.py index 266fbb92..7690bc60 100644 --- a/vng_api_common/api/views.py +++ b/vng_api_common/api/views.py @@ -7,6 +7,7 @@ class CreateJWTSecretView(CreateAPIView): + action = "create" swagger_schema = None model = JWTSecret diff --git a/vng_api_common/apps.py b/vng_api_common/apps.py index 76aee926..73df7cb2 100644 --- a/vng_api_common/apps.py +++ b/vng_api_common/apps.py @@ -1,14 +1,10 @@ +import logging + from django.apps import AppConfig from django.db import models from django.forms.fields import CharField from django.utils.translation import gettext_lazy as _ -from drf_yasg import openapi -from drf_yasg.inspectors.field import ( - basic_type_info, - model_field_to_basic_type, - serializer_field_to_basic_type, -) from rest_framework import serializers from . import fields @@ -25,6 +21,8 @@ # is collected somewhere so there's precedent FORMAT_DURATION = "duration" +logger = logging.getLogger(__name__) + class CommonGroundAPICommonConfig(AppConfig): name = "vng_api_common" @@ -32,36 +30,21 @@ class CommonGroundAPICommonConfig(AppConfig): def ready(self): from . import checks # noqa + from . import schema # noqa from .caching import signals # noqa + from .extensions import gegevensgroep, hyperlink, polymorphic, query # noqa - patch_duration_type() register_serializer_field() set_custom_hyperlinkedmodelserializer_field() set_charfield_error_messages() ensure_text_choice_descriptions(TextChoicesWithDescriptions) - - -def patch_duration_type(): - def _patch(basic_types, _field_cls, format=None): - for index, (field_cls, basic_type) in enumerate(basic_types): - if field_cls is _field_cls: - basic_types[index] = (_field_cls, (openapi.TYPE_STRING, format)) - break - - _patch(model_field_to_basic_type, models.DurationField, FORMAT_DURATION) - _patch(basic_type_info, models.DurationField, FORMAT_DURATION) - _patch(serializer_field_to_basic_type, serializers.DurationField, FORMAT_DURATION) - _patch(basic_type_info, serializers.DurationField, FORMAT_DURATION) - - # best-effort support for relativedeltafield - if RelativeDeltaField is not None: - _patch(model_field_to_basic_type, RelativeDeltaField, FORMAT_DURATION) - _patch(basic_type_info, RelativeDeltaField, FORMAT_DURATION) + register_geojson_field_extension() + register_base64_field_extension() def register_serializer_field(): mapping = serializers.ModelSerializer.serializer_field_mapping - mapping[models.fields.DurationField] = DurationField + mapping[models.DurationField] = DurationField mapping[fields.DaysDurationField] = DurationField if RelativeDeltaField is not None: @@ -97,3 +80,37 @@ def ensure_text_choice_descriptions(text_choice_class): for cls in text_choice_class.__subclasses__(): ensure_text_choice_descriptions(cls) + + +def register_geojson_field_extension() -> None: + """ + register GeoJSONGeometry extension only if rest_framework_gis is + installed + """ + try: + from rest_framework_gis.fields import GeometryField # noqa + except ImportError: + logger.debug( + "Could not import djangorestframework-gis, skipping " + "GeometryFieldExtension registration." + ) + return + + from .extensions import geojson # noqa + + +def register_base64_field_extension() -> None: + """ + register Base64FileFileFieldExtension extension only if drf_extra_fields is + installed + """ + try: + from drf_extra_fields.fields import Base64FileField # noqa + except ImportError: + logger.debug( + "Could not import drf-extra-fields, skipping " + "Base64FileFileFieldExtension registration." + ) + return + + from .extensions import file # noqa diff --git a/vng_api_common/audittrails/utils.py b/vng_api_common/audittrails/utils.py new file mode 100644 index 00000000..6a455425 --- /dev/null +++ b/vng_api_common/audittrails/utils.py @@ -0,0 +1,44 @@ +import inspect +import logging + +from django.apps import apps + +from rest_framework import viewsets + +logger = logging.getLogger(__name__) + + +AUDIT_TRAIL_ENABLED = apps.is_installed("vng_api_common.audittrails") + + +def _view_supports_audittrail(view: viewsets.ViewSet) -> bool: + # moved from vng_api_common.inspectors + if not AUDIT_TRAIL_ENABLED: + return False + + if not hasattr(view, "action"): + logger.debug("Could not determine view action for view %r", view) + return False + + # local imports, since you get errors if you try to import non-installed app + # models + from vng_api_common.audittrails.viewsets import AuditTrailMixin + + relevant_bases = [ + base for base in view.__class__.__bases__ if issubclass(base, AuditTrailMixin) + ] + if not relevant_bases: + return False + + # check if the view action is listed in any of the audit trail mixins + action = view.action + if action == "partial_update": # partial update is self.update(partial=True) + action = "update" + + # if the current view action is not provided by any of the audit trail + # related bases, then it's not audit trail enabled + action_in_audit_bases = any( + action in dict(inspect.getmembers(base)) for base in relevant_bases + ) + + return action_in_audit_bases diff --git a/vng_api_common/conf/api.py b/vng_api_common/conf/api.py index d2156f2e..cbd7b09f 100644 --- a/vng_api_common/conf/api.py +++ b/vng_api_common/conf/api.py @@ -1,7 +1,7 @@ __all__ = [ "API_VERSION", "BASE_REST_FRAMEWORK", - "BASE_SWAGGER_SETTINGS", + "BASE_SPECTACULAR_SETTINGS", "COMMON_SPEC", "LINK_FETCHER", "GEMMA_URL_TEMPLATE", @@ -12,43 +12,30 @@ "NOTIFICATIONS_KANAAL", "NOTIFICATIONS_DISABLED", "JWT_LEEWAY", + "SECURITY_DEFINITION_NAME", "COMMONGROUND_API_COMMON_GET_DOMAIN", + "JWT_SPECTACULAR_SETTINGS", ] API_VERSION = "1.0.0-rc1" # semantic version +SECURITY_DEFINITION_NAME = "JWT-Claims" + BASE_REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "vng_api_common.schema.AutoSchema", "DEFAULT_RENDERER_CLASSES": ( "djangorestframework_camel_case.render.CamelCaseJSONRenderer", ), "DEFAULT_PARSER_CLASSES": ( "djangorestframework_camel_case.parser.CamelCaseJSONParser", ), - "DEFAULT_AUTHENTICATION_CLASSES": ( - # 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', - # 'rest_framework.authentication.SessionAuthentication', - # 'rest_framework.authentication.BasicAuthentication' - ), # there is no authentication of 'end-users', only authorization (via JWT) # of applications "DEFAULT_AUTHENTICATION_CLASSES": (), - # 'DEFAULT_PERMISSION_CLASSES': ( - # 'oauth2_provider.contrib.rest_framework.TokenHasReadWriteScope', - # # 'rest_framework.permissions.IsAuthenticated', - # # 'rest_framework.permissions.AllowAny', - # ), "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", - # - # # Generic view behavior - # 'DEFAULT_PAGINATION_CLASS': 'ztc.api.utils.pagination.HALPagination', - "DEFAULT_FILTER_BACKENDS": ( - "vng_api_common.filters.Backend", - # 'rest_framework.filters.SearchFilter', - # 'rest_framework.filters.OrderingFilter', - ), + "DEFAULT_FILTER_BACKENDS": ("vng_api_common.filters.Backend",), # # # Filtering - # 'SEARCH_PARAM': 'zoek', # 'search', "ORDERING_PARAM": "ordering", # 'ordering', # # Versioning @@ -60,34 +47,35 @@ "EXCEPTION_HANDLER": "vng_api_common.views.exception_handler", "TEST_REQUEST_DEFAULT_FORMAT": "json", } - -BASE_SWAGGER_SETTINGS = { +BASE_SPECTACULAR_SETTINGS = { "DEFAULT_GENERATOR_CLASS": "vng_api_common.generators.OpenAPISchemaGenerator", - "DEFAULT_AUTO_SCHEMA_CLASS": "vng_api_common.inspectors.view.AutoSchema", - "DEFAULT_INFO": "must.be.overridden", - "DEFAULT_FIELD_INSPECTORS": ( - # GeometryFieldInspector has external dependencies, and is opt-in - # 'vng_api_common.inspectors.geojson.GeometryFieldInspector', - "vng_api_common.inspectors.fields.HyperlinkedIdentityFieldInspector", - "vng_api_common.inspectors.fields.ReadOnlyFieldInspector", - "vng_api_common.inspectors.polymorphic.PolymorphicSerializerInspector", - "vng_api_common.inspectors.fields.GegevensGroepInspector", - "drf_yasg.inspectors.CamelCaseJSONFilter", - "drf_yasg.inspectors.RecursiveFieldInspector", - "drf_yasg.inspectors.ReferencingSerializerInspector", - "drf_yasg.inspectors.ChoiceFieldInspector", - "drf_yasg.inspectors.FileFieldInspector", - "drf_yasg.inspectors.DictFieldInspector", - "drf_yasg.inspectors.JSONFieldInspector", - "drf_yasg.inspectors.HiddenFieldInspector", - "drf_yasg.inspectors.RelatedFieldInspector", - "drf_yasg.inspectors.SerializerMethodFieldInspector", - "drf_yasg.inspectors.SimpleFieldInspector", - "drf_yasg.inspectors.StringDefaultFieldInspector", - ), - "DEFAULT_FILTER_INSPECTORS": ("vng_api_common.inspectors.query.FilterInspector",), + "SERVE_INCLUDE_SCHEMA": False, + "POSTPROCESSING_HOOKS": [ + "drf_spectacular.hooks.postprocess_schema_enums", + "drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields", + ], + "SCHEMA_PATH_PREFIX": "/api/v1", +} + +# add to SPECTACULAR_SETTINGS if you are using the AuthMiddleware +JWT_SPECTACULAR_SETTINGS = { + "APPEND_COMPONENTS": { + "securitySchemes": { + SECURITY_DEFINITION_NAME: { + "type": "http", + "bearerFormat": "JWT", + "scheme": "bearer", + } + }, + }, + "SECURITY": [ + { + SECURITY_DEFINITION_NAME: [], + } + ], } + REDOC_SETTINGS = {"EXPAND_RESPONSES": "200,201", "SPEC_URL": "openapi.json"} # See: https://github.com/Rebilly/ReDoc#redoc-options-object diff --git a/vng_api_common/inspectors/__init__.py b/vng_api_common/extensions/__init__.py similarity index 100% rename from vng_api_common/inspectors/__init__.py rename to vng_api_common/extensions/__init__.py diff --git a/vng_api_common/extensions/file.py b/vng_api_common/extensions/file.py new file mode 100644 index 00000000..5cd39d7e --- /dev/null +++ b/vng_api_common/extensions/file.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext_lazy as _ + +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.openapi import OpenApiTypes +from drf_spectacular.plumbing import build_basic_type + + +class Base64FileFileFieldExtension(OpenApiSerializerFieldExtension): + target_class = "drf_extra_fields.fields.Base64FileField" + match_subclasses = True + + def map_serializer_field(self, auto_schema, direction): + base64_schema = { + **build_basic_type(OpenApiTypes.BYTE), + "description": _("Base64 encoded binary content."), + } + + uri_schema = { + **build_basic_type(OpenApiTypes.URI), + "description": _("Download URL of the binary content."), + } + + if direction == "request": + return base64_schema + elif direction == "response": + return uri_schema if not self.target.represent_in_base64 else base64_schema diff --git a/vng_api_common/extensions/gegevensgroep.py b/vng_api_common/extensions/gegevensgroep.py new file mode 100644 index 00000000..8a996a37 --- /dev/null +++ b/vng_api_common/extensions/gegevensgroep.py @@ -0,0 +1,16 @@ +from drf_spectacular.extensions import OpenApiSerializerExtension +from drf_spectacular.openapi import AutoSchema + + +class GegevensGroepFieldExtension(OpenApiSerializerExtension): + target_class = "vng_api_common.serializers.GegevensGroepSerializer" + match_subclasses = True + + def map_serializer(self, auto_schema: AutoSchema, direction): + schema = auto_schema._map_serializer( + self.target, direction, bypass_extensions=True + ) + + del schema["description"] + + return schema diff --git a/vng_api_common/extensions/geojson.py b/vng_api_common/extensions/geojson.py new file mode 100644 index 00000000..c028c6e5 --- /dev/null +++ b/vng_api_common/extensions/geojson.py @@ -0,0 +1,270 @@ +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import ResolvedComponent + + +class GeometryFieldExtension(OpenApiSerializerFieldExtension): + target_class = "rest_framework_gis.fields.GeometryField" + match_subclasses = True + priority = 1 + + def get_name(self): + return "GeoJSONGeometry" + + def map_serializer_field(self, auto_schema, direction): + geometry = ResolvedComponent( + name="Geometry", + type=ResolvedComponent.SCHEMA, + object="Geometry", + schema={ + "type": "object", + "title": "Geometry", + "description": "GeoJSON geometry", + "required": ["type"], + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1" + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + "Feature", + "FeatureCollection", + "GeometryCollection", + ], + "description": "The geometry type", + } + }, + }, + ) + point_2d = ResolvedComponent( + name="Point2D", + type=ResolvedComponent.SCHEMA, + object="Point2D", + schema={ + "type": "array", + "title": "Point2D", + "description": "A 2D point", + "items": {"type": "number"}, + "maxItems": 2, + "minItems": 2, + }, + ) + point = ResolvedComponent( + name="Point", + type=ResolvedComponent.SCHEMA, + object="Point", + schema={ + "type": "object", + "description": "GeoJSON point geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.2" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": {"coordinates": point_2d.ref}, + }, + ], + }, + ) + + multi_point = ResolvedComponent( + name="MultiPoint", + type=ResolvedComponent.SCHEMA, + object="MultiPoint", + schema={ + "type": "object", + "description": "GeoJSON multi-point geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.3" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": {"type": "array", "items": point_2d.ref} + }, + }, + ], + }, + ) + + line_string = ResolvedComponent( + name="LineString", + type=ResolvedComponent.SCHEMA, + object="LineString", + schema={ + "type": "object", + "description": "GeoJSON line-string geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.4" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": point_2d.ref, + "minItems": 2, + } + }, + }, + ], + }, + ) + + multi_line_string = ResolvedComponent( + name="MultiLineString", + type=ResolvedComponent.SCHEMA, + object="MultiLineString", + schema={ + "type": "object", + "description": "GeoJSON multi-line-string geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.5" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": point_2d.ref, + }, + } + }, + }, + ], + }, + ) + + polygon = ResolvedComponent( + name="Polygon", + type=ResolvedComponent.SCHEMA, + object="Polygon", + schema={ + "type": "object", + "description": "GeoJSON polygon geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.6" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": point_2d.ref, + }, + } + }, + }, + ], + }, + ) + + multi_polygon = ResolvedComponent( + name="MultiPolygon", + type=ResolvedComponent.SCHEMA, + object="MultiPolygon", + schema={ + "type": "object", + "description": "GeoJSON multi-polygon geometry", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.7" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["coordinates"], + "properties": { + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": point_2d.ref, + }, + }, + } + }, + }, + ], + }, + ) + + geometry_collection = ResolvedComponent( + name="GeometryCollection", + type=ResolvedComponent.SCHEMA, + object="GeometryCollection", + schema={ + "type": "object", + "description": "GeoJSON geometry collection", + "externalDocs": { + "url": "https://tools.ietf.org/html/rfc7946#section-3.1.8" + }, + "allOf": [ + geometry.ref, + { + "type": "object", + "required": ["geometries"], + "properties": { + "geometries": {"type": "array", "items": geometry.ref} + }, + }, + ], + }, + ) + + for component in [ + geometry, + point_2d, + point, + multi_point, + line_string, + multi_line_string, + polygon, + multi_polygon, + geometry_collection, + ]: + auto_schema.registry.register_on_missing(component) + + return { + "title": "GeoJSONGeometry", + "type": "object", + "oneOf": [ + point.ref, + multi_point.ref, + line_string.ref, + multi_line_string.ref, + polygon.ref, + multi_polygon.ref, + geometry_collection.ref, + ], + "discriminator": { + "propertyName": "type", + }, + } diff --git a/vng_api_common/extensions/hyperlink.py b/vng_api_common/extensions/hyperlink.py new file mode 100644 index 00000000..7e5f27e1 --- /dev/null +++ b/vng_api_common/extensions/hyperlink.py @@ -0,0 +1,37 @@ +from django.utils.translation import gettext_lazy as _ + +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.openapi import AutoSchema + + +class HyperlinkedRelatedFieldExtension(OpenApiSerializerFieldExtension): + target_class = "vng_api_common.serializers.LengthHyperlinkedRelatedField" + match_subclasses = True + + def map_serializer_field(self, auto_schema: AutoSchema, direction): + default_schema = auto_schema._map_serializer_field( + self.target, direction, bypass_extensions=True + ) + return { + **default_schema, + "minLength": self.target.min_length, + "maxLength": self.target.max_length, + } + + +class HyperlinkedIdentityFieldExtension(OpenApiSerializerFieldExtension): + target_class = "rest_framework.serializers.HyperlinkedIdentityField" + match_subclasses = True + + def map_serializer_field(self, auto_schema: AutoSchema, direction): + default_schema = auto_schema._map_serializer_field( + self.target, direction, bypass_extensions=True + ) + return { + **default_schema, + "minLength": 1, + "maxLength": 1000, + "description": _( + "URL-referentie naar dit object. Dit is de unieke identificatie en locatie van dit object." + ), + } diff --git a/vng_api_common/extensions/polymorphic.py b/vng_api_common/extensions/polymorphic.py new file mode 100644 index 00000000..a7826c73 --- /dev/null +++ b/vng_api_common/extensions/polymorphic.py @@ -0,0 +1,68 @@ +from drf_spectacular.extensions import OpenApiSerializerExtension +from drf_spectacular.plumbing import ResolvedComponent +from drf_spectacular.settings import spectacular_settings + +from ..utils import underscore_to_camel + + +class PolymorphicSerializerExtension(OpenApiSerializerExtension): + target_class = "vng_api_common.polymorphism.PolymorphicSerializer" + match_subclasses = True + + def map_serializer(self, auto_schema, direction): + if not getattr(self.target, "discriminator", None): + raise AttributeError( + "'PolymorphicSerializer' derived serializers need to have 'discriminator' set" + ) + + discriminator = self.target.discriminator + + # resolve component with base path + base_schema = auto_schema._map_serializer( + self.target, direction, bypass_extensions=True + ) + base_name = f"Base_{self.target.__class__.__name__}" + if direction == "request" and spectacular_settings.COMPONENT_SPLIT_REQUEST: + base_name = base_name + "Request" + base_component = ResolvedComponent( + name=base_name, + type=ResolvedComponent.SCHEMA, + object=base_name, + schema=base_schema, + ) + auto_schema.registry.register_on_missing(base_component) + + components = {} + # resolve sub components and components + for resource_type, sub_serializer in discriminator.mapping.items(): + if not sub_serializer or not sub_serializer.fields: + schema = {"allOf": [base_component.ref]} + else: + sub_component = auto_schema.resolve_serializer( + sub_serializer, direction + ) + schema = {"allOf": [base_component.ref, sub_component.ref]} + + component_name = f"{resource_type.value}_{self.target.__class__.__name__}" + if direction == "request" and spectacular_settings.COMPONENT_SPLIT_REQUEST: + component_name = component_name + "Request" + component = ResolvedComponent( + name=component_name, + type=ResolvedComponent.SCHEMA, + object=component_name, + schema=schema, + ) + auto_schema.registry.register_on_missing(component) + + components[resource_type.value] = component + + return { + "oneOf": [component.ref for _, component in components.items()], + "discriminator": { + "propertyName": underscore_to_camel(discriminator.discriminator_field), + "mapping": { + resource: component.ref["$ref"] + for resource, component in components.items() + }, + }, + } diff --git a/vng_api_common/extensions/query.py b/vng_api_common/extensions/query.py new file mode 100644 index 00000000..8bc386fe --- /dev/null +++ b/vng_api_common/extensions/query.py @@ -0,0 +1,20 @@ +from drf_spectacular.contrib.django_filters import DjangoFilterExtension + +from vng_api_common.utils import underscore_to_camel + + +class CamelizeFilterExtension(DjangoFilterExtension): + priority = 1 + + def get_schema_operation_parameters(self, auto_schema, *args, **kwargs): + """ + camelize query parameters + """ + parameters = super().get_schema_operation_parameters( + auto_schema, *args, **kwargs + ) + + for parameter in parameters: + parameter["name"] = underscore_to_camel(parameter["name"]) + + return parameters diff --git a/vng_api_common/filters.py b/vng_api_common/filters.py index d5e4c9e2..f80d37d1 100644 --- a/vng_api_common/filters.py +++ b/vng_api_common/filters.py @@ -26,7 +26,6 @@ class Backend(DjangoFilterBackend): - # Taken from drf_yasg.inspectors.field.CamelCaseJSONFilter def _is_camel_case(self, view): return any( issubclass(parser, CamelCaseJSONParser) for parser in view.parser_classes diff --git a/vng_api_common/generators.py b/vng_api_common/generators.py index c84f8de4..611f6f61 100644 --- a/vng_api_common/generators.py +++ b/vng_api_common/generators.py @@ -1,16 +1,7 @@ -from collections import OrderedDict -from typing import List - -from drf_yasg import openapi -from drf_yasg.generators import ( +from drf_spectacular.generators import ( EndpointEnumerator as _EndpointEnumerator, - OpenAPISchemaGenerator as _OpenAPISchemaGenerator, + SchemaGenerator as _OpenAPISchemaGenerator, ) -from drf_yasg.utils import get_consumes, get_produces -from rest_framework.schemas.utils import is_list_view -from rest_framework.settings import api_settings - -from vng_api_common.utils import get_view_summary class EndpointEnumerator(_EndpointEnumerator): @@ -29,110 +20,18 @@ def get_allowed_methods(self, callback) -> list: class OpenAPISchemaGenerator(_OpenAPISchemaGenerator): - endpoint_enumerator_class = EndpointEnumerator - - def get_tags(self, request=None, public=False): - """Retrieve the tags for the root schema. - - :param request: the request used for filtering accessible endpoints and finding the spec URI - :param bool public: if True, all endpoints are included regardless of access through `request` - - :return: List of tags containing the tag name and a description. - """ - tags = {} + endpoint_inspector_cls = EndpointEnumerator - endpoints = self.get_endpoints(request) - for path, (view_cls, methods) in sorted(endpoints.items()): - if "{" in path: - continue - - tag = path.rsplit("/", 1)[-1] - if tag in tags: - continue - - # exclude special non-rest actions - if tag.startswith("_"): - continue - tags[tag] = get_view_summary(view_cls) - - return [ - OrderedDict([("name", operation), ("description", desc)]) - for operation, desc in sorted(tags.items()) - ] - - def get_schema(self, request=None, public=False): - """ - Rewrite parent class to add 'responses' in components + def create_view(self, callback, method, request=None): """ - endpoints = self.get_endpoints(request) - components = self.reference_resolver_class( - openapi.SCHEMA_DEFINITIONS, "responses", force_init=True - ) - self.consumes = get_consumes(api_settings.DEFAULT_PARSER_CLASSES) - self.produces = get_produces(api_settings.DEFAULT_RENDERER_CLASSES) - paths, prefix = self.get_paths(endpoints, components, request, public) - - security_definitions = self.get_security_definitions() - if security_definitions: - security_requirements = self.get_security_requirements(security_definitions) - else: - security_requirements = None - - url = self.url - if url is None and request is not None: - url = request.build_absolute_uri() - - return openapi.Swagger( - info=self.info, - paths=paths, - consumes=self.consumes or None, - produces=self.produces or None, - tags=self.get_tags(request, public), - security_definitions=security_definitions, - security=security_requirements, - _url=url, - _prefix=prefix, - _version=self.version, - **dict(components), - ) - - def get_path_parameters(self, path, view_cls): - """Return a list of Parameter instances corresponding to any templated path variables. - - :param str path: templated request path - :param type view_cls: the view class associated with the path - :return: path parameters - :rtype: list[openapi.Parameter] + workaround for HEAD method which doesn't have action """ - parameters = super().get_path_parameters(path, view_cls) - - # see if we can specify UUID a bit more - for parameter in parameters: - # the most pragmatic of checks - if not parameter.name.endswith("_uuid"): - continue - parameter.format = openapi.FORMAT_UUID - parameter.description = "Unieke resource identifier (UUID4)" - return parameters - - def get_operation_keys(self, subpath, method, view) -> List[str]: - if method != "HEAD": - return super().get_operation_keys(subpath, method, view) - - assert not is_list_view( - subpath, method, view - ), "HEAD requests are only supported on detail endpoints" - - # taken from DRF schema generation - named_path_components = [ - component - for component in subpath.strip("/").split("/") - if "{" not in component - ] + if method == "HEAD": + view = super(_OpenAPISchemaGenerator, self).create_view( + callback, method, request=request + ) + return view - return named_path_components + ["headers"] + return super().create_view(callback, method, request=request) - def get_overrides(self, view, method) -> dict: - if method == "HEAD": - return {} - return super().get_overrides(view, method) + # todo support registering and reusing Response components diff --git a/vng_api_common/inspectors/cache.py b/vng_api_common/inspectors/cache.py deleted file mode 100644 index 841f1247..00000000 --- a/vng_api_common/inspectors/cache.py +++ /dev/null @@ -1,57 +0,0 @@ -from collections import OrderedDict - -from django.utils.translation import gettext_lazy as _ - -from drf_yasg import openapi -from rest_framework.views import APIView - -from ..caching.introspection import has_cache_header - -CACHE_REQUEST_HEADERS = [ - openapi.Parameter( - name="If-None-Match", - type=openapi.TYPE_STRING, - in_=openapi.IN_HEADER, - required=False, - description=_( - "Perform conditional requests. This header should contain one or " - "multiple ETag values of resources the client has cached. If the " - "current resource ETag value is in this set, then an HTTP 304 " - "empty body will be returned. See " - "[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) " - "for details." - ), - examples={ - "oneValue": { - "summary": _("One ETag value"), - "value": '"79054025255fb1a26e4bc422aef54eb4"', - }, - "multipleValues": { - "summary": _("Multiple ETag values"), - "value": '"79054025255fb1a26e4bc422aef54eb4", "e4d909c290d0fb1ca068ffaddf22cbd0"', - }, - }, - ) -] - - -def get_cache_headers(view: APIView) -> OrderedDict: - if not has_cache_header(view): - return OrderedDict() - - return OrderedDict( - ( - ( - "ETag", - openapi.Schema( - type=openapi.TYPE_STRING, - description=_( - "De ETag berekend op de response body JSON. " - "Indien twee resources exact dezelfde ETag hebben, dan zijn " - "deze resources identiek aan elkaar. Je kan de ETag gebruiken " - "om caching te implementeren." - ), - ), - ), - ) - ) diff --git a/vng_api_common/inspectors/fields.py b/vng_api_common/inspectors/fields.py deleted file mode 100644 index f8513dd6..00000000 --- a/vng_api_common/inspectors/fields.py +++ /dev/null @@ -1,126 +0,0 @@ -import logging - -from drf_yasg import openapi -from drf_yasg.inspectors.base import NotHandled -from drf_yasg.inspectors.field import FieldInspector, InlineSerializerInspector -from rest_framework import serializers - -from ..serializers import GegevensGroepSerializer, LengthHyperlinkedRelatedField - -logger = logging.getLogger(__name__) - - -TYPES_MAP = { - str: openapi.TYPE_STRING, - int: openapi.TYPE_INTEGER, - bool: openapi.TYPE_BOOLEAN, -} - - -class ReadOnlyFieldInspector(FieldInspector): - """ - Provides conversion for derived ReadOnlyField from model fields. - - This inspector looks at the type hint to determine the type/format of - a model property. - """ - - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - SwaggerType, ChildSwaggerType = self._get_partial_types( - field, swagger_object_type, use_references, **kwargs - ) - - if ( - isinstance(field, serializers.ReadOnlyField) - and swagger_object_type == openapi.Schema - ): - prop = getattr(field.parent.Meta.model, field.source) - if not isinstance(prop, property): - return NotHandled - - return_type = prop.fget.__annotations__.get("return") - if return_type is None: # no type annotation, too bad... - logger.debug( - "Missing return type annotation for prop %s on model %s", - field.source, - field.parent.Meta.model, - ) - return NotHandled - - type_ = TYPES_MAP.get(return_type) - if type_ is None: - logger.debug("Missing type mapping for %r", return_type) - - return SwaggerType(type=type_ or openapi.TYPE_STRING) - - return NotHandled - - -class HyperlinkedIdentityFieldInspector(FieldInspector): - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - SwaggerType, ChildSwaggerType = self._get_partial_types( - field, swagger_object_type, use_references, **kwargs - ) - - if ( - isinstance(field, serializers.HyperlinkedIdentityField) - and swagger_object_type == openapi.Schema - ): - return SwaggerType( - type=openapi.TYPE_STRING, - format=openapi.FORMAT_URI, - min_length=1, - max_length=1000, - description="URL-referentie naar dit object. Dit is de unieke identificatie en locatie van dit object.", - ) - - return NotHandled - - -class HyperlinkedRelatedFieldInspector(FieldInspector): - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - SwaggerType, ChildSwaggerType = self._get_partial_types( - field, swagger_object_type, use_references, **kwargs - ) - - if ( - isinstance(field, LengthHyperlinkedRelatedField) - and swagger_object_type == openapi.Schema - ): - max_length = field.max_length - min_length = field.min_length - return SwaggerType( - type=openapi.TYPE_STRING, - format=openapi.FORMAT_URI, - min_length=min_length, - max_length=max_length, - description=field.help_text, - ) - - return NotHandled - - -class GegevensGroepInspector(InlineSerializerInspector): - def process_result(self, result, method_name, obj, **kwargs): - if not isinstance(result, openapi.Schema.OR_REF): - return result - - if not isinstance(obj, GegevensGroepSerializer): - return result - - if method_name != "field_to_swagger_object": - return result - - if not obj.allow_null: - return result - - schema = openapi.resolve_ref(result, self.components) - schema.x_nullable = True - - return result diff --git a/vng_api_common/inspectors/files.py b/vng_api_common/inspectors/files.py deleted file mode 100644 index 1d824f3f..00000000 --- a/vng_api_common/inspectors/files.py +++ /dev/null @@ -1,121 +0,0 @@ -from collections import OrderedDict - -from django.utils.translation import gettext as _ - -from drf_extra_fields.fields import Base64FieldMixin -from drf_yasg import openapi -from drf_yasg.inspectors import ( - CamelCaseJSONFilter, - FieldInspector, - NotHandled, - ViewInspector, -) -from drf_yasg.utils import filter_none, get_serializer_ref_name -from rest_framework import serializers - - -class FileFieldInspector(CamelCaseJSONFilter): - def get_schema(self, serializer): - if self.method not in ViewInspector.body_methods: - return NotHandled - - # only do this if there are base64 mixin fields - if any( - isinstance(field, Base64FieldMixin) for field in serializer.fields.values() - ): - return self.probe_field_inspectors(serializer, openapi.Schema, True) - - return NotHandled - - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - if isinstance(field, serializers.Serializer): - return self._serializer_to_swagger_object( - field, swagger_object_type, use_references, **kwargs - ) - - if not isinstance(field, Base64FieldMixin): - return NotHandled - - SwaggerType, ChildSwaggerType = self._get_partial_types( - field, swagger_object_type, use_references, **kwargs - ) - - type_b64 = SwaggerType( - type=openapi.TYPE_STRING, - format=openapi.FORMAT_BASE64, - description=_("Base64 encoded binary content."), - ) - type_uri = SwaggerType( - type=openapi.TYPE_STRING, - read_only=True, - format=openapi.FORMAT_URI, - description=_("Download URL of the binary content."), - ) - - if swagger_object_type == openapi.Schema: - # on writes, it's always b64 - if self.method in ViewInspector.body_methods: - return type_b64 - - # if not representing in base64, it's a link - return type_uri if not field.represent_in_base64 else type_b64 - - return NotHandled - - def _serializer_to_swagger_object( - self, serializer, swagger_object_type, use_references, **kwargs - ): - if self.method not in ViewInspector.body_methods: - return NotHandled - - if not any( - isinstance(field, Base64FieldMixin) for field in serializer.fields.values() - ): - return NotHandled - - SwaggerType, ChildSwaggerType = self._get_partial_types( - serializer, swagger_object_type, use_references, **kwargs - ) - - ref_name = get_serializer_ref_name(serializer) - ref_name = f"{ref_name}Data" if ref_name else None - - def make_schema_definition(): - properties = OrderedDict() - required = [] - for property_name, child in serializer.fields.items(): - prop_kwargs = {"read_only": bool(child.read_only) or None} - prop_kwargs = filter_none(prop_kwargs) - - child_schema = self.probe_field_inspectors( - child, ChildSwaggerType, use_references, **prop_kwargs - ) - properties[property_name] = child_schema - - if child.required and not getattr(child_schema, "read_only", False): - required.append(property_name) - - result = SwaggerType( - type=openapi.TYPE_OBJECT, - properties=properties, - required=required or None, - ) - if not ref_name and "title" in result: - # on an inline model, the title is derived from the field name - # but is visually displayed like the model name, which is confusing - # it is better to just remove title from inline models - del result.title - - # Provide an option to add manual paremeters to a schema - # for example, to add examples - # self.add_manual_fields(serializer, result) - return self.process_result(result, None, None) - - if not ref_name or not use_references: - return make_schema_definition() - - definitions = self.components.with_scope(openapi.SCHEMA_DEFINITIONS) - definitions.setdefault(ref_name, make_schema_definition) - return openapi.SchemaRef(definitions, ref_name) diff --git a/vng_api_common/inspectors/geojson.py b/vng_api_common/inspectors/geojson.py deleted file mode 100644 index 24025c5f..00000000 --- a/vng_api_common/inspectors/geojson.py +++ /dev/null @@ -1,360 +0,0 @@ -from collections import OrderedDict - -from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled -from rest_framework import serializers -from rest_framework_gis.fields import GeometryField - -from ..geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT - -REF_NAME_GEOJSON_GEOMETRY = "GeoJSONGeometry" - - -def register_geojson(definitions): - Geometry = openapi.Schema( - type=openapi.TYPE_OBJECT, - title="Geometry", - description="GeoJSON geometry", - required=["type"], - externalDocs=OrderedDict(url="https://tools.ietf.org/html/rfc7946#section-3.1"), - properties=OrderedDict( - ( - ( - "type", - openapi.Schema( - type=openapi.TYPE_STRING, - enum=[ - "Point", - "MultiPoint", - "LineString", - "MultiLineString", - "Polygon", - "MultiPolygon", - "Feature", - "FeatureCollection", - "GeometryCollection", - ], - description="The geometry type", - ), - ), - ) - ), - ) - definitions.set("Geometry", Geometry) - - Point2D = openapi.Schema( - type=openapi.TYPE_ARRAY, - title="Point2D", - description="A 2D point", - items=openapi.Schema(type=openapi.TYPE_NUMBER), - maxItems=2, - minItems=2, - ) - definitions.set("Point2D", Point2D) - - Point = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON point geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.2" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - (("coordinates", openapi.SchemaRef(definitions, "Point2D")),) - ), - ), - ], - ) - definitions.set("Point", Point) - - MultiPoint = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON multi-point geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.3" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - ( - ( - "coordinates", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Point2D"), - ), - ), - ) - ), - ), - ], - ) - definitions.set("MultiPoint", MultiPoint) - - LineString = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON line-string geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.4" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - ( - ( - "coordinates", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Point2D"), - minItems=2, - ), - ), - ) - ), - ), - ], - ) - definitions.set("LineString", LineString) - - MultiLineString = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON multi-line-string geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.5" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - ( - ( - "coordinates", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Point2D"), - ), - ), - ), - ) - ), - ), - ], - ) - definitions.set("MultiLineString", MultiLineString) - - Polygon = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON polygon geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.6" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - ( - ( - "coordinates", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Point2D"), - ), - ), - ), - ) - ), - ), - ], - ) - definitions.set("Polygon", Polygon) - - MultiPolygon = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON multi-polygon geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.7" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["coordinates"], - properties=OrderedDict( - ( - ( - "coordinates", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Point2D"), - ), - ), - ), - ), - ) - ), - ), - ], - ) - definitions.set("MultiPolygon", MultiPolygon) - - GeometryCollection = openapi.Schema( - type=openapi.TYPE_OBJECT, - description="GeoJSON multi-polygon geometry", - externalDocs=OrderedDict( - url="https://tools.ietf.org/html/rfc7946#section-3.1.8" - ), - allOf=[ - openapi.SchemaRef(definitions, "Geometry"), - openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["geometries"], - properties=OrderedDict( - ( - ( - "geometries", - openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.SchemaRef(definitions, "Geometry"), - ), - ), - ) - ), - ), - ], - ) - definitions.set("GeometryCollection", GeometryCollection) - - GeoJSONGeometry = openapi.Schema( - title=REF_NAME_GEOJSON_GEOMETRY, - type=openapi.TYPE_OBJECT, - oneOf=[ - openapi.SchemaRef(definitions, "Point"), - openapi.SchemaRef(definitions, "MultiPoint"), - openapi.SchemaRef(definitions, "LineString"), - openapi.SchemaRef(definitions, "MultiLineString"), - openapi.SchemaRef(definitions, "Polygon"), - openapi.SchemaRef(definitions, "MultiPolygon"), - openapi.SchemaRef(definitions, "GeometryCollection"), - ], - discriminator="type", - ) - definitions.set(REF_NAME_GEOJSON_GEOMETRY, GeoJSONGeometry) - - -class GeometryFieldInspector(FieldInspector): - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - if not isinstance(field, GeometryField): - return NotHandled - - definitions = self.components.with_scope(openapi.SCHEMA_DEFINITIONS) - - if not definitions.has("Geometry"): - register_geojson(definitions) - - return openapi.SchemaRef(definitions, REF_NAME_GEOJSON_GEOMETRY) - - def has_geo_fields(self, serializer) -> bool: - """ - Check if any of the serializer fields are a GeometryField. - - If the serializer has nested serializers, a depth-first search is done - to check if the nested serializers has `GeometryField`\ s. - """ - for field in serializer.fields.values(): - if isinstance(field, serializers.Serializer): - has_nested_geo_fields = self.probe_inspectors( - self.field_inspectors, - "has_geo_fields", - field, - {"field_inspectors": self.field_inspectors}, - ) - if has_nested_geo_fields: - return True - - elif isinstance(field, (serializers.ListSerializer, serializers.ListField)): - field = field.child - - if isinstance(field, GeometryField): - return True - - return False - - def get_request_header_parameters(self, serializer): - if not self.has_geo_fields(serializer): - return [] - - if self.method == "DELETE": - return [] - - # see also http://lyzidiamond.com/posts/4326-vs-3857 for difference - # between coordinate system and projected coordinate system - return [ - openapi.Parameter( - name=HEADER_ACCEPT, - type=openapi.TYPE_STRING, - in_=openapi.IN_HEADER, - required=True, - description="Het gewenste 'Coordinate Reference System' (CRS) van de " - "geometrie in het antwoord (response body). Volgens de " - "GeoJSON spec is WGS84 de default (EPSG:4326 is " - "hetzelfde als WGS84).", - enum=[DEFAULT_CRS], - ), - openapi.Parameter( - name=HEADER_CONTENT, - type=openapi.TYPE_STRING, - in_=openapi.IN_HEADER, - required=True, - description="Het 'Coordinate Reference System' (CRS) van de " - "geometrie in de vraag (request body). Volgens de " - "GeoJSON spec is WGS84 de default (EPSG:4326 is " - "hetzelfde als WGS84).", - enum=[DEFAULT_CRS], - ), - ] - - def get_response_headers(self, serializer, status=None): - if not self.has_geo_fields(serializer): - return None - - if int(status) != 200: - return None - - return OrderedDict( - ( - ( - HEADER_CONTENT, - openapi.Schema( - type=openapi.TYPE_STRING, - enum=[DEFAULT_CRS], - description="Het 'Coordinate Reference System' (CRS) van de " - "antwoorddata. Volgens de GeoJSON spec is WGS84 de " - "default (EPSG:4326 is hetzelfde als WGS84).", - ), - ), - ) - ) diff --git a/vng_api_common/inspectors/polymorphic.py b/vng_api_common/inspectors/polymorphic.py deleted file mode 100644 index b0aed97e..00000000 --- a/vng_api_common/inspectors/polymorphic.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Introspect polymorphic resources - -Bulk of the code taken from https://github.com/axnsan12/drf-yasg/issues/100 -""" - -from drf_yasg import openapi -from drf_yasg.errors import SwaggerGenerationError -from drf_yasg.inspectors.base import NotHandled -from drf_yasg.inspectors.field import ( - CamelCaseJSONFilter, - ReferencingSerializerInspector, -) - -from ..polymorphism import PolymorphicSerializer -from ..utils import underscore_to_camel - - -class PolymorphicSerializerInspector( - CamelCaseJSONFilter, ReferencingSerializerInspector -): - def field_to_swagger_object( - self, field, swagger_object_type, use_references, **kwargs - ): - SwaggerType, ChildSwaggerType = self._get_partial_types( - field, swagger_object_type, use_references, **kwargs - ) - - if not isinstance(field, PolymorphicSerializer): - return NotHandled - - if not getattr(field, "discriminator", None): - raise SwaggerGenerationError( - "'PolymorphicSerializer' derived serializers need to have 'discriminator' set" - ) - - base_schema_ref = super().field_to_swagger_object( - field, swagger_object_type, use_references, **kwargs - ) - if not isinstance(base_schema_ref, openapi.SchemaRef): - raise SwaggerGenerationError( - "discriminator inheritance requires model references" - ) - - base_schema = base_schema_ref.resolve(self.components) # type: openapi.Schema - base_schema.discriminator = underscore_to_camel( - field.discriminator.discriminator_field - ) - - for value, serializer in field.discriminator.mapping.items(): - if serializer is None: - allof_derived = openapi.Schema( - type=openapi.TYPE_OBJECT, all_of=[base_schema_ref] - ) - else: - derived_ref = self.probe_field_inspectors( - serializer, openapi.Schema, use_references=True - ) - if not isinstance(derived_ref, openapi.SchemaRef): - raise SwaggerGenerationError( - "discriminator inheritance requies model references" - ) - - allof_derived = openapi.Schema( - type=openapi.TYPE_OBJECT, all_of=[base_schema_ref, derived_ref] - ) - if not self.components.has(value, scope=openapi.SCHEMA_DEFINITIONS): - self.components.set( - value, allof_derived, scope=openapi.SCHEMA_DEFINITIONS - ) - - return base_schema_ref diff --git a/vng_api_common/inspectors/query.py b/vng_api_common/inspectors/query.py deleted file mode 100644 index 8b84e278..00000000 --- a/vng_api_common/inspectors/query.py +++ /dev/null @@ -1,91 +0,0 @@ -from django.db import models -from django.utils.encoding import force_str -from django.utils.translation import gettext as _ - -from django_filters.filters import BaseCSVFilter, ChoiceFilter -from drf_yasg import openapi -from drf_yasg.inspectors.query import CoreAPICompatInspector -from rest_framework.filters import OrderingFilter - -from ..filters import URLModelChoiceFilter -from ..utils import underscore_to_camel -from .utils import get_target_field - - -class FilterInspector(CoreAPICompatInspector): - """ - Filter inspector that specifies the format of URL-based fields and lists - enum options. - """ - - def get_filter_parameters(self, filter_backend): - fields = super().get_filter_parameters(filter_backend) - if isinstance(filter_backend, OrderingFilter): - return fields - - if fields: - queryset = self.view.get_queryset() - filter_class = filter_backend.get_filterset_class(self.view, queryset) - - for parameter in fields: - filter_field = filter_class.base_filters[parameter.name] - model_field = get_target_field(queryset.model, parameter.name) - parameter._filter_field = filter_field - - help_text = filter_field.extra.get( - "help_text", - getattr(model_field, "help_text", "") if model_field else "", - ) - - if isinstance(filter_field, BaseCSVFilter): - if "choices" in filter_field.extra: - schema = openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_STRING, - enum=[ - choice[0] - for choice in filter_field.extra["choices"] - ], - ), - ) - else: - schema = openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_STRING), - ) - parameter["type"] = openapi.TYPE_ARRAY - parameter["schema"] = schema - parameter["style"] = "form" - parameter["explode"] = False - elif isinstance(filter_field, URLModelChoiceFilter): - description = _("URL to the related {resource}").format( - resource=parameter.name - ) - parameter.description = help_text or description - parameter.format = openapi.FORMAT_URI - elif isinstance(filter_field, ChoiceFilter): - parameter.enum = [ - choice[0] for choice in filter_field.extra["choices"] - ] - elif model_field and isinstance(model_field, models.URLField): - parameter.format = openapi.FORMAT_URI - - if not parameter.description and help_text: - parameter.description = force_str(help_text) - - if "max_length" in filter_field.extra: - parameter.max_length = filter_field.extra["max_length"] - if "min_length" in filter_field.extra: - parameter.min_length = filter_field.extra["min_length"] - - return fields - - def process_result(self, result, method_name, obj, **kwargs): - """ - Convert snake-case to camelCase. - """ - if result and type(result) is list: - for parameter in result: - parameter.name = underscore_to_camel(parameter.name) - return result diff --git a/vng_api_common/inspectors/utils.py b/vng_api_common/inspectors/utils.py deleted file mode 100644 index a6fd0982..00000000 --- a/vng_api_common/inspectors/utils.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Optional, Type - -from django.db import models - -from rest_framework.utils.model_meta import get_field_info - - -def get_target_field(model: Type[models.Model], field: str) -> Optional[models.Field]: - """ - Retrieve the end-target that ``field`` points to. - - :param field: A string containing a lookup, potentially spanning relations. E.g.: - foo__bar__lte. - :return: A Django model field instance or `None` - """ - - start, *remaining = field.split("__") - field_info = get_field_info(model) - - # simple, non relational field? - if start in field_info.fields: - return field_info.fields[start] - - # simple relational field? - if start in field_info.forward_relations: - relation_info = field_info.forward_relations[start] - if not remaining: - return relation_info.model_field - else: - return get_target_field(relation_info.related_model, "__".join(remaining)) - - # check the reverse relations - note that the model name is used instead of model_name_set - # in the queries -> we can't just test for containment in field_info.reverse_relations - for relation_info in field_info.reverse_relations.values(): - # not sure about this - what if there are more relations with different related names? - if relation_info.related_model._meta.model_name != start: - continue - return get_target_field(relation_info.related_model, "__".join(remaining)) - - return None diff --git a/vng_api_common/inspectors/view.py b/vng_api_common/inspectors/view.py deleted file mode 100644 index dc3a658d..00000000 --- a/vng_api_common/inspectors/view.py +++ /dev/null @@ -1,547 +0,0 @@ -import inspect -import logging -from collections import OrderedDict -from itertools import chain -from typing import Optional, Tuple, Union - -from django.apps import apps -from django.conf import settings -from django.utils.translation import gettext, gettext_lazy as _ - -from drf_yasg import openapi -from drf_yasg.inspectors import SwaggerAutoSchema -from drf_yasg.utils import get_consumes -from rest_framework import exceptions, serializers, status, viewsets - -from ..constants import HEADER_AUDIT, HEADER_LOGRECORD_ID, VERSION_HEADER -from ..exceptions import Conflict, Gone, PreconditionFailed -from ..geo import GeoMixin -from ..permissions import BaseAuthRequired, get_required_scopes -from ..search import is_search_view -from ..serializers import ( - FoutSerializer, - ValidatieFoutSerializer, - add_choice_values_help_text, -) -from .cache import CACHE_REQUEST_HEADERS, get_cache_headers, has_cache_header - -logger = logging.getLogger(__name__) - -TYPE_TO_FIELDMAPPING = { - openapi.TYPE_INTEGER: serializers.IntegerField, - openapi.TYPE_NUMBER: serializers.FloatField, - openapi.TYPE_STRING: serializers.CharField, - openapi.TYPE_BOOLEAN: serializers.BooleanField, - openapi.TYPE_ARRAY: serializers.ListField, -} - -COMMON_ERRORS = [ - exceptions.AuthenticationFailed, - exceptions.NotAuthenticated, - exceptions.PermissionDenied, - exceptions.NotAcceptable, - Conflict, - Gone, - exceptions.UnsupportedMediaType, - exceptions.Throttled, - exceptions.APIException, -] - -DEFAULT_ACTION_ERRORS = { - "create": COMMON_ERRORS + [exceptions.ParseError, exceptions.ValidationError], - "list": COMMON_ERRORS, - "retrieve": COMMON_ERRORS + [exceptions.NotFound], - "update": COMMON_ERRORS - + [exceptions.ParseError, exceptions.ValidationError, exceptions.NotFound], - "partial_update": COMMON_ERRORS - + [exceptions.ParseError, exceptions.ValidationError, exceptions.NotFound], - "destroy": COMMON_ERRORS + [exceptions.NotFound], -} - -HTTP_STATUS_CODE_TITLES = { - status.HTTP_100_CONTINUE: "Continue", - status.HTTP_101_SWITCHING_PROTOCOLS: "Switching protocols", - status.HTTP_200_OK: "OK", - status.HTTP_201_CREATED: "Created", - status.HTTP_202_ACCEPTED: "Accepted", - status.HTTP_203_NON_AUTHORITATIVE_INFORMATION: "Non authoritative information", - status.HTTP_204_NO_CONTENT: "No content", - status.HTTP_205_RESET_CONTENT: "Reset content", - status.HTTP_206_PARTIAL_CONTENT: "Partial content", - status.HTTP_207_MULTI_STATUS: "Multi status", - status.HTTP_300_MULTIPLE_CHOICES: "Multiple choices", - status.HTTP_301_MOVED_PERMANENTLY: "Moved permanently", - status.HTTP_302_FOUND: "Found", - status.HTTP_303_SEE_OTHER: "See other", - status.HTTP_304_NOT_MODIFIED: "Not modified", - status.HTTP_305_USE_PROXY: "Use proxy", - status.HTTP_306_RESERVED: "Reserved", - status.HTTP_307_TEMPORARY_REDIRECT: "Temporary redirect", - status.HTTP_400_BAD_REQUEST: "Bad request", - status.HTTP_401_UNAUTHORIZED: "Unauthorized", - status.HTTP_402_PAYMENT_REQUIRED: "Payment required", - status.HTTP_403_FORBIDDEN: "Forbidden", - status.HTTP_404_NOT_FOUND: "Not found", - status.HTTP_405_METHOD_NOT_ALLOWED: "Method not allowed", - status.HTTP_406_NOT_ACCEPTABLE: "Not acceptable", - status.HTTP_407_PROXY_AUTHENTICATION_REQUIRED: "Proxy authentication required", - status.HTTP_408_REQUEST_TIMEOUT: "Request timeout", - status.HTTP_409_CONFLICT: "Conflict", - status.HTTP_410_GONE: "Gone", - status.HTTP_411_LENGTH_REQUIRED: "Length required", - status.HTTP_412_PRECONDITION_FAILED: "Precondition failed", - status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "Request entity too large", - status.HTTP_414_REQUEST_URI_TOO_LONG: "Request uri too long", - status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: "Unsupported media type", - status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE: "Requested range not satisfiable", - status.HTTP_417_EXPECTATION_FAILED: "Expectation failed", - status.HTTP_422_UNPROCESSABLE_ENTITY: "Unprocessable entity", - status.HTTP_423_LOCKED: "Locked", - status.HTTP_424_FAILED_DEPENDENCY: "Failed dependency", - status.HTTP_428_PRECONDITION_REQUIRED: "Precondition required", - status.HTTP_429_TOO_MANY_REQUESTS: "Too many requests", - status.HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE: "Request header fields too large", - status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS: "Unavailable for legal reasons", - status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal server error", - status.HTTP_501_NOT_IMPLEMENTED: "Not implemented", - status.HTTP_502_BAD_GATEWAY: "Bad gateway", - status.HTTP_503_SERVICE_UNAVAILABLE: "Service unavailable", - status.HTTP_504_GATEWAY_TIMEOUT: "Gateway timeout", - status.HTTP_505_HTTP_VERSION_NOT_SUPPORTED: "HTTP version not supported", - status.HTTP_507_INSUFFICIENT_STORAGE: "Insufficient storage", - status.HTTP_511_NETWORK_AUTHENTICATION_REQUIRED: "Network authentication required", -} - -AUDIT_TRAIL_ENABLED = apps.is_installed("vng_api_common.audittrails") - -AUDIT_REQUEST_HEADERS = [ - openapi.Parameter( - name=HEADER_LOGRECORD_ID, - type=openapi.TYPE_STRING, - in_=openapi.IN_HEADER, - required=False, - description=gettext( - "Identifier of the request, traceable throughout the network" - ), - ), - openapi.Parameter( - name=HEADER_AUDIT, - type=openapi.TYPE_STRING, - in_=openapi.IN_HEADER, - required=False, - description=gettext("Explanation why the request is done"), - ), -] - - -def response_header(description: str, type: str, format: str = None) -> OrderedDict: - header = OrderedDict( - (("schema", OrderedDict((("type", type),))), ("description", description)) - ) - if format is not None: - header["schema"]["format"] = format - return header - - -version_header = response_header( - "Geeft een specifieke API-versie aan in de context van een specifieke aanroep. Voorbeeld: 1.2.1.", - type=openapi.TYPE_STRING, -) - -location_header = response_header( - "URL waar de resource leeft.", type=openapi.TYPE_STRING, format=openapi.FORMAT_URI -) - - -def _view_supports_audittrail(view: viewsets.ViewSet) -> bool: - if not AUDIT_TRAIL_ENABLED: - return False - - if not hasattr(view, "action"): - logger.debug("Could not determine view action for view %r", view) - return False - - # local imports, since you get errors if you try to import non-installed app - # models - from ..audittrails.viewsets import AuditTrailMixin - - relevant_bases = [ - base for base in view.__class__.__bases__ if issubclass(base, AuditTrailMixin) - ] - if not relevant_bases: - return False - - # check if the view action is listed in any of the audit trail mixins - action = view.action - if action == "partial_update": # partial update is self.update(partial=True) - action = "update" - - # if the current view action is not provided by any of the audit trail - # related bases, then it's not audit trail enabled - action_in_audit_bases = any( - action in dict(inspect.getmembers(base)) for base in relevant_bases - ) - - return action_in_audit_bases - - -class ResponseRef(openapi._Ref): - def __init__(self, resolver, response_name, ignore_unresolved=False): - """ - Adds a reference to a named Response defined in the ``#/responses/`` object. - """ - assert "responses" in resolver.scopes - super().__init__( - resolver, response_name, "responses", openapi.Response, ignore_unresolved - ) - - -class AutoSchema(SwaggerAutoSchema): - @property - def model(self): - if hasattr(self.view, "queryset") and self.view.queryset is not None: - return self.view.queryset.model - - if hasattr(self.view, "get_queryset"): - qs = self.view.get_queryset() - return qs.model - return None - - @property - def _is_search_view(self): - return is_search_view(self.view) - - def get_operation_id(self, operation_keys=None) -> str: - """ - Simply return the model name as lowercase string, postfixed with the operation name. - """ - operation_keys = operation_keys or self.operation_keys - - operation_id = self.overrides.get("operation_id", "") - if operation_id: - return operation_id - - action = operation_keys[-1] - if self.model is not None: - model_name = self.model._meta.model_name - return f"{model_name}_{action}" - else: - operation_id = "_".join(operation_keys) - return operation_id - - def should_page(self): - if self._is_search_view: - return hasattr(self.view, "paginator") - return super().should_page() - - def get_request_serializer(self): - if not self._is_search_view: - return super().get_request_serializer() - - Base = self.view.get_search_input_serializer_class() - - filter_fields = [] - for filter_backend in self.view.filter_backends: - filter_fields += ( - self.probe_inspectors( - self.filter_inspectors, "get_filter_parameters", filter_backend() - ) - or [] - ) - - filters = {} - for parameter in filter_fields: - help_text = parameter.description - # we can't get the verbose_label back from the enum, so the inspector - # in vng_api_common.inspectors.fields leaves a filter field reference behind - _filter_field = getattr(parameter, "_filter_field", None) - choices = getattr(_filter_field, "extra", {}).get("choices", []) - if choices: - FieldClass = serializers.ChoiceField - extra = {"choices": choices} - value_display_mapping = add_choice_values_help_text(choices) - help_text += f"\n\n{value_display_mapping}" - else: - FieldClass = TYPE_TO_FIELDMAPPING[parameter.type] - extra = {} - - filters[parameter.name] = FieldClass( - help_text=help_text, required=parameter.required, **extra - ) - - SearchSerializer = type(Base.__name__, (Base,), filters) - return SearchSerializer() - - def _get_search_responses(self): - response_status = status.HTTP_200_OK - response_schema = self.serializer_to_schema(self.get_view_serializer()) - schema = openapi.Schema(type=openapi.TYPE_ARRAY, items=response_schema) - if self.should_page(): - schema = self.get_paginated_response(schema) or schema - return OrderedDict({str(response_status): schema}) - - def register_error_responses(self): - ref_responses = self.components.with_scope("responses") - - if not ref_responses.keys(): - # general errors - general_classes = list(chain(*DEFAULT_ACTION_ERRORS.values())) - # add geo and validation errors - exception_classes = general_classes + [ - PreconditionFailed, - exceptions.ValidationError, - ] - status_codes = sorted({e.status_code for e in exception_classes}) - - fout_schema = self.serializer_to_schema(FoutSerializer()) - validation_fout_schema = self.serializer_to_schema( - ValidatieFoutSerializer() - ) - for status_code in status_codes: - schema = ( - validation_fout_schema - if status_code == exceptions.ValidationError.status_code - else fout_schema - ) - response = openapi.Response( - description=HTTP_STATUS_CODE_TITLES.get(status_code, ""), - schema=schema, - ) - self.set_response_headers(str(status_code), response) - ref_responses.set(str(status_code), response) - - def _get_error_responses(self) -> OrderedDict: - """ - Add the appropriate possible error responses to the schema. - - E.g. - we know that HTTP 400 on a POST/PATCH/PUT leads to validation - errors, 403 to Permission Denied etc. - """ - # only supports viewsets - if not hasattr(self.view, "action"): - return OrderedDict() - - self.register_error_responses() - - action = self.view.action - if ( - action not in DEFAULT_ACTION_ERRORS and self._is_search_view - ): # similar to a CREATE - action = "create" - - # general errors - general_klasses = DEFAULT_ACTION_ERRORS.get(action) - if general_klasses is None: - logger.debug("Unknown action %s, no default error responses added") - return OrderedDict() - - exception_klasses = general_klasses[:] - # add geo and validation errors - has_validation_errors = self.get_filter_parameters() or any( - issubclass(klass, exceptions.ValidationError) for klass in exception_klasses - ) - if has_validation_errors: - exception_klasses.append(exceptions.ValidationError) - - if isinstance(self.view, GeoMixin): - exception_klasses.append(PreconditionFailed) - - status_codes = sorted({e.status_code for e in exception_klasses}) - - return OrderedDict( - [ - (status_code, ResponseRef(self.components, str(status_code))) - for status_code in status_codes - ] - ) - - def get_default_responses(self) -> OrderedDict: - if self._is_search_view: - responses = self._get_search_responses() - serializer = self.get_view_serializer() - else: - responses = super().get_default_responses() - serializer = self.get_request_serializer() or self.get_view_serializer() - - # inject any headers - _responses = OrderedDict() - custom_headers = OrderedDict() - for status_, schema in responses.items(): - if serializer is not None: - custom_headers = ( - self.probe_inspectors( - self.field_inspectors, - "get_response_headers", - serializer, - {"field_inspectors": self.field_inspectors}, - status=status_, - ) - or OrderedDict() - ) - - # add the cache headers, if applicable - for header, header_schema in get_cache_headers(self.view).items(): - custom_headers[header] = header_schema - - assert isinstance(schema, openapi.Schema.OR_REF) or schema == "" - response = openapi.Response( - description=HTTP_STATUS_CODE_TITLES.get(int(status_), ""), - schema=schema or None, - headers=custom_headers, - ) - _responses[status_] = response - - for status_code, response in self._get_error_responses().items(): - _responses[status_code] = response - - return _responses - - @staticmethod - def set_response_headers( - status_code: str, response: Union[openapi.Response, ResponseRef] - ): - if not isinstance(response, openapi.Response): - return - - response.setdefault("headers", OrderedDict()) - response["headers"][VERSION_HEADER] = version_header - - if status_code == "201": - response["headers"]["Location"] = location_header - - def get_response_schemas(self, response_serializers): - # parent class doesn't support responses as ref objects, - # so we temporary remove them - ref_responses = OrderedDict() - for status_code, serializer in response_serializers.copy().items(): - if isinstance(serializer, ResponseRef): - ref_responses[str(status_code)] = response_serializers.pop(status_code) - - responses = super().get_response_schemas(response_serializers) - - # and add them again - responses.update(ref_responses) - responses = OrderedDict(sorted(responses.items())) - - # add the Api-Version headers - for status_code, response in responses.items(): - self.set_response_headers(status_code, response) - - return responses - - def get_request_content_type_header(self) -> Optional[openapi.Parameter]: - if self.method not in ["POST", "PUT", "PATCH"]: - return None - - consumes = get_consumes(self.get_parser_classes()) - return openapi.Parameter( - name="Content-Type", - in_=openapi.IN_HEADER, - type=openapi.TYPE_STRING, - required=True, - enum=consumes, - description=_("Content type of the request body."), - ) - - def add_manual_parameters(self, parameters): - base = super().add_manual_parameters(parameters) - - content_type = self.get_request_content_type_header() - if content_type is not None: - base = [content_type] + base - - if self._is_search_view: - serializer = self.get_request_serializer() - else: - serializer = self.get_request_serializer() or self.get_view_serializer() - - extra = [] - if serializer is not None: - extra = ( - self.probe_inspectors( - self.field_inspectors, - "get_request_header_parameters", - serializer, - {"field_inspectors": self.field_inspectors}, - ) - or [] - ) - result = base + extra - - if has_cache_header(self.view): - result += CACHE_REQUEST_HEADERS - - if _view_supports_audittrail(self.view): - result += AUDIT_REQUEST_HEADERS - - return result - - def get_security(self): - """Return a list of security requirements for this operation. - - Returning an empty list marks the endpoint as unauthenticated (i.e. removes all accepted - authentication schemes). Returning ``None`` will inherit the top-level secuirty requirements. - - :return: security requirements - :rtype: list[dict[str,list[str]]]""" - permissions = self.view.get_permissions() - scope_permissions = [ - perm for perm in permissions if isinstance(perm, BaseAuthRequired) - ] - - if not scope_permissions: - return super().get_security() - - if len(permissions) != len(scope_permissions): - logger.warning( - "Can't represent all permissions in OAS for path %s and method %s", - self.path, - self.method, - ) - - required_scopes = [] - for perm in scope_permissions: - scopes = get_required_scopes(self.request, self.view) - if scopes is None: - continue - required_scopes.append(scopes) - - if not required_scopes: - return None # use global security - - scopes = [str(scope) for scope in sorted(required_scopes)] - - # operation level security - return [{settings.SECURITY_DEFINITION_NAME: scopes}] - - # all of these break if you accept method HEAD because the view.action is None - def is_list_view(self) -> bool: - if self.method == "HEAD": - return False - return super().is_list_view() - - def get_summary_and_description(self) -> Tuple[str, str]: - if self.method != "HEAD": - return super().get_summary_and_description() - - default_description = _( - "De headers voor een specifiek(e) {model_name} opvragen" - ).format(model_name=self.model._meta.model_name.upper()) - default_summary = _( - "Vraag de headers op die je bij een GET request zou krijgen." - ) - - description = self.overrides.get("operation_description", default_description) - summary = self.overrides.get("operation_summary", default_summary) - return description, summary - - # patch around drf-yasg not taking overrides into account - # TODO: contribute back in PR - def get_produces(self) -> list: - produces = super().get_produces() - return self.overrides.get("produces", produces) - - -# translations aren't picked up/defined in DRF, so we need to hook them up here -_("A page number within the paginated result set.") -_("Number of results to return per page.") diff --git a/vng_api_common/management/__init__.py b/vng_api_common/management/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/vng_api_common/management/commands/__init__.py b/vng_api_common/management/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/vng_api_common/management/commands/generate_autorisaties.py b/vng_api_common/management/commands/generate_autorisaties.py deleted file mode 100644 index d64acca8..00000000 --- a/vng_api_common/management/commands/generate_autorisaties.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging -import os - -from django.conf import settings -from django.core.management import BaseCommand -from django.template.loader import render_to_string - -from ...scopes import SCOPE_REGISTRY - - -class Command(BaseCommand): - """ - Generate a markdown file documenting the auth scopes of the component - """ - - def add_arguments(self, parser): - super().add_arguments(parser) - - parser.add_argument( - "--output-file", - dest="output_file", - default=None, - help="Name of the output file", - ) - - def handle(self, output_file, *args, **options): - scopes = sorted( - (scope for scope in SCOPE_REGISTRY if not scope.children), - key=lambda s: s.label, - ) - - template = "vng_api_common/autorisaties.md" - markdown = render_to_string( - template, - context={ - "scopes": scopes, - "project_name": settings.PROJECT_NAME, - "site_title": settings.SITE_TITLE, - }, - ) - - with open(output_file, "w") as f: - f.write(markdown) diff --git a/vng_api_common/management/commands/generate_notificaties.py b/vng_api_common/management/commands/generate_notificaties.py deleted file mode 100644 index f7806f31..00000000 --- a/vng_api_common/management/commands/generate_notificaties.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import os - -from django.conf import settings -from django.core.management import BaseCommand -from django.template.loader import render_to_string - -from notifications_api_common.kanalen import KANAAL_REGISTRY - - -class Command(BaseCommand): - """ - Generate a markdown file documenting the notification channels of the component - """ - - def add_arguments(self, parser): - super().add_arguments(parser) - - parser.add_argument( - "--output-file", - dest="output_file", - default=None, - help="Name of the output file", - ) - - def handle(self, output_file, *args, **options): - kanalen = sorted(KANAAL_REGISTRY, key=lambda s: s.label) - - template = "vng_api_common/notificaties.md" - markdown = render_to_string( - template, - context={ - "kanalen": kanalen, - "project_name": settings.PROJECT_NAME, - "site_title": settings.SITE_TITLE, - }, - ) - - with open(output_file, "w") as f: - f.write(markdown) diff --git a/vng_api_common/management/commands/generate_swagger.py b/vng_api_common/management/commands/generate_swagger.py deleted file mode 100644 index ebdd7101..00000000 --- a/vng_api_common/management/commands/generate_swagger.py +++ /dev/null @@ -1,197 +0,0 @@ -import logging -import os - -from django.apps import apps -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.exceptions import ImproperlyConfigured -from django.template.loader import render_to_string -from django.urls import NoReverseMatch, reverse -from django.utils.module_loading import import_string - -from drf_yasg import openapi -from drf_yasg.app_settings import swagger_settings -from drf_yasg.management.commands import generate_swagger -from rest_framework.settings import api_settings - -from ...version import get_major_version - - -class Table: - def __init__(self, resource: str): - self.resource = resource - self.rows = [] - - -class Row: - def __init__( - self, - label: str, - description: str, - type: str, - required: bool, - create: bool, - read: bool, - update: bool, - delete: bool, - ): - self.label = label - self.description = description - self.type = type - self.required = required - self.create = create - self.read = read - self.update = update - self.delete = delete - - -class Command(generate_swagger.Command): - """ - Patches to the provided command to modify the schema. - """ - - leave_locale_alone = True - - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument("--to-markdown-table", action="store_true") - - parser.add_argument( - "--info", dest="info", default=None, help="Path to schema info object" - ) - - parser.add_argument( - "--urlconf", - dest="urlconf", - default=None, - help="Urlconf for schema generator", - ) - - def get_mock_request(self, *args, **kwargs): - request = super().get_mock_request(*args, **kwargs) - request.version = api_settings.DEFAULT_VERSION - return request - - def write_schema(self, schema, stream, format): - del schema.host - del schema.schemes - super().write_schema(schema, stream, format) - - # need to overwrite the generator class... - def handle( - self, - output_file, - overwrite, - format, - api_url, - mock, - api_version, - user, - private, - generator_class_name, - info=None, - urlconf=None, - **options, - ): - # disable logs of WARNING and below - logging.disable(logging.WARNING) - - if info: - info = import_string(info) - else: - info = getattr(swagger_settings, "DEFAULT_INFO", None) - - if not isinstance(info, openapi.Info): - raise ImproperlyConfigured( - 'settings.SWAGGER_SETTINGS["DEFAULT_INFO"] should be an ' - "import string pointing to an openapi.Info object" - ) - - if not format: - if os.path.splitext(output_file)[1] in (".yml", ".yaml"): - format = "yaml" - format = format or "json" - - try: - api_root = reverse("api-root", kwargs={"version": get_major_version()}) - except NoReverseMatch: - api_root = reverse("api-root") - - api_url = ( - api_url - or swagger_settings.DEFAULT_API_URL # noqa - or f"http://example.com{api_root}" # noqa - ) - - if user: - # Only call get_user_model if --user was passed in order to - # avoid crashing if auth is not configured in the project - user = get_user_model().objects.get(username=user) - - mock = mock or private or (user is not None) or (api_version is not None) - if mock and not api_url: - raise ImproperlyConfigured( - "--mock-request requires an API url; either provide " - "the --url argument or set the DEFAULT_API_URL setting" - ) - - request = None - if mock: - request = self.get_mock_request(api_url, format, user) - - api_version = api_version or api_settings.DEFAULT_VERSION - if request and api_version: - request.version = api_version - - generator = self.get_schema_generator( - generator_class_name, info, settings.API_VERSION, api_url - ) - schema = self.get_schema(generator, request, not private) - - if output_file == "-": - self.write_schema(schema, self.stdout, format) - else: - with open(output_file, "w", encoding="utf8") as stream: - if options["to_markdown_table"]: - self.to_markdown_table(schema, stream) - else: - self.write_schema(schema, stream, format) - - def to_markdown_table(self, schema, stream): - template = "vng_api_common/api_schema_to_markdown_table.md" - tables = [] - - whitelist = [model._meta.object_name for model in apps.get_models()] - - for resource, definition in schema.definitions.items(): - if resource not in whitelist: - continue - - if not hasattr(definition, "properties"): - continue - - table = Table(resource) - for field, _schema in definition.properties.items(): - if isinstance(_schema, openapi.SchemaRef): - continue - required = ( - hasattr(definition, "required") and field in definition.required - ) - - readonly = getattr(_schema, "readOnly", False) - table.rows.append( - Row( - label=field, - description=getattr(_schema, "description", ""), - type=_schema.type, - required=required, - create=not readonly, - read=True, - update=not readonly, - delete=not readonly, - ) - ) - tables.append(table) - - markdown = render_to_string(template, context={"tables": tables}) - stream.write(markdown) diff --git a/vng_api_common/management/commands/patch_error_contenttypes.py b/vng_api_common/management/commands/patch_error_contenttypes.py deleted file mode 100644 index 6d502e14..00000000 --- a/vng_api_common/management/commands/patch_error_contenttypes.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Patch the content-type of error responses. - -Due to the changes between Swagger 2.0 and OpenAPI 3.0, we cannot handle -this at the Python level. -""" - -from django.core.management import BaseCommand - -import oyaml as yaml - -from ...views import ERROR_CONTENT_TYPE - - -class Command(BaseCommand): - help = "Patch the error-response content types in the OAS 3 spec" - - def add_arguments(self, parser): - parser.add_argument( - "api-spec", help="Path to the openapi spec. Will be overwritten!" - ) - - def patch_response(self, response): - content = {} - - for contenttype, _response in response["content"].items(): - contenttype = ERROR_CONTENT_TYPE - content[contenttype] = _response - - response["content"] = content - - def handle(self, **options): - source = options["api-spec"] - - # Enforce the file to be read as UTF-8 to prevent any platform - # dependent encoding. - with open(source, "r", encoding="utf8") as infile: - spec = yaml.safe_load(infile) - - for endpoint in spec["paths"].values(): - for data in endpoint.values(): - # filter the available request methods - if not "responses" in data: - continue - - for status, response in data["responses"].items(): - # Only edit the error responses which are defined directly - # and not referencing existing error responses - if not (400 <= int(status) < 600) or not "content" in response: - continue - - self.patch_response(response) - - for status, response in spec["components"]["responses"].items(): - if not (400 <= int(status) < 600): - continue - - self.patch_response(response) - - with open(source, "w", encoding="utf8") as outfile: - yaml.dump(spec, outfile, default_flow_style=False) diff --git a/vng_api_common/management/commands/use_external_components.py b/vng_api_common/management/commands/use_external_components.py deleted file mode 100644 index f1cac6f1..00000000 --- a/vng_api_common/management/commands/use_external_components.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Replace internal references to external for reusable components - -Due to the limitations of drf_yasg we cannot handle this at the Python level -""" - -import os.path - -from django.conf import settings -from django.core.management import BaseCommand - -import oyaml as yaml -import requests - - -class QuotedString(str): - pass - - -def quoted_scalar(dumper, data): - # a representer to force quotations on scalars - return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'") - - -def replace_refs(source: dict, replace_dict: dict) -> None: - for k, v in source.items(): - if isinstance(v, dict): - replace_refs(v, replace_dict) - - if k != "$ref": - continue - - if v in replace_dict: - source[k] = QuotedString(replace_dict[v]) - - -class Command(BaseCommand): - help = "Replace internal references to external for reusable components" - - def add_arguments(self, parser): - parser.add_argument( - "api-spec", help="Path to the openapi spec. Will be overwritten!" - ) - parser.add_argument( - "output", help="Path to the yaml file with external components" - ) - - def handle(self, **options): - source = options["api-spec"] - output = options["output"] - common_url = settings.COMMON_SPEC - try: - response = requests.get(common_url) - response.raise_for_status() - common_yaml = response.text - except requests.exceptions.RequestException: - return - - common_spec = yaml.safe_load(common_yaml) - common_components = common_spec["components"] - - with open(source, "r", encoding="utf8") as infile: - spec = yaml.safe_load(infile) - components = spec["components"] - refs = {} - - for scope, scope_items in components.items(): - if scope not in common_components: - continue - - for item, item_spec in scope_items.copy().items(): - if item not in common_components[scope]: - continue - - common_item_spec = common_components[scope][item] - if item_spec == common_item_spec: - # add ref to replace - ref = f"#/components/{scope}/{item}" - refs[ref] = f"{common_url}{ref}" - - # remove item from internal components - del components[scope][item] - - # remove empty components - for scope, scope_items in components.copy().items(): - if not scope_items: - del components[scope] - - # replace all refs - replace_refs(spec, refs) - - with open(output, "w", encoding="utf8") as outfile: - yaml.add_representer(QuotedString, quoted_scalar) - yaml.dump(spec, outfile, default_flow_style=False) diff --git a/vng_api_common/notifications/api/views.py b/vng_api_common/notifications/api/views.py index ba4f2f69..be523ee5 100644 --- a/vng_api_common/notifications/api/views.py +++ b/vng_api_common/notifications/api/views.py @@ -1,7 +1,7 @@ from django.conf import settings from django.utils.module_loading import import_string -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import extend_schema from notifications_api_common.api.serializers import NotificatieSerializer from notifications_api_common.constants import SCOPE_NOTIFICATIES_PUBLICEREN_LABEL from rest_framework import status @@ -18,7 +18,7 @@ class NotificationBaseView(APIView): Abstract view to receive webhooks """ - swagger_schema = None + schema = None permission_classes = (AuthScopesRequired,) required_scopes = Scope(SCOPE_NOTIFICATIES_PUBLICEREN_LABEL, private=True) @@ -26,7 +26,7 @@ class NotificationBaseView(APIView): def get_serializer(self, *args, **kwargs): return NotificatieSerializer(*args, **kwargs) - @swagger_auto_schema( + @extend_schema( responses={ 204: "", 400: ValidatieFoutSerializer, diff --git a/vng_api_common/oas.py b/vng_api_common/oas.py index 3bbb7f3a..773bb143 100644 --- a/vng_api_common/oas.py +++ b/vng_api_common/oas.py @@ -6,15 +6,14 @@ import requests import yaml -from drf_yasg import openapi TYPE_MAP = { - openapi.TYPE_OBJECT: dict, - openapi.TYPE_STRING: str, - openapi.TYPE_NUMBER: (float, int), - openapi.TYPE_INTEGER: int, - openapi.TYPE_BOOLEAN: bool, - openapi.TYPE_ARRAY: list, + "object": dict, + "string": str, + "number": (float, int), + "integer": int, + "boolean": bool, + "array": list, } diff --git a/vng_api_common/schema.py b/vng_api_common/schema.py index 9c94af79..9171e1f1 100644 --- a/vng_api_common/schema.py +++ b/vng_api_common/schema.py @@ -1,177 +1,433 @@ -import json import logging -import os -from urllib.parse import urlsplit +from typing import Dict, List, Optional, Type from django.conf import settings -from django.urls import get_script_prefix - -from drf_yasg import openapi -from drf_yasg.app_settings import swagger_settings -from drf_yasg.codecs import yaml_sane_dump, yaml_sane_load -from drf_yasg.generators import OpenAPISchemaGenerator as _OpenAPISchemaGenerator -from drf_yasg.renderers import SwaggerJSONRenderer, SwaggerYAMLRenderer, _SpecRenderer -from drf_yasg.utils import get_consumes, get_produces -from drf_yasg.views import get_schema_view -from rest_framework import exceptions, permissions -from rest_framework.response import Response -from rest_framework.settings import api_settings - -logger = logging.getLogger(__name__) +from django.utils.translation import gettext_lazy as _ +from drf_spectacular import openapi +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes +from rest_framework import exceptions, serializers, status -class OpenAPISchemaGenerator(_OpenAPISchemaGenerator): - def get_schema(self, request=None, public=False): - """ - Rewrite parent class to add 'responses' in components - """ - endpoints = self.get_endpoints(request) - components = self.reference_resolver_class( - openapi.SCHEMA_DEFINITIONS, "responses", force_init=True - ) - self.consumes = get_consumes(api_settings.DEFAULT_PARSER_CLASSES) - self.produces = get_produces(api_settings.DEFAULT_RENDERER_CLASSES) - paths, prefix = self.get_paths(endpoints, components, request, public) +from .audittrails.utils import _view_supports_audittrail +from .caching.introspection import has_cache_header +from .constants import HEADER_AUDIT, HEADER_LOGRECORD_ID, VERSION_HEADER +from .exceptions import Conflict, Gone, PreconditionFailed +from .geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT, GeoMixin +from .permissions import BaseAuthRequired, get_required_scopes +from .serializers import FoutSerializer, ValidatieFoutSerializer +from .views import ERROR_CONTENT_TYPE - security_definitions = self.get_security_definitions() - if security_definitions: - security_requirements = self.get_security_requirements(security_definitions) - else: - security_requirements = None - - url = self.url - if url is None and request is not None: - url = request.build_absolute_uri() - - return openapi.Swagger( - info=self.info, - paths=paths, - consumes=self.consumes or None, - produces=self.produces or None, - security_definitions=security_definitions, - security=security_requirements, - _url=url, - _prefix=prefix, - _version=self.version, - **dict(components), - ) - - def get_path_parameters(self, path, view_cls): - """Return a list of Parameter instances corresponding to any templated path variables. +logger = logging.getLogger(__name__) - :param str path: templated request path - :param type view_cls: the view class associated with the path - :return: path parameters - :rtype: list[openapi.Parameter] +COMMON_ERRORS = [ + exceptions.AuthenticationFailed, + exceptions.NotAuthenticated, + exceptions.PermissionDenied, + exceptions.NotAcceptable, + Conflict, + Gone, + exceptions.UnsupportedMediaType, + exceptions.Throttled, + exceptions.APIException, +] + +DEFAULT_ACTION_ERRORS = { + "create": COMMON_ERRORS + [exceptions.ParseError, exceptions.ValidationError], + "list": COMMON_ERRORS, + "retrieve": COMMON_ERRORS + [exceptions.NotFound], + "update": COMMON_ERRORS + + [exceptions.ParseError, exceptions.ValidationError, exceptions.NotFound], + "partial_update": COMMON_ERRORS + + [exceptions.ParseError, exceptions.ValidationError, exceptions.NotFound], + "destroy": COMMON_ERRORS + [exceptions.NotFound], +} + +HTTP_STATUS_CODE_TITLES = { + status.HTTP_100_CONTINUE: "Continue", + status.HTTP_101_SWITCHING_PROTOCOLS: "Switching protocols", + status.HTTP_200_OK: "OK", + status.HTTP_201_CREATED: "Created", + status.HTTP_202_ACCEPTED: "Accepted", + status.HTTP_203_NON_AUTHORITATIVE_INFORMATION: "Non authoritative information", + status.HTTP_204_NO_CONTENT: "No content", + status.HTTP_205_RESET_CONTENT: "Reset content", + status.HTTP_206_PARTIAL_CONTENT: "Partial content", + status.HTTP_207_MULTI_STATUS: "Multi status", + status.HTTP_300_MULTIPLE_CHOICES: "Multiple choices", + status.HTTP_301_MOVED_PERMANENTLY: "Moved permanently", + status.HTTP_302_FOUND: "Found", + status.HTTP_303_SEE_OTHER: "See other", + status.HTTP_304_NOT_MODIFIED: "Not modified", + status.HTTP_305_USE_PROXY: "Use proxy", + status.HTTP_306_RESERVED: "Reserved", + status.HTTP_307_TEMPORARY_REDIRECT: "Temporary redirect", + status.HTTP_400_BAD_REQUEST: "Bad request", + status.HTTP_401_UNAUTHORIZED: "Unauthorized", + status.HTTP_402_PAYMENT_REQUIRED: "Payment required", + status.HTTP_403_FORBIDDEN: "Forbidden", + status.HTTP_404_NOT_FOUND: "Not found", + status.HTTP_405_METHOD_NOT_ALLOWED: "Method not allowed", + status.HTTP_406_NOT_ACCEPTABLE: "Not acceptable", + status.HTTP_407_PROXY_AUTHENTICATION_REQUIRED: "Proxy authentication required", + status.HTTP_408_REQUEST_TIMEOUT: "Request timeout", + status.HTTP_409_CONFLICT: "Conflict", + status.HTTP_410_GONE: "Gone", + status.HTTP_411_LENGTH_REQUIRED: "Length required", + status.HTTP_412_PRECONDITION_FAILED: "Precondition failed", + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "Request entity too large", + status.HTTP_414_REQUEST_URI_TOO_LONG: "Request uri too long", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: "Unsupported media type", + status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE: "Requested range not satisfiable", + status.HTTP_417_EXPECTATION_FAILED: "Expectation failed", + status.HTTP_422_UNPROCESSABLE_ENTITY: "Unprocessable entity", + status.HTTP_423_LOCKED: "Locked", + status.HTTP_424_FAILED_DEPENDENCY: "Failed dependency", + status.HTTP_428_PRECONDITION_REQUIRED: "Precondition required", + status.HTTP_429_TOO_MANY_REQUESTS: "Too many requests", + status.HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE: "Request header fields too large", + status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS: "Unavailable for legal reasons", + status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal server error", + status.HTTP_501_NOT_IMPLEMENTED: "Not implemented", + status.HTTP_502_BAD_GATEWAY: "Bad gateway", + status.HTTP_503_SERVICE_UNAVAILABLE: "Service unavailable", + status.HTTP_504_GATEWAY_TIMEOUT: "Gateway timeout", + status.HTTP_505_HTTP_VERSION_NOT_SUPPORTED: "HTTP version not supported", + status.HTTP_507_INSUFFICIENT_STORAGE: "Insufficient storage", + status.HTTP_511_NETWORK_AUTHENTICATION_REQUIRED: "Network authentication required", +} + + +class AutoSchema(openapi.AutoSchema): + method_mapping = dict( + **openapi.AutoSchema.method_mapping, + head="headers", + ) + + def get_auth(self) -> List[Dict[str, List[str]]]: """ - parameters = super().get_path_parameters(path, view_cls) - - # see if we can specify UUID a bit more - for parameter in parameters: - # the most pragmatic of checks - if not parameter.name.endswith("_uuid"): - continue - parameter.format = openapi.FORMAT_UUID - parameter.description = "Unieke resource identifier (UUID4)" - return parameters - - -DefaultSchemaView = get_schema_view( - # validators=['flex', 'ssv'], - public=True, - permission_classes=(permissions.AllowAny,), -) - - -class OpenAPIV3RendererMixin: - def render(self, data, media_type=None, renderer_context=None): - if "openapi" in data or "swagger" in data: - if self.format == ".yaml": - return yaml_sane_dump(data, False) - elif self.format == ".json": - return json.dumps(data) - - return super().render( - data, media_type=media_type, renderer_context=renderer_context - ) - - -SPEC_RENDERERS = ( - type("SwaggerYAMLRenderer", (OpenAPIV3RendererMixin, SwaggerYAMLRenderer), {}), - type("SwaggerJSONRenderer", (OpenAPIV3RendererMixin, SwaggerJSONRenderer), {}), -) - + Return a list of security requirements for this operation. -class SchemaMixin: - """ - Always serve the v3 version, which is kept in version control. + `OpenApiAuthenticationExtension` can't be used here since it's tightly coupled + with DRF authentication classes + """ + permissions = self.view.get_permissions() + scope_permissions = [ + perm for perm in permissions if isinstance(perm, BaseAuthRequired) + ] - .. warn:: there is a risk of the generated schema not being in sync with - the code. Unfortunately, that's the tradeoff we have. We could set up - CI to check for outdated schemas. - """ + if not scope_permissions: + return super().get_auth() - schema_path = None + scopes = get_required_scopes(self.view.request, self.view) + if not scopes: + return [] - @property - def _is_openapi_v2(self) -> bool: - default = "3" if "format" in self.kwargs else "2" - version = self.request.GET.get("v", default) - return version.startswith("2") + return [{settings.SECURITY_DEFINITION_NAME: [str(scopes)]}] - def get_renderers(self): - if self._is_openapi_v2: - return super().get_renderers() - return [renderer() for renderer in SPEC_RENDERERS] + def get_operation_id(self): + """ + Use view basename as a base for operation_id + """ + if hasattr(self.view, "basename"): + basename = self.view.basename + action = "head" if self.method == "HEAD" else self.view.action + # make compatible with old OAS + if action == "destroy": + action = "delete" + elif action == "retrieve": + action = "read" + + return f"{basename}_{action}" + return super().get_operation_id() + + def get_error_responses(self) -> Dict[int, Type[serializers.Serializer]]: + """ + return dictionary of error codes and correspondent error serializers + - define status codes based on exceptions for each endpoint + - define error serializers based on status code + """ - def get_schema_path(self) -> str: - return self.schema_path or os.path.join( - settings.BASE_DIR, "src", "openapi.yaml" + # only supports viewsets + action = getattr(self.view, "action", None) + if not action: + return {} + + # define status codes for the action based on potential exceptions + # general errors + general_klasses = DEFAULT_ACTION_ERRORS.get(action) + if general_klasses is None: + logger.debug("Unknown action %s, no default error responses added") + return {} + + exception_klasses = general_klasses[:] + # add geo and validation errors + has_validation_errors = action == "list" or any( + issubclass(klass, exceptions.ValidationError) for klass in exception_klasses ) - - def get(self, request, version="", *args, **kwargs): - if self._is_openapi_v2: - version = request.version or version or "" - if isinstance(request.accepted_renderer, _SpecRenderer): - generator = self.generator_class( - getattr(self, "info", swagger_settings.DEFAULT_INFO), - version, - None, - None, - None, - ) + if has_validation_errors: + exception_klasses.append(exceptions.ValidationError) + + if isinstance(self.view, GeoMixin): + exception_klasses.append(PreconditionFailed) + + status_codes = sorted({e.status_code for e in exception_klasses}) + + # choose serializer based on the status code + responses = {} + for status_code in status_codes: + error_serializer = ( + ValidatieFoutSerializer + if status_code == exceptions.ValidationError.status_code + else FoutSerializer + ) + responses[status_code] = error_serializer + + return responses + + def get_response_serializers( + self, + ) -> Dict[int, Optional[Type[serializers.Serializer]]]: + """append error serializers""" + response_serializers = super().get_response_serializers() + + if self.method == "HEAD": + return {200: None} + + if self.method == "DELETE": + status_code = 204 + serializer = None + elif self._is_create_operation(): + status_code = 201 + serializer = response_serializers + else: + status_code = 200 + serializer = response_serializers + + responses = { + status_code: serializer, + **self.get_error_responses(), + } + return responses + + def _get_response_for_code( + self, serializer, status_code, media_types=None, direction="response" + ): + """ + choose media types and set descriptions + add custom response for expand + """ + if not media_types: + if int(status_code) >= 400: + media_types = [ERROR_CONTENT_TYPE] else: - generator = self.generator_class( - getattr(self, "info", swagger_settings.DEFAULT_INFO), - version, - None, - patterns=[], - ) - - schema = generator.get_schema(request, self.public) - if schema is None: - raise exceptions.PermissionDenied() # pragma: no cover - return Response(schema) + media_types = ["application/json"] - # serve the staticically included V3 schema - SCHEMA_PATH = self.get_schema_path() - with open(SCHEMA_PATH, "r") as infile: - schema = yaml_sane_load(infile) - - # fix the servers - for server in schema["servers"]: - split_url = urlsplit(server["url"]) - if split_url.netloc: - continue - - prefix = get_script_prefix() - if prefix.endswith("/"): - prefix = prefix[:-1] - server_path = f"{prefix}{server['url']}" - server["url"] = request.build_absolute_uri(server_path) - - return Response(data=schema, headers={"X-OAS-Version": schema["openapi"]}) + response = super()._get_response_for_code( + serializer, status_code, media_types, direction + ) + # add description based on the status code + if not response.get("description"): + response["description"] = HTTP_STATUS_CODE_TITLES.get(int(status_code), "") + return response + + def get_override_parameters(self): + """Add request and response headers""" + version_headers = self.get_version_headers() + content_type_headers = self.get_content_type_headers() + cache_headers = self.get_cache_headers() + log_headers = self.get_log_headers() + location_headers = self.get_location_headers() + geo_headers = self.get_geo_headers() + return ( + version_headers + + content_type_headers + + cache_headers + + log_headers + + location_headers + + geo_headers + ) -class SchemaView(SchemaMixin, DefaultSchemaView): - pass + 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_content_type_headers(self) -> List[OpenApiParameter]: + if self.method not in ["POST", "PUT", "PATCH"]: + return [] + + mime_type_enum = [ + cls.media_type + for cls in self.view.parser_classes + if hasattr(cls, "media_type") + ] + + return [ + OpenApiParameter( + name="Content-Type", + type=str, + location=OpenApiParameter.HEADER, + description=_("Content type of the request body."), + enum=mime_type_enum, + required=True, + ) + ] + + def get_cache_headers(self) -> List[OpenApiParameter]: + """ + support ETag headers + """ + if not has_cache_header(self.view): + return [] + + return [ + OpenApiParameter( + name="If-None-Match", + type=str, + location=OpenApiParameter.HEADER, + required=False, + description=_( + "Perform conditional requests. This header should contain one or " + "multiple ETag values of resources the client has cached. If the " + "current resource ETag value is in this set, then an HTTP 304 " + "empty body will be returned. See " + "[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) " + "for details." + ), + examples=[ + OpenApiExample( + name="oneValue", + summary=_("One ETag value"), + value='"79054025255fb1a26e4bc422aef54eb4"', + ), + OpenApiExample( + name="multipleValues", + summary=_("Multiple ETag values"), + value='"79054025255fb1a26e4bc422aef54eb4", "e4d909c290d0fb1ca068ffaddf22cbd0"', + ), + ], + ), + OpenApiParameter( + name="ETag", + type=str, + location=OpenApiParameter.HEADER, + response=[200], + description=_( + "De ETag berekend op de response body JSON. " + "Indien twee resources exact dezelfde ETag hebben, dan zijn " + "deze resources identiek aan elkaar. Je kan de ETag gebruiken " + "om caching te implementeren." + ), + ), + ] + + def get_location_headers(self) -> List[OpenApiParameter]: + return [ + OpenApiParameter( + name="Location", + type=OpenApiTypes.URI, + location=OpenApiParameter.HEADER, + description=_("URL waar de resource leeft."), + response=[201], + ), + ] + + def get_geo_headers(self) -> List[OpenApiParameter]: + if not isinstance(self.view, GeoMixin): + return [] + + request_headers = [] + if self.method != "DELETE": + request_headers.append( + OpenApiParameter( + name=HEADER_ACCEPT, + type=str, + location=OpenApiParameter.HEADER, + required=False, + description=_( + "The desired 'Coordinate Reference System' (CRS) of the response data. " + "According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 " + "is the same as WGS84)." + ), + enum=[DEFAULT_CRS], + ) + ) + + if self.method in ("POST", "PUT", "PATCH"): + request_headers.append( + OpenApiParameter( + name=HEADER_CONTENT, + type=str, + location=OpenApiParameter.HEADER, + required=True, + 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)." + ), + enum=[DEFAULT_CRS], + ), + ) + + response_headers = [ + OpenApiParameter( + name=HEADER_CONTENT, + type=str, + location=OpenApiParameter.HEADER, + required=True, + 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)." + ), + enum=[DEFAULT_CRS], + response=[200, 201], + ) + ] + + return request_headers + response_headers + + def get_log_headers(self) -> List[OpenApiParameter]: + if not _view_supports_audittrail(self.view): + return [] + + return [ + OpenApiParameter( + name=HEADER_LOGRECORD_ID, + type=str, + location=OpenApiParameter.HEADER, + required=False, + description=_( + "Identifier of the request, traceable throughout the network" + ), + ), + OpenApiParameter( + name=HEADER_AUDIT, + type=str, + location=OpenApiParameter.HEADER, + required=False, + description=_("Explanation why the request is done"), + ), + ] + + def get_summary(self): + if self.method == "HEAD": + return _("De headers voor een specifiek(e) %(model)s opvragen ") % { + "model": self.view.queryset.model._meta.verbose_name.upper() + } + return super().get_summary() + + def get_description(self): + if self.method == "HEAD": + return _("Vraag de headers op die je bij een GET request zou krijgen.") + return super().get_description() diff --git a/vng_api_common/templates/vng_api_common/api_schema_to_markdown_table.md b/vng_api_common/templates/vng_api_common/api_schema_to_markdown_table.md deleted file mode 100644 index 13537b88..00000000 --- a/vng_api_common/templates/vng_api_common/api_schema_to_markdown_table.md +++ /dev/null @@ -1,16 +0,0 @@ -{% load vng_api_common %}# Resources - -Dit document beschrijft de (RGBZ-)objecttypen die als resources ontsloten -worden met de beschikbare attributen. - -{% for table in tables %} -## {{ table.resource }} - -Objecttype op [GEMMA Online]({{ table.resource|gemmaonline_url }}) - -| Attribuut | Omschrijving | Type | Verplicht | CRUD* | -| --- | --- | --- | --- | --- |{% for row in table.rows %} -| {{ row.label|md_table_cell }} | {{ row.description|md_table_cell }} | {{ row.type|md_table_cell }} | {{ row.required|yesno:"ja,nee" }} | {{ row|crud }} |{% endfor %} -{% endfor %} - -* Create, Read, Update, Delete diff --git a/vng_api_common/templates/vng_api_common/autorisaties.md b/vng_api_common/templates/vng_api_common/autorisaties.md deleted file mode 100644 index 116daa18..00000000 --- a/vng_api_common/templates/vng_api_common/autorisaties.md +++ /dev/null @@ -1,15 +0,0 @@ -{% load vng_api_common markup_tags %} -# Autorisaties -## Scopes voor {{ project_name }} API - -Scopes worden typisch per component gedefinieerd en geven aan welke rechten er zijn. -Zie de repository van de [Autorisaties API](https://github.com/VNG-Realisatie/autorisaties-api) - -{% for scope in scopes %} -### {{ scope.label }} - -**Scope** -`{{ scope.label }}` - -{{ scope.description|default:"" }} -{% endfor %} diff --git a/vng_api_common/templates/vng_api_common/notificaties.md b/vng_api_common/templates/vng_api_common/notificaties.md deleted file mode 100644 index 8cc6c412..00000000 --- a/vng_api_common/templates/vng_api_common/notificaties.md +++ /dev/null @@ -1,24 +0,0 @@ -## Notificaties -## Berichtkenmerken voor {{ project_name }} API - -Kanalen worden typisch per component gedefinieerd. Producers versturen berichten op bepaalde kanalen, -consumers ontvangen deze. Consumers abonneren zich via een notificatiecomponent (zoals {{ 'https://notificaties-api.vng.cloud/api/v1/schema/'|urlize }}) op berichten. - -Hieronder staan de kanalen beschreven die door deze component gebruikt worden, met de kenmerken bij elk bericht. - -De architectuur van de notificaties staat beschreven op {{ 'https://github.com/VNG-Realisatie/notificaties-api'|urlize }}. - -{% for kanaal in kanalen %} -### {{ kanaal.label }} - -**Kanaal** -`{{ kanaal.label }}` - -{{ kanaal.description|default:""|urlize }} - -**Resources en acties** - -{% for resource, actions in kanaal.get_usage %} -* {{ resource }}: {{ actions|join:", " }} -{% endfor %} -{% endfor %} diff --git a/vng_api_common/views.py b/vng_api_common/views.py index 65924d19..7db16aef 100644 --- a/vng_api_common/views.py +++ b/vng_api_common/views.py @@ -15,7 +15,7 @@ from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler -from vng_api_common.client import Client, ClientError +from vng_api_common.client import Client from . import exceptions from .compat import sentry_client