Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion webservice/__manifest__.py
Comment thread
simahawk marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{
"name": "WebService",
"summary": """Defines webservice abstract definition to be used generally""",
"version": "18.0.2.0.0",
"version": "18.0.2.0.1",
"license": "AGPL-3",
"development_status": "Production/Stable",
"maintainers": ["etobella", "simahawk"],
Expand Down
9 changes: 6 additions & 3 deletions webservice/components/request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class BaseRestRequestsAdapter(Component):
# TODO: url and url_params could come from work_ctx
def _request(self, method, url=None, url_params=None, **kwargs):
url = self._get_url(url=url, url_params=url_params)
content_only = kwargs.pop("content_only", True)
self.collection._pop_deprecated_content_only_kwarg(kwargs)
# TODO: turn on/off debug from webservice setting?
url_to_log = self._sanitize_url_for_log(url)
_logger.info("%s call to %s", method, url_to_log)
Expand All @@ -40,7 +40,7 @@ def _request(self, method, url=None, url_params=None, **kwargs):
# pylint: disable=E8106
request = requests.request(method, url, **new_kwargs)
request.raise_for_status()
if content_only:
if self.collection._get_request_content_only():
return request.content
return request

Expand Down Expand Up @@ -178,6 +178,7 @@ def _fetch_new_token(self, old_token):

def _request(self, method, url=None, url_params=None, **kwargs):
url = self._get_url(url=url, url_params=url_params)
self.collection._pop_deprecated_content_only_kwarg(kwargs)
new_kwargs = kwargs.copy()
new_kwargs.update(
{
Expand All @@ -190,7 +191,9 @@ def _request(self, method, url=None, url_params=None, **kwargs):
# pylint: disable=E8106
request = session.request(method, url, **new_kwargs)
request.raise_for_status()
return request.content
if self.collection._get_request_content_only():
return request.content
return request


class WebApplicationOAuth2RestRequestsAdapter(Component):
Expand Down
52 changes: 52 additions & 0 deletions webservice/models/webservice_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

_logger = logging.getLogger(__name__)

# Backward-compat switch for the `content_only` default removal on `_request`.
# Existing databases get it set (see `webservice`'s `18.0.2.0.1` upgrade
# script) to keep returning only the response content; new installs get the
# full `requests.Response` object with no param set. Safe to delete once
# calling code has been adapted; the code checking it can then be dropped
# too.
CONTENT_ONLY_COMPAT_PARAM = "webservice.request_content_only"


class WebserviceBackend(models.Model):
_name = "webservice.backend"
Expand Down Expand Up @@ -89,6 +97,50 @@ def _get_adapter(self):
webservice_protocol=self._get_adapter_protocol(),
)

def _request(self, method, url=None, url_params=None, **kwargs):
self._pop_deprecated_content_only_kwarg(kwargs)
response = super()._request(method, url=url, url_params=url_params, **kwargs)
if self._get_request_content_only():
return response.content
return response

def _pop_deprecated_content_only_kwarg(self, kwargs):
"""Drop the removed ``content_only`` call argument, warning if used.

It used to switch between returning the raw response content or the
full ``requests.Response`` object per call. It's gone: the full
response is always returned now, controlled only (and temporarily)
by the ``CONTENT_ONLY_COMPAT_PARAM`` system parameter for the whole
database - not something to keep sprinkling through call sites.
"""
if "content_only" in kwargs:
kwargs.pop("content_only")
_logger.warning(
"%s: the 'content_only' argument is no longer supported "
"and was ignored; the full response object is always "
"returned now. Remove it from the calling code.",
self.display_name,
)

def _get_request_content_only(self):
"""Whether to return only the response content (legacy behavior).

See ``CONTENT_ONLY_COMPAT_PARAM``.
"""
content_only = bool(
self.env["ir.config_parameter"].sudo().get_param(CONTENT_ONLY_COMPAT_PARAM)
)
if content_only:
_logger.warning(
"%s: returning only the response content because the "
"'%s' system parameter is set (kept for backward "
"compatibility after upgrade). Delete it once the calling "
"code is adapted to use the full response object.",
self.display_name,
CONTENT_ONLY_COMPAT_PARAM,
)
return content_only

def _get_adapter_protocol(self):
protocol = self.protocol
if self.auth_type.startswith("oauth2"):
Expand Down
10 changes: 10 additions & 0 deletions webservice/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
On upgrade from a version prior to `18.0.2.0.1`, a
`webservice.request_content_only` system parameter is created automatically
(see that version's migration script) to preserve the previous default
behavior of HTTP calls, which returned only the response content instead of
the full `requests.Response` object.

If your code relies on that implicit default, keep the parameter for now.
Once it's adapted to use the full response object (see *Usage*), delete the
parameter under *Settings > Technical > System Parameters* - new installs
never get it set.
2 changes: 1 addition & 1 deletion webservice/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
This module creates WebService frameworks to be used globally.

The module introduces support for HTTP Request protocol. The webservice HTTP call returns by default the content of the response. A context 'content_only' can be passed to get the full response object.
The module introduces support for HTTP Request protocol. The webservice HTTP call returns the full `requests.Response` object.

It builds on top of ``webservice_core`` (which provides the ``webservice.backend``
model with public/username-password/API key authentication) to add OAuth2
Expand Down
1 change: 1 addition & 0 deletions webservice/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_content_only_compat
from . import test_oauth2
64 changes: 64 additions & 0 deletions webservice/tests/test_content_only_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import responses

from .common import CommonWebService


class TestContentOnlyCompat(CommonWebService):
"""`content_only` removal, and its backward-compat system parameter.

This lives in `webservice`, not `webservice_core`: the compat switch
only matters for databases that already ran with the old
`content_only=True` default, and that's `webservice`'s history, not
`webservice_core`'s (a new, not-yet-released module nobody depends on
with that old default).
"""

@classmethod
def _setup_records(cls):
res = super()._setup_records()
cls.url = "https://localhost.demo.odoo/"
cls.webservice = cls.env["webservice.backend"].create(
{
"name": "WebService",
"protocol": "http",
"url": cls.url,
"tech_name": "demo_ws_content_only",
"auth_type": "none",
}
)
return res

@responses.activate
def test_returns_full_response_by_default(self):
responses.add(responses.GET, self.url, body="{}")
result = self.webservice.call("get")
self.assertEqual(result.content, b"{}")
self.assertEqual(result.status_code, 200)

@responses.activate
def test_compat_param_restores_content_only(self):
self.env["ir.config_parameter"].sudo().set_param(
"webservice.request_content_only", "1"
)
responses.add(responses.GET, self.url, body="{}")
with self.assertLogs(
"odoo.addons.webservice.models.webservice_backend", level="WARNING"
) as log_catcher:
result = self.webservice.call("get")
self.assertEqual(result, b"{}")
self.assertTrue(
any("webservice.request_content_only" in m for m in log_catcher.output)
)

@responses.activate
def test_content_only_kwarg_is_ignored(self):
"""The removed `content_only` kwarg no longer has any effect."""
responses.add(responses.GET, self.url, body="{}")
with self.assertLogs(
"odoo.addons.webservice.models.webservice_backend", level="WARNING"
) as log_catcher:
result = self.webservice.call("get", content_only=True)
self.assertEqual(result.content, b"{}")
self.assertTrue(any("content_only" in m for m in log_catcher.output))
4 changes: 2 additions & 2 deletions webservice/tests/test_oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_fetch_token(self):
delta=1, # Accept a diff of 1s
)
self.assertEqual(responses.calls[1].response.content.decode(), "OK")
self.assertEqual(result.decode(), "OK")
self.assertEqual(result.content.decode(), "OK")

@responses.activate
def test_update_token(self):
Expand Down Expand Up @@ -111,7 +111,7 @@ def test_update_token(self):
delta=1, # Accept a diff of 1s
)
self.assertEqual(responses.calls[1].response.content.decode(), "OK")
self.assertEqual(result.decode(), "OK")
self.assertEqual(result.content.decode(), "OK")

@responses.activate
def test_update_token_with_error(self):
Expand Down
24 changes: 24 additions & 0 deletions webservice/upgrades/18.0.2.0.1/post-update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from openupgradelib import openupgrade

CONTENT_ONLY_COMPAT_PARAM = "webservice.request_content_only"


@openupgrade.migrate()
def migrate(env, version):
"""Preserve the pre-upgrade behavior of HTTP webservice calls.

Before this version, ``webservice.backend.call()`` (and friends)
returned only the response content (raw bytes) by default. That default
is gone: calls now always return the full ``requests.Response`` object.

Existing databases get ``CONTENT_ONLY_COMPAT_PARAM`` set so their
calling code keeps working unchanged until it's adapted; new installs
never get it, so they see the new behavior right away. Leave the
parameter unset (or delete it) once the calling code is updated.
"""
icp = env["ir.config_parameter"].sudo()
if not icp.search_count([("key", "=", CONTENT_ONLY_COMPAT_PARAM)]):
icp.set_param(CONTENT_ONLY_COMPAT_PARAM, "1")
3 changes: 0 additions & 3 deletions webservice_core/models/webservice_request_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ def call_delete(self, **kwargs):

def _request(self, method, url=None, url_params=None, **kwargs):
url = self._get_url(url=url, url_params=url_params)
content_only = kwargs.pop("content_only", True)
url_to_log = self._sanitize_url_for_log(url)
_logger.info("%s call to %s", method, url_to_log)
new_kwargs = kwargs.copy()
Expand All @@ -179,8 +178,6 @@ def _request(self, method, url=None, url_params=None, **kwargs):
# pylint: disable=E8106
request = requests.request(method, url, **new_kwargs)
request.raise_for_status()
if content_only:
return request.content
return request

def _sanitize_url_for_log(self, url):
Expand Down
12 changes: 3 additions & 9 deletions webservice_core/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ Look up the backend (e.g. by its technical name) and call it:

```python
backend = env["webservice.backend"].search([("tech_name", "=", "my_api")])
result = backend.call("get") # -> bytes: the response content
result = backend.call("get") # -> requests.Response
result.content
result.status_code
```

`call(method, *args, **kwargs)` accepts any of the standard HTTP verbs
Expand Down Expand Up @@ -48,11 +50,3 @@ for a single call (same format `requests` itself accepts, e.g. a
backend.call("get", auth=("other_user", "other_password"))
```

**Full response**: `call()` returns `response.content` by default. Pass
`content_only=False` to get the full `requests.Response` object instead
(status code, headers, etc.):

```python
response = backend.call("get", content_only=False)
response.status_code
```
23 changes: 12 additions & 11 deletions webservice_core/tests/test_webservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ def test_auth_validation(self):
def test_web_service_get(self):
responses.add(responses.GET, self.url, body="{}")
result = self.webservice.call("get")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(result.status_code, 200)
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -97,7 +98,7 @@ def test_web_service_get_url_combine(self):
endpoint = "api/test"
responses.add(responses.GET, self.url + endpoint, body="{}")
result = self.webservice.call("get", url="api/test")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -108,7 +109,7 @@ def test_web_service_get_url_combine_full_url(self):
endpoint = "api/test"
responses.add(responses.GET, self.url + endpoint, body="{}")
result = self.webservice.call("get", url="https://localhost.demo.odoo/api/test")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -118,7 +119,7 @@ def test_web_service_get_url_combine_full_url(self):
def test_web_service_post(self):
responses.add(responses.POST, self.url, body="{}")
result = self.webservice.call("post", data="demo_response")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
)
Expand All @@ -128,7 +129,7 @@ def test_web_service_post(self):
def test_web_service_put(self):
responses.add(responses.PUT, self.url, body="{}")
result = self.webservice.call("put", data="demo_response")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
)
Expand All @@ -141,7 +142,7 @@ def test_web_service_backend_username(self):
)
responses.add(responses.GET, self.url, body="{}")
result = self.webservice.call("get")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -156,7 +157,7 @@ def test_web_service_username(self):
)
responses.add(responses.GET, self.url, body="{}")
result = self.webservice.call("get", auth=("user2", "pass2"))
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -171,7 +172,7 @@ def test_web_service_backend_api_key(self):
)
responses.add(responses.POST, self.url, body="{}")
result = self.webservice.call("post")
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -182,7 +183,7 @@ def test_web_service_backend_api_key(self):
def test_web_service_headers(self):
responses.add(responses.GET, self.url, body="{}")
result = self.webservice.call("get", headers={"demo_header": "HEADER"})
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -196,7 +197,7 @@ def test_web_service_call_args(self):
result = self.webservice.call(
"post", url=url, headers={"demo_header": "HEADER"}
)
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 1)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand All @@ -211,7 +212,7 @@ def test_web_service_call_args(self):
url_params={"endpoint": "custom/path"},
headers={"demo_header": "HEADER"},
)
self.assertEqual(result, b"{}")
self.assertEqual(result.content, b"{}")
self.assertEqual(len(responses.calls), 2)
self.assertEqual(
responses.calls[0].request.headers["Content-Type"], "application/xml"
Expand Down
Loading