From a3d1d6714d359744ffdb0418e6ecda8904049640 Mon Sep 17 00:00:00 2001 From: William Conti <58711692+wconti27@users.noreply.github.com> Date: Tue, 13 Aug 2024 13:19:26 -0400 Subject: [PATCH] fix(django): recreate response stream to prevent reading from empty response body (#10137) Fixes customer escalation. Customer was using the [Spyne library](https://github.com/arskom/spyne) with Django and making SOAP requests towards it. The problem was found to be that Django integration was reading the response body to set some span tags. However, later in the callstack, Spyne also tries to read the response body (now empty since the stream was fully read). This PR copies/recreates the response body to prevent this error. - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) (cherry picked from commit 20b8c2b30da25670dc5bc0f8632bd25b4581c874) --- .riot/requirements/11065bb.txt | 77 +++++++++++++++++++ .riot/requirements/1d3001d.txt | 73 ++++++++++++++++++ .riot/requirements/1ef9f39.txt | 73 ++++++++++++++++++ .riot/requirements/1fe2c8e.txt | 77 +++++++++++++++++++ .riot/requirements/2848d2c.txt | 76 ++++++++++++++++++ .riot/requirements/cbc433f.txt | 75 ++++++++++++++++++ .riot/requirements/d5fcd88.txt | 76 ++++++++++++++++++ ddtrace/_trace/trace_handlers.py | 4 +- ddtrace/contrib/django/patch.py | 4 +- ddtrace/contrib/django/utils.py | 14 ++++ ...exhausted-by-ddtrace-eb25702730c20e5e.yaml | 4 + riotfile.py | 2 + tests/contrib/django/soap/__init__.py | 0 tests/contrib/django/soap/apps.py | 6 ++ tests/contrib/django/soap/models.py | 51 ++++++++++++ tests/contrib/django/soap/services.py | 36 +++++++++ tests/contrib/django/test_django_wsgi.py | 44 +++++++++-- tests/contrib/django/utils.py | 14 ++++ 18 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 .riot/requirements/11065bb.txt create mode 100644 .riot/requirements/1d3001d.txt create mode 100644 .riot/requirements/1ef9f39.txt create mode 100644 .riot/requirements/1fe2c8e.txt create mode 100644 .riot/requirements/2848d2c.txt create mode 100644 .riot/requirements/cbc433f.txt create mode 100644 .riot/requirements/d5fcd88.txt create mode 100644 releasenotes/notes/fix-django-stream-body-exhausted-by-ddtrace-eb25702730c20e5e.yaml create mode 100644 tests/contrib/django/soap/__init__.py create mode 100644 tests/contrib/django/soap/apps.py create mode 100644 tests/contrib/django/soap/models.py create mode 100644 tests/contrib/django/soap/services.py create mode 100644 tests/contrib/django/utils.py diff --git a/.riot/requirements/11065bb.txt b/.riot/requirements/11065bb.txt new file mode 100644 index 00000000000..810ef364a30 --- /dev/null +++ b/.riot/requirements/11065bb.txt @@ -0,0 +1,77 @@ +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/11065bb.in +# +arrow==1.3.0 +asgiref==3.8.1 +attrs==24.2.0 +autobahn==23.1.2 +automat==22.10.0 +backports-zoneinfo==0.2.1 +blessed==1.20.0 +certifi==2024.7.4 +cffi==1.17.0 +channels==4.1.0 +charset-normalizer==3.3.2 +constantly==23.10.4 +coverage[toml]==7.6.1 +cryptography==43.0.0 +daphne==4.1.2 +django==4.2.15 +django-configurations==2.5.1 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +exceptiongroup==1.2.2 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +importlib-metadata==8.2.0 +incremental==24.7.2 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.3.0 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg==3.2.1 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +pycparser==2.22 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==8.3.2 +pytest-cov==5.0.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.14.0 +pytest-randomly==3.15.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==24.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.5.1 +tomli==2.0.1 +twisted[tls]==24.7.0 +txaio==23.1.1 +types-python-dateutil==2.9.0.20240316 +typing-extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +zeep==4.2.1 +zipp==3.20.0 +zope-interface==7.0.1 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==72.1.0 \ No newline at end of file diff --git a/.riot/requirements/1d3001d.txt b/.riot/requirements/1d3001d.txt new file mode 100644 index 00000000000..9ec69bc30dc --- /dev/null +++ b/.riot/requirements/1d3001d.txt @@ -0,0 +1,73 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1d3001d.in +# +arrow==1.3.0 +asgiref==3.8.1 +attrs==24.2.0 +autobahn==24.4.2 +automat==22.10.0 +blessed==1.20.0 +certifi==2024.7.4 +cffi==1.17.0 +channels==4.1.0 +charset-normalizer==3.3.2 +constantly==23.10.4 +coverage[toml]==7.6.1 +cryptography==43.0.0 +daphne==4.1.2 +django==4.2.15 +django-configurations==2.5.1 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +incremental==24.7.2 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.2.2 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg==3.2.1 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +pycparser==2.22 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==8.3.2 +pytest-cov==5.0.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.14.0 +pytest-randomly==3.15.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==24.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.5.1 +twisted[tls]==24.3.0 +txaio==23.1.1 +types-python-dateutil==2.9.0.20240316 +typing-extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +zeep==4.2.1 +zope-interface==7.0.1 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==72.1.0 \ No newline at end of file diff --git a/.riot/requirements/1ef9f39.txt b/.riot/requirements/1ef9f39.txt new file mode 100644 index 00000000000..18718025ebb --- /dev/null +++ b/.riot/requirements/1ef9f39.txt @@ -0,0 +1,73 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1ef9f39.in +# +arrow==1.3.0 +asgiref==3.8.1 +attrs==24.2.0 +autobahn==24.4.2 +automat==22.10.0 +blessed==1.20.0 +certifi==2024.7.4 +cffi==1.17.0 +channels==4.1.0 +charset-normalizer==3.3.2 +constantly==23.10.4 +coverage[toml]==7.6.1 +cryptography==43.0.0 +daphne==4.1.2 +django==4.2.15 +django-configurations==2.5.1 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +incremental==24.7.2 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.3.0 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg==3.2.1 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +pycparser==2.22 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==8.3.2 +pytest-cov==5.0.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.14.0 +pytest-randomly==3.15.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==24.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.5.1 +twisted[tls]==24.7.0 +txaio==23.1.1 +types-python-dateutil==2.9.0.20240316 +typing-extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +zeep==4.2.1 +zope-interface==7.0.1 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==72.1.0 \ No newline at end of file diff --git a/.riot/requirements/1fe2c8e.txt b/.riot/requirements/1fe2c8e.txt new file mode 100644 index 00000000000..db6061367e8 --- /dev/null +++ b/.riot/requirements/1fe2c8e.txt @@ -0,0 +1,77 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/1fe2c8e.in +# +arrow==1.3.0 +asgiref==3.8.1 +attrs==24.2.0 +autobahn==23.1.2 +automat==22.10.0 +backports-zoneinfo==0.2.1 +blessed==1.20.0 +certifi==2024.7.4 +cffi==1.17.0 +channels==4.1.0 +charset-normalizer==3.3.2 +constantly==23.10.4 +coverage[toml]==7.6.1 +cryptography==43.0.0 +daphne==4.1.2 +django==4.2.15 +django-configurations==2.5.1 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +exceptiongroup==1.2.2 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +importlib-metadata==8.2.0 +incremental==24.7.2 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.3.0 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +pycparser==2.22 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==8.3.2 +pytest-cov==5.0.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.14.0 +pytest-randomly==3.15.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==24.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.5.1 +tomli==2.0.1 +twisted[tls]==24.7.0 +txaio==23.1.1 +types-python-dateutil==2.9.0.20240316 +typing-extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +zeep==4.2.1 +zipp==3.20.0 +zope-interface==7.0.1 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==72.1.0 \ No newline at end of file diff --git a/.riot/requirements/2848d2c.txt b/.riot/requirements/2848d2c.txt new file mode 100644 index 00000000000..7fce8123246 --- /dev/null +++ b/.riot/requirements/2848d2c.txt @@ -0,0 +1,76 @@ +# +# This file is autogenerated by pip-compile with Python 3.7 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/2848d2c.in +# +arrow==1.2.3 +asgiref==3.7.2 +attrs==24.2.0 +autobahn==23.1.2 +automat==22.10.0 +blessed==1.20.0 +cached-property==1.5.2 +certifi==2024.7.4 +cffi==1.15.1 +channels==3.0.5 +charset-normalizer==3.3.2 +constantly==15.1.0 +coverage[toml]==7.2.7 +cryptography==43.0.0 +daphne==3.0.2 +django==3.2.25 +django-configurations==2.4.2 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +exceptiongroup==1.2.2 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +importlib-metadata==6.7.0 +incremental==22.10.0 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.3.0 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.0 +platformdirs==4.0.0 +pluggy==1.2.0 +psycopg2-binary==2.9.9 +pyasn1==0.5.1 +pyasn1-modules==0.3.0 +pycparser==2.21 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==7.4.4 +pytest-cov==4.1.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.11.1 +pytest-randomly==3.12.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.31.0 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==21.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.4.4 +tomli==2.0.1 +twisted[tls]==23.8.0 +txaio==23.1.1 +typing-extensions==4.7.1 +urllib3==2.0.7 +wcwidth==0.2.13 +zeep==4.2.1 +zipp==3.15.0 +zope-interface==6.4.post2 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==68.0.0 \ No newline at end of file diff --git a/.riot/requirements/cbc433f.txt b/.riot/requirements/cbc433f.txt new file mode 100644 index 00000000000..ceacdf8724b --- /dev/null +++ b/.riot/requirements/cbc433f.txt @@ -0,0 +1,75 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --no-annotate .riot/requirements/cbc433f.in +# +arrow==1.3.0 +asgiref==3.8.1 +attrs==24.2.0 +autobahn==24.4.2 +automat==22.10.0 +blessed==1.20.0 +certifi==2024.7.4 +cffi==1.17.0 +channels==4.1.0 +charset-normalizer==3.3.2 +constantly==23.10.4 +coverage[toml]==7.6.1 +cryptography==43.0.0 +daphne==4.1.2 +django==4.2.15 +django-configurations==2.5.1 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +exceptiongroup==1.2.2 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +incremental==24.7.2 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.2.2 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg==3.2.1 +psycopg2-binary==2.9.9 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +pycparser==2.22 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==8.3.2 +pytest-cov==5.0.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.14.0 +pytest-randomly==3.15.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==24.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.5.1 +tomli==2.0.1 +twisted[tls]==24.3.0 +txaio==23.1.1 +types-python-dateutil==2.9.0.20240316 +typing-extensions==4.12.2 +urllib3==2.2.2 +wcwidth==0.2.13 +zeep==4.2.1 +zope-interface==7.0.1 + +# The following packages are considered to be unsafe in a requirements file: +# setuptools \ No newline at end of file diff --git a/.riot/requirements/d5fcd88.txt b/.riot/requirements/d5fcd88.txt new file mode 100644 index 00000000000..fb526189336 --- /dev/null +++ b/.riot/requirements/d5fcd88.txt @@ -0,0 +1,76 @@ +# +# This file is autogenerated by pip-compile with Python 3.7 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/d5fcd88.in +# +arrow==1.2.3 +asgiref==3.7.2 +attrs==24.2.0 +autobahn==23.1.2 +automat==22.10.0 +blessed==1.20.0 +cached-property==1.5.2 +certifi==2024.7.4 +cffi==1.15.1 +channels==4.0.0 +charset-normalizer==3.3.2 +constantly==15.1.0 +coverage[toml]==7.2.7 +cryptography==43.0.0 +daphne==4.0.0 +django==3.2.25 +django-configurations==2.4.2 +django-picklefield==3.2 +django-pylibmc==0.6.1 +django-q==1.3.6 +django-redis==4.5.0 +exceptiongroup==1.2.2 +hyperlink==21.0.0 +hypothesis==6.45.0 +idna==3.7 +importlib-metadata==6.7.0 +incremental==22.10.0 +iniconfig==2.0.0 +isodate==0.6.1 +lxml==5.3.0 +mock==5.1.0 +opentracing==2.4.0 +packaging==24.0 +platformdirs==4.0.0 +pluggy==1.2.0 +psycopg2-binary==2.9.9 +pyasn1==0.5.1 +pyasn1-modules==0.3.0 +pycparser==2.21 +pylibmc==1.6.3 +pyopenssl==24.2.1 +pytest==7.4.4 +pytest-cov==4.1.0 +pytest-django[testing]==3.10.0 +pytest-mock==3.11.1 +pytest-randomly==3.12.0 +python-dateutil==2.9.0.post0 +python-memcached==1.62 +pytz==2024.1 +redis==2.10.6 +requests==2.31.0 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +service-identity==21.1.0 +six==1.16.0 +sortedcontainers==2.4.0 +spyne==2.14.0 +sqlparse==0.4.4 +tomli==2.0.1 +twisted[tls]==23.8.0 +txaio==23.1.1 +typing-extensions==4.7.1 +urllib3==2.0.7 +wcwidth==0.2.13 +zeep==4.2.1 +zipp==3.15.0 +zope-interface==6.4.post2 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==68.0.0 \ No newline at end of file diff --git a/ddtrace/_trace/trace_handlers.py b/ddtrace/_trace/trace_handlers.py index 3fee65b575e..198627645f1 100644 --- a/ddtrace/_trace/trace_handlers.py +++ b/ddtrace/_trace/trace_handlers.py @@ -487,10 +487,12 @@ def _on_django_finalize_response_pre(ctx, after_request_tags, request, response) def _on_django_start_response( - ctx, request, extract_body: Callable, query: str, uri: str, path: Optional[Dict[str, str]] + ctx, request, extract_body: Callable, remake_body: Callable, query: str, uri: str, path: Optional[Dict[str, str]] ): parsed_query = request.GET body = extract_body(request) + remake_body(request) + trace_utils.set_http_meta( ctx["call"], ctx["distributed_headers_config"], diff --git a/ddtrace/contrib/django/patch.py b/ddtrace/contrib/django/patch.py index 0b94a4e3fed..34a41ba1d3d 100644 --- a/ddtrace/contrib/django/patch.py +++ b/ddtrace/contrib/django/patch.py @@ -527,7 +527,9 @@ def blocked_response(): except Exception: path = None - core.dispatch("django.start_response", (ctx, request, utils._extract_body, query, uri, path)) + core.dispatch( + "django.start_response", (ctx, request, utils._extract_body, utils._remake_body, query, uri, path) + ) core.dispatch("django.start_response.post", ("Django",)) if core.get_item(HTTP_REQUEST_BLOCKED): diff --git a/ddtrace/contrib/django/utils.py b/ddtrace/contrib/django/utils.py index ed38328a4c6..eaec377d7bc 100644 --- a/ddtrace/contrib/django/utils.py +++ b/ddtrace/contrib/django/utils.py @@ -1,3 +1,4 @@ +import io import json from typing import Any # noqa:F401 from typing import Dict # noqa:F401 @@ -286,6 +287,19 @@ def _extract_body(request): return req_body +def _remake_body(request): + # some libs that utilize django (Spyne) require the body stream to be unread or else will throw errors + # see: https://github.com/arskom/spyne/blob/f105ec2f41495485fef1211fe73394231b3f76e5/spyne/server/wsgi.py#L538 + if request.method in _BODY_METHODS: + try: + unread_body = io.BytesIO(request._body) + if unread_body.seekable(): + unread_body.seek(0) + request.META["wsgi.input"] = unread_body + except Exception: + log.debug("Failed to remake Django request body", exc_info=True) + + def _get_request_headers(request): # type: (Any) -> Mapping[str, str] if DJANGO22: diff --git a/releasenotes/notes/fix-django-stream-body-exhausted-by-ddtrace-eb25702730c20e5e.yaml b/releasenotes/notes/fix-django-stream-body-exhausted-by-ddtrace-eb25702730c20e5e.yaml new file mode 100644 index 00000000000..91964e991c7 --- /dev/null +++ b/releasenotes/notes/fix-django-stream-body-exhausted-by-ddtrace-eb25702730c20e5e.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + tracing(django): This fix resolves a bug where ddtrace was exhausting a Django stream response before returning it to user. diff --git a/riotfile.py b/riotfile.py index 0d992eb3c78..6fde633926e 100644 --- a/riotfile.py +++ b/riotfile.py @@ -800,6 +800,8 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION): "python-memcached": latest, "pytest-randomly": latest, "django-q": latest, + "spyne": latest, + "zeep": latest, }, env={ "DD_CIVISIBILITY_ITR_ENABLED": "0", diff --git a/tests/contrib/django/soap/__init__.py b/tests/contrib/django/soap/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/contrib/django/soap/apps.py b/tests/contrib/django/soap/apps.py new file mode 100644 index 00000000000..05f7bc90b2c --- /dev/null +++ b/tests/contrib/django/soap/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SoapConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "soap" diff --git a/tests/contrib/django/soap/models.py b/tests/contrib/django/soap/models.py new file mode 100644 index 00000000000..9df7e1f2288 --- /dev/null +++ b/tests/contrib/django/soap/models.py @@ -0,0 +1,51 @@ +from spyne import Boolean +from spyne import ComplexModel +from spyne import ComplexModelBase +from spyne import String +from spyne import Unicode +from spyne.util.odict import odict + + +class OrderedModel(object): + """ + Ugly hack to create an ordered model in Spyne, but there's no other way. + See: https://mail.python.org/pipermail/soap/2013-June/001113.html + """ + + def __init__(self): + self.result = odict() + + def fields(self): + """This method should be overwritten.""" + raise NotImplementedError("Overwrite the OrderedModel.fields() method.") + + def model_names(self): + """This method should be overwritten.""" + raise NotImplementedError("Overwrite the OrderedModel.model_names() method.") + + def produce(self, type_name, prefix=""): + """Produce the actual model.""" + for field in self.fields(): + if isinstance(field[1], OrderedModel): + self.result[field[0]] = field[1].produce(field[2]) + else: + self.result[field[0]] = field[1] + return ComplexModelBase.produce(prefix, type_name, self.result) + + +class LeaveStatusModel(OrderedModel): + def fields(self): + return [ + ("LeaveID", String(), "leave_id"), + ("Description", String(), "description"), + ] + + def model_names(self): + return ("leave", "LeaveStatus") + + +class ResponseModel(ComplexModel): + __namespace__ = "" + + success = Boolean + errorText = Unicode diff --git a/tests/contrib/django/soap/services.py b/tests/contrib/django/soap/services.py new file mode 100644 index 00000000000..9b767e296dc --- /dev/null +++ b/tests/contrib/django/soap/services.py @@ -0,0 +1,36 @@ +import logging + +from django.views.decorators.csrf import csrf_exempt +from lxml import etree +from spyne import Application # noqa +from spyne import ServiceBase # noqa +from spyne import rpc # noqa +from spyne.protocol.soap import Soap11 # noqa +from spyne.server.django import DjangoApplication # noqa + +from .models import LeaveStatusModel +from .models import ResponseModel + + +logger = logging.getLogger(__name__) + + +class LeaveStatusService(ServiceBase): + @rpc(LeaveStatusModel().produce("leave_status", ""), _body_style="bare", _returns=ResponseModel) + def EmployeeLeaveStatus(self, leave_status): + in_body_doc = etree.tostring(self.in_body_doc) + logger.info("Leave service called with: ", in_body_doc) + logger.info("Parsed Leave Status: ", leave_status) + return ResponseModel(success=True, errorText=in_body_doc) + + +leave_status_app = Application( + [ + LeaveStatusService, + ], + tns="http://kabisa.nl/soap/reproduction", + in_protocol=Soap11(validator="lxml"), + out_protocol=Soap11(), +) + +leave_status_service = csrf_exempt(DjangoApplication(leave_status_app)) diff --git a/tests/contrib/django/test_django_wsgi.py b/tests/contrib/django/test_django_wsgi.py index d2db3dc8def..edc065377d8 100644 --- a/tests/contrib/django/test_django_wsgi.py +++ b/tests/contrib/django/test_django_wsgi.py @@ -11,6 +11,8 @@ import pytest from ddtrace.contrib.wsgi import DDWSGIMiddleware +from ddtrace.internal.compat import PYTHON_VERSION_INFO +from tests.contrib.django.utils import make_soap_request from tests.webclient import Client @@ -34,18 +36,27 @@ def handler(_): urlpatterns = [path("", handler)] + +# this import fails for python 3.12 +if PYTHON_VERSION_INFO < (3, 12): + from tests.contrib.django.soap.services import leave_status_service + + urlpatterns.append(path("soap/", leave_status_service, name="soap_account")) + + # it would be better to check for app_is_iterator programmatically, but Django WSGI apps behave like # iterators for the purpose of DDWSGIMiddleware despite not having both "__next__" and "__iter__" methods app = DDWSGIMiddleware(get_wsgi_application(), app_is_iterator=True) -@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="Older Django versions don't work with this use of django-admin") -def test_django_app_receives_request_finished_signal_when_app_is_ddwsgimiddleware(): +@pytest.fixture() +def wsgi_app(): env = os.environ.copy() env.update( { "PYTHONPATH": os.path.dirname(os.path.abspath(__file__)) + ":" + env["PYTHONPATH"], "DJANGO_SETTINGS_MODULE": "test_django_wsgi", + "DD_TRACE_ENABLED": "true", } ) cmd = ["django-admin", "runserver", "--noreload", str(SERVER_PORT)] @@ -57,6 +68,18 @@ def test_django_app_receives_request_finished_signal_when_app_is_ddwsgimiddlewar env=env, ) + yield proc + + try: + proc.terminate() + proc.wait(timeout=5) # Wait up to 5 seconds for the process to terminate + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="Older Django versions don't work with this use of django-admin") +def test_django_app_receives_request_finished_signal_when_app_is_ddwsgimiddleware(wsgi_app): client = Client("http://localhost:%d" % SERVER_PORT) client.wait() output = "" @@ -64,8 +87,19 @@ def test_django_app_receives_request_finished_signal_when_app_is_ddwsgimiddlewar assert client.get("/").status_code == 200 finally: try: - _, output = proc.communicate(timeout=1) + _, output = wsgi_app.communicate(timeout=1) except subprocess.TimeoutExpired: - proc.kill() - _, output = proc.communicate() + wsgi_app.kill() + _, output = wsgi_app.communicate() assert SENTINEL_LOG in str(output) + + +@pytest.mark.skipif(PYTHON_VERSION_INFO >= (3, 12), reason="A Spyne import fails when using Python 3.12") +def test_django_wsgi_soap_app_works(wsgi_app): + client = Client("http://localhost:%d" % SERVER_PORT) + client.wait() + + url = "http://localhost:%d" % SERVER_PORT + "/soap/?wsdl" + response = make_soap_request(url) + + assert response["success"] is True diff --git a/tests/contrib/django/utils.py b/tests/contrib/django/utils.py new file mode 100644 index 00000000000..f69140a0456 --- /dev/null +++ b/tests/contrib/django/utils.py @@ -0,0 +1,14 @@ +from zeep import Client +from zeep.transports import Transport + + +def make_soap_request(url): + client = Client(wsdl=url, transport=Transport()) + + # Call the SOAP service + response = client.service.EmployeeLeaveStatus(LeaveID="124", Description="Annual leave") + # Print the response + print(f"Success: {response.success}") + print(f"ErrorText: {response.errorText}") + + return response