diff --git a/base_external_system_http/README.rst b/base_external_system_http/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/base_external_system_http/__init__.py b/base_external_system_http/__init__.py new file mode 100644 index 000000000..c32fd62b7 --- /dev/null +++ b/base_external_system_http/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/base_external_system_http/__manifest__.py b/base_external_system_http/__manifest__.py new file mode 100644 index 000000000..2df622182 --- /dev/null +++ b/base_external_system_http/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "External System HTTP", + "version": "16.0.1.0.0", + "author": "Therp BV, Odoo Community Association (OCA)", + "maintainers": ["NL66278", "ntsirintanis"], + "license": "AGPL-3", + "category": "Base", + "website": "https://github.com/OCA/server-backend", + "summary": "HTTP Connector for External Systems", + "depends": ["base_external_system"], + "data": [ + "security/ir.model.access.csv", + "demo/external_system_demo.xml", + "demo/external_system_endpoint_demo.xml", + "views/external_system.xml", + ], + "demo": [ + "demo/external_system_demo.xml", + "demo/external_system_endpoint_demo.xml", + ], + "installable": True, + "application": False, +} diff --git a/base_external_system_http/demo/external_system_demo.xml b/base_external_system_http/demo/external_system_demo.xml new file mode 100644 index 000000000..9e1f73fc2 --- /dev/null +++ b/base_external_system_http/demo/external_system_demo.xml @@ -0,0 +1,16 @@ + + + + + + Example Connection to GitHub for Testing + external.system.adapter.http + github.com + /OCA + + + + diff --git a/base_external_system_http/demo/external_system_endpoint_demo.xml b/base_external_system_http/demo/external_system_endpoint_demo.xml new file mode 100644 index 000000000..92de37ee2 --- /dev/null +++ b/base_external_system_http/demo/external_system_endpoint_demo.xml @@ -0,0 +1,14 @@ + + + + + + + tools + /server-tools + + + diff --git a/base_external_system_http/models/__init__.py b/base_external_system_http/models/__init__.py new file mode 100644 index 000000000..f2f6d84ba --- /dev/null +++ b/base_external_system_http/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import external_system_endpoint # Must be before external_system! +from . import external_system +from . import external_system_adapter_http diff --git a/base_external_system_http/models/external_system.py b/base_external_system_http/models/external_system.py new file mode 100644 index 000000000..40a685a36 --- /dev/null +++ b/base_external_system_http/models/external_system.py @@ -0,0 +1,17 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ExternalSystem(models.Model): + """Extend base external system""" + + _inherit = "external.system" + + endpoint_ids = fields.One2many( + comodel_name="external.system.endpoint", + inverse_name="system_id", + string="Endpoints", + help="Endpoints on remote system", + ) diff --git a/base_external_system_http/models/external_system_adapter_http.py b/base_external_system_http/models/external_system_adapter_http.py new file mode 100644 index 000000000..5dc27cf8b --- /dev/null +++ b/base_external_system_http/models/external_system_adapter_http.py @@ -0,0 +1,157 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +"""HTTP(S) adapter for :class:`external.system`.""" + +import logging + +import requests +from requests import exceptions as req_exc + +from odoo import _, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class ExternalSystemAdapterHTTP(models.Model): + """HTTP external system adapter.""" + + _name = "external.system.adapter.http" + _inherit = "external.system.adapter" + _description = "External System HTTP" + + def external_get_client(self): + self.ensure_one() + return self + + def external_destroy_client(self, client): + self.ensure_one() + return super().external_destroy_client(client) + + def external_test_connection(self): + """Test connection in the UI by doing a GET on the base URL.""" + self.ensure_one() + try: + self.get(endpoint=None) + except ValidationError: + raise + except Exception as exc: + raise ValidationError(_("Connection failed.\n\nDETAIL: %s") % exc) from exc + return super().external_test_connection() + + def get(self, endpoint=None, params=None, timeout=16, **kwargs): + """GET helper.""" + self.ensure_one() + url = self._get_url(endpoint=endpoint) + _logger.debug("Will GET %s", url) + try: + response = requests.get(url, params=params, timeout=timeout, **kwargs) + except req_exc.RequestException as exc: + _logger.error("GET %s failed: %s", url, exc) + raise ValidationError( + _("GET request failed for %(url)s.\n\nDETAIL: %(detail)s") + % {"url": url, "detail": exc} + ) from exc + return self._return_checked_response(endpoint, response) + + def post(self, endpoint=None, data=None, json=None, timeout=16, **kwargs): + """POST helper.""" + self.ensure_one() + url = self._get_url(endpoint=endpoint) + _logger.debug("Will POST %s", url) + try: + response = requests.post( + url, data=data, json=json, timeout=timeout, **kwargs + ) + except req_exc.RequestException as exc: + _logger.error("POST %s failed: %s", url, exc) + raise ValidationError( + _("POST request failed for %(url)s.\n\nDETAIL: %(detail)s") + % {"url": url, "detail": exc} + ) from exc + return self._return_checked_response(endpoint, response) + + def put(self, endpoint=None, data=None, json=None, timeout=16, **kwargs): + """PUT helper.""" + self.ensure_one() + url = self._get_url(endpoint=endpoint) + _logger.debug("Will PUT %s", url) + try: + response = requests.put( + url, data=data, json=json, timeout=timeout, **kwargs + ) + except req_exc.RequestException as exc: + _logger.error("PUT %s failed: %s", url, exc) + raise ValidationError( + _("PUT request failed for %(url)s.\n\nDETAIL: %(detail)s") + % {"url": url, "detail": exc} + ) from exc + return self._return_checked_response(endpoint, response) + + def delete(self, endpoint=None, params=None, timeout=16, **kwargs): + """DELETE helper.""" + self.ensure_one() + url = self._get_url(endpoint=endpoint) + _logger.debug("Will DELETE %s", url) + try: + response = requests.delete(url, params=params, timeout=timeout, **kwargs) + except req_exc.RequestException as exc: + _logger.error("DELETE %s failed: %s", url, exc) + raise ValidationError( + _("DELETE request failed for %(url)s.\n\nDETAIL: %(detail)s") + % {"url": url, "detail": exc} + ) from exc + return self._return_checked_response(endpoint, response) + + def _get_url(self, endpoint=None, url_suffix=None): + """Build full URL for an endpoint""" + self.ensure_one() + system = self.system_id + endpoint_record = None + if endpoint: + endpoint_record = self.env["external.system.endpoint"].search( + [("system_id", "=", system.id), ("name", "=", endpoint)], + limit=1, + ) + if not endpoint_record: + raise UserError( + _("Endpoint %(endpoint)s not found on system %(system_name)s") + % {"endpoint": endpoint, "system_name": system.name} + ) + host = (system.host or "").strip() + if host.startswith(("http://", "https://")): + base = host.rstrip("/") + else: + base = ("https://" + host).rstrip("/") + port = ":" + str(system.port) if system.port else "" + remote_path = (system.remote_path or "").rstrip("/") + endpoint_path = endpoint_record.endpoint if endpoint_record else "" + suffix = url_suffix or "" + return f"{base}{port}{remote_path}{endpoint_path}{suffix}" + + def _return_checked_response(self, endpoint, response): + """Validate response.""" + if response.status_code >= 400: + text = response.text or "" + _logger.error( + "Got response with statuscode %(status)s from endpoint %(endpoint)s: %(text)s", + { + "status": str(response.status_code), + "endpoint": endpoint or "", + "text": text, + }, + ) + raise ValidationError( + _( + "Communication failure with %(endpoint)s " + "(HTTP %(status)s).\n\nDETAIL: %(detail)s" + ) + % { + "endpoint": endpoint or "", + "status": response.status_code, + "detail": (text or "").strip(), + } + ) + + _logger.info("Succesfull communication with endpoint %s", endpoint or "") + return response diff --git a/base_external_system_http/models/external_system_endpoint.py b/base_external_system_http/models/external_system_endpoint.py new file mode 100644 index 000000000..204a75a98 --- /dev/null +++ b/base_external_system_http/models/external_system_endpoint.py @@ -0,0 +1,19 @@ +# Copyright 2026 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +"""Endpoint on remote system to get from or post to.""" + +from odoo import fields, models + + +class ExternalSystemEndpoint(models.Model): + """Endpoint on remote system to get from or post to.""" + + _name = "external.system.endpoint" + _description = __doc__ + + system_id = fields.Many2one(comodel_name="external.system", required=True) + name = fields.Char(string="Logical name for endpoint", required=True) + endpoint = fields.Char( + required=True, + help="Remote path to append to base url for endpoint", + ) diff --git a/base_external_system_http/readme/CONTRIBUTORS.rst b/base_external_system_http/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..697b6daff --- /dev/null +++ b/base_external_system_http/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Ronald Portier (Therp BV) +* Nikos Tsirintanis diff --git a/base_external_system_http/readme/DESCRIPTION.rst b/base_external_system_http/readme/DESCRIPTION.rst new file mode 100644 index 000000000..06f049581 --- /dev/null +++ b/base_external_system_http/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +This module provides an HTTP(S) adapter for the ``base_external_system`` framework. + +It allows Odoo to communicate with external systems over HTTP using a consistent +adapter interface, supporting GET, POST and PUT requests, endpoint resolution, +and connection testing. + +The adapter itself acts as the client and is yielded via the standard +``system.client()`` context manager. diff --git a/base_external_system_http/readme/ROADMAP.rst b/base_external_system_http/readme/ROADMAP.rst new file mode 100644 index 000000000..e45fea246 --- /dev/null +++ b/base_external_system_http/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Optional authentication helpers +* Optional persistent session support diff --git a/base_external_system_http/readme/USAGE.rst b/base_external_system_http/readme/USAGE.rst new file mode 100644 index 000000000..03c60f42f --- /dev/null +++ b/base_external_system_http/readme/USAGE.rst @@ -0,0 +1,24 @@ +Create an external system record and optionally define endpoints. + +Example configuration: + +* Host: ``api.example.com`` +* Remote path: ``/v1`` + +Define endpoints such as: + +* ``status`` → ``/status`` +* ``items`` → ``/items`` + +Use the adapter: + +.. code-block:: python + + with system.client() as client: + response = client.get(endpoint="status") + response = client.post(endpoint="items", json={"name": "Item"}) + response = client.put(endpoint="items", json={"active": True}) + +Responses are returned as ``requests.Response`` objects. + +Errors raise ``ValidationError`` with detailed messages. diff --git a/base_external_system_http/security/ir.model.access.csv b/base_external_system_http/security/ir.model.access.csv new file mode 100644 index 000000000..bd1520dea --- /dev/null +++ b/base_external_system_http/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +base_external_system_http.access_external_system_adapter_http_user,access_external_system_adapter_http_user,base_external_system_http.model_external_system_adapter_http,base.group_user,1,0,0,0 +base_external_system_http.access_external_system_endpoint_user,access_external_system_endpoint_user,base_external_system_http.model_external_system_endpoint,base.group_user,1,0,0,0 +base_external_system_http.access_external_system_endpoint_admin,access_external_system_endpoint_admin,base_external_system_http.model_external_system_endpoint,base.group_system,1,1,1,1 diff --git a/base_external_system_http/static/description/icon.png b/base_external_system_http/static/description/icon.png new file mode 100644 index 000000000..4c7ab3029 Binary files /dev/null and b/base_external_system_http/static/description/icon.png differ diff --git a/base_external_system_http/static/description/index.html b/base_external_system_http/static/description/index.html new file mode 100644 index 000000000..fdc18fe09 --- /dev/null +++ b/base_external_system_http/static/description/index.html @@ -0,0 +1,463 @@ + + + + + +External System HTTP + + + +
+

External System HTTP

+ + +

Beta License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

+

This module provides an HTTP(S) adapter for the base_external_system framework.

+

It allows Odoo to communicate with external systems over HTTP using a consistent +adapter interface, supporting GET, POST and PUT requests, endpoint resolution, +and connection testing.

+

The adapter itself acts as the client and is yielded via the standard +system.client() context manager.

+

Table of contents

+ +
+

Usage

+

Create an external system record and optionally define endpoints.

+

Example configuration:

+
    +
  • Host: api.example.com
  • +
  • Remote path: /v1
  • +
+

Define endpoints such as:

+
    +
  • status/status
  • +
  • items/items
  • +
+

Use the adapter:

+
+with system.client() as client:
+    response = client.get(endpoint="status")
+    response = client.post(endpoint="items", json={"name": "Item"})
+    response = client.put(endpoint="items", json={"active": True})
+
+

Responses are returned as requests.Response objects.

+

Errors raise ValidationError with detailed messages.

+
+
+

Known issues / Roadmap

+
    +
  • Optional authentication helpers
  • +
  • Optional persistent session support
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

NL66278 ntsirintanis

+

This module is part of the OCA/server-backend project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_external_system_http/tests/__init__.py b/base_external_system_http/tests/__init__.py new file mode 100644 index 000000000..dbb83aed8 --- /dev/null +++ b/base_external_system_http/tests/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_external_system_http diff --git a/base_external_system_http/tests/test_external_system_http.py b/base_external_system_http/tests/test_external_system_http.py new file mode 100644 index 000000000..df1e3eebc --- /dev/null +++ b/base_external_system_http/tests/test_external_system_http.py @@ -0,0 +1,196 @@ +# Copyright 2026 Therp BV. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from unittest.mock import patch + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import TransactionCase + +from ..models import external_system_adapter_http as http_mod + + +class _FakeResponse: + __slots__ = ("status_code", "text") + + def __init__(self, status_code=200, text="OK"): + self.status_code = status_code + self.text = text + + +class TestExternalSystemHTTP(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.system = cls.env.ref("base_external_system_http.external_system_github") + + def _make_variant_system(self, **overrides): + """Create a minimal external.system record for isolated tests""" + vals = { + "name": "HTTP Test System (variant)", + "system_type": "external.system.adapter.http", + "host": "github.com", + "remote_path": "/OCA", + "company_ids": [(6, 0, [self.env.company.id])], + } + vals.update(overrides) + return self.env["external.system"].create(vals) + + def test_get_http_adapter(self): + adapter_model = self.env["external.system.adapter.http"] + self.assertIn( + (adapter_model._name, adapter_model._description), + self.env["external.system"]._get_system_types(), + ) + + def test_interface(self): + self.assertTrue(self.system.interface) + self.assertEqual( + self.system.interface._name, + "external.system.adapter.http", + ) + self.assertEqual(self.system.interface.system_id, self.system) + + def test_get_base_url_and__response(self): + with patch.object(http_mod.requests, "get") as mock_get: + mock_get.return_value = _FakeResponse(status_code=200, text="") + with self.system.client() as client: + response = client.get() + mock_get.assert_called() + called_url = mock_get.call_args[0][0] + self.assertTrue(called_url.startswith("https://github.com")) + self.assertIn("/OCA", called_url) + self.assertIn("", response.text) + + def test_get_with_endpoint(self): + with patch.object(http_mod.requests, "get") as mock_get: + mock_get.return_value = _FakeResponse(status_code=200, text="server-tools") + + with self.system.client() as client: + response = client.get(endpoint="tools") + + called_url = mock_get.call_args[0][0] + self.assertIn("/server-tools", called_url) + self.assertIn("server-tools", response.text) + + def test_get_http_error(self): + with patch.object(http_mod.requests, "get") as mock_get: + mock_get.return_value = _FakeResponse(status_code=500, text="boom") + with self.assertRaises(ValidationError): + with self.system.client() as client: + client.get() + + def test_get_request_exception(self): + with patch.object( + http_mod.requests, + "get", + side_effect=http_mod.req_exc.RequestException("network down"), + ): + with self.assertRaises(ValidationError): + with self.system.client() as client: + client.get() + + def test_post_response(self): + with patch.object(http_mod.requests, "post") as mock_post: + mock_post.return_value = _FakeResponse(status_code=200, text="ok") + with self.system.client() as client: + response = client.post() + mock_post.assert_called() + called_url = mock_post.call_args[0][0] + self.assertTrue(called_url.startswith("https://github.com")) + self.assertIn("/OCA", called_url) + self.assertIn("ok", response.text) + + def test_post_with_endpoint(self): + with patch.object(http_mod.requests, "post") as mock_post: + mock_post.return_value = _FakeResponse(status_code=200, text="server-tools") + with self.system.client() as client: + response = client.post(endpoint="tools") + called_url = mock_post.call_args[0][0] + self.assertIn("/server-tools", called_url) + self.assertIn("server-tools", response.text) + + def test_post_http_error(self): + with patch.object(http_mod.requests, "post") as mock_post: + mock_post.return_value = _FakeResponse(status_code=500, text="boom") + with self.assertRaises(ValidationError): + with self.system.client() as client: + client.post() + + def test_put_response(self): + with patch.object(http_mod.requests, "put") as mock_put: + mock_put.return_value = _FakeResponse(status_code=200, text="ok") + + with self.system.client() as client: + response = client.put() + mock_put.assert_called() + called_url = mock_put.call_args[0][0] + self.assertTrue(called_url.startswith("https://github.com")) + self.assertIn("/OCA", called_url) + self.assertIn("ok", response.text) + + def test_put_with_endpoint(self): + with patch.object(http_mod.requests, "put") as mock_put: + mock_put.return_value = _FakeResponse(status_code=200, text="server-tools") + with self.system.client() as client: + response = client.put(endpoint="tools") + called_url = mock_put.call_args[0][0] + self.assertIn("/server-tools", called_url) + self.assertIn("server-tools", response.text) + + def test_put_http_error(self): + with patch.object(http_mod.requests, "put") as mock_put: + mock_put.return_value = _FakeResponse(status_code=500, text="boom") + with self.assertRaises(ValidationError): + with self.system.client() as client: + client.put() + + def test_delete_response(self): + with patch.object(http_mod.requests, "delete") as mock_delete: + mock_delete.return_value = _FakeResponse(status_code=200, text="ok") + with self.system.client() as client: + response = client.delete() + mock_delete.assert_called() + called_url = mock_delete.call_args[0][0] + self.assertTrue(called_url.startswith("https://github.com")) + self.assertIn("/OCA", called_url) + self.assertIn("ok", response.text) + + def test_delete_with_endpoint(self): + with patch.object(http_mod.requests, "delete") as mock_delete: + mock_delete.return_value = _FakeResponse( + status_code=200, text="server-tools" + ) + with self.system.client() as client: + response = client.delete(endpoint="tools") + called_url = mock_delete.call_args[0][0] + self.assertIn("/server-tools", called_url) + self.assertIn("server-tools", response.text) + + def test_delete_http_error(self): + with patch.object(http_mod.requests, "delete") as mock_delete: + mock_delete.return_value = _FakeResponse(status_code=500, text="boom") + with self.assertRaises(ValidationError): + with self.system.client() as client: + client.delete() + + def test_get_url(self): + sys = self._make_variant_system( + name="HTTP Test System (https host)", + host="https://github.com", + ) + with sys.client() as client: + url = client._get_url() + self.assertTrue(url.startswith("https://github.com")) + sys2 = self._make_variant_system( + name="HTTP Test System (http host)", + host="http://github.com", + ) + with sys2.client() as client: + url2 = client._get_url() + self.assertTrue(url2.startswith("http://github.com")) + + def test_action_test_connection(self): + with patch.object(http_mod.requests, "get") as mock_get: + mock_get.return_value = _FakeResponse(status_code=200, text="ok") + with self.assertRaises(UserError): + self.system.action_test_connection() diff --git a/base_external_system_http/views/external_system.xml b/base_external_system_http/views/external_system.xml new file mode 100644 index 000000000..0961ef94b --- /dev/null +++ b/base_external_system_http/views/external_system.xml @@ -0,0 +1,22 @@ + + + + + external.system + + + + + + + + + + + + + + + + + diff --git a/setup/base_external_system_http/odoo/addons/base_external_system_http b/setup/base_external_system_http/odoo/addons/base_external_system_http new file mode 120000 index 000000000..1a3fdb337 --- /dev/null +++ b/setup/base_external_system_http/odoo/addons/base_external_system_http @@ -0,0 +1 @@ +../../../../base_external_system_http \ No newline at end of file diff --git a/setup/base_external_system_http/setup.py b/setup/base_external_system_http/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/base_external_system_http/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)