diff --git a/requirements.txt b/requirements.txt
index 45e2e80f..2cdbab3a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,6 @@
# generated from manifests external_dependencies
oauthlib
+openupgradelib
+requests
requests-oauthlib
responses
diff --git a/webservice/README.rst b/webservice/README.rst
index 57519604..6cf262a5 100644
--- a/webservice/README.rst
+++ b/webservice/README.rst
@@ -38,6 +38,11 @@ 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.
+It builds on top of ``webservice_core`` (which provides the
+``webservice.backend`` model with public/username-password/API key
+authentication) to add OAuth2 authentication and ``server_environment``
+support.
+
**Table of contents**
.. contents::
diff --git a/webservice/__manifest__.py b/webservice/__manifest__.py
index 6c77df48..2fdc0610 100644
--- a/webservice/__manifest__.py
+++ b/webservice/__manifest__.py
@@ -6,17 +6,15 @@
{
"name": "WebService",
"summary": """Defines webservice abstract definition to be used generally""",
- "version": "18.0.1.1.2",
+ "version": "18.0.2.0.0",
"license": "AGPL-3",
"development_status": "Production/Stable",
- "maintainers": ["etobella"],
+ "maintainers": ["etobella", "simahawk"],
"author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/web-api",
- "depends": ["component"],
+ "depends": ["webservice_core", "component"],
"external_dependencies": {"python": ["requests-oauthlib", "oauthlib", "responses"]},
"data": [
- "security/ir.model.access.csv",
- "security/ir_rule.xml",
"views/webservice_backend.xml",
],
"demo": [],
diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py
index a7f7c943..4b5519b8 100644
--- a/webservice/components/request_adapter.py
+++ b/webservice/components/request_adapter.py
@@ -12,8 +12,7 @@
from requests_oauthlib import OAuth2Session
from odoo.addons.component.core import Component
-
-from ..utils import sanitize_url_for_log
+from odoo.addons.webservice_core.utils import sanitize_url_for_log
_logger = logging.getLogger(__name__)
diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py
index 3f463799..22b9c8d3 100644
--- a/webservice/models/webservice_backend.py
+++ b/webservice/models/webservice_backend.py
@@ -5,7 +5,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
-from odoo import _, api, exceptions, fields, models
+from odoo import api, fields, models
from odoo.tools import config
_logger = logging.getLogger(__name__)
@@ -13,26 +13,15 @@
class WebserviceBackend(models.Model):
_name = "webservice.backend"
- _inherit = ["collection.base"]
- _description = "WebService Backend"
+ _inherit = [
+ "webservice.backend",
+ "collection.base",
+ ]
- name = fields.Char(required=True)
- tech_name = fields.Char(required=True)
- protocol = fields.Selection([("http", "HTTP Request")], required=True)
- url = fields.Char(required=True)
auth_type = fields.Selection(
- selection=[
- ("none", "Public"),
- ("user_pwd", "Username & password"),
- ("api_key", "API Key"),
- ("oauth2", "OAuth2"),
- ],
- required=True,
+ selection_add=[("oauth2", "OAuth2")],
+ ondelete={"oauth2": "cascade"},
)
- username = fields.Char(auth_type="user_pwd")
- password = fields.Char(auth_type="user_pwd")
- api_key = fields.Char(string="API Key", auth_type="api_key")
- api_key_header = fields.Char(string="API Key header", auth_type="api_key")
oauth2_flow = fields.Selection(
[
("backend_application", "Backend Application (Client Credentials Grant)"),
@@ -58,47 +47,6 @@ class WebserviceBackend(models.Model):
help="random key generated when authorization flow starts "
"to ensure that no CSRF attack happen"
)
- content_type = fields.Selection(
- [
- ("application/json", "JSON"),
- ("application/xml", "XML"),
- ("application/x-www-form-urlencoded", "Form"),
- ],
- )
- company_id = fields.Many2one("res.company", string="Company")
-
- @api.constrains("auth_type")
- def _check_auth_type(self):
- valid_fields = {
- k: v for k, v in self._fields.items() if hasattr(v, "auth_type")
- }
- for rec in self:
- if rec.auth_type == "none":
- continue
- _fields = [v for v in valid_fields.values() if v.auth_type == rec.auth_type]
- missing = []
- for _field in _fields:
- if not rec[_field.name]:
- missing.append(_field)
- if missing:
- raise exceptions.UserError(rec._msg_missing_auth_param(missing))
-
- def _msg_missing_auth_param(self, missing_fields):
- def get_selection_value(fname):
- return self._fields.get(fname).convert_to_export(self[fname], self)
-
- return _(
- "Webservice '%(name)s' requires '%(auth_type)s' authentication. "
- "However, the following field(s) are not valued: %(fields)s"
- ) % {
- "name": self.name,
- "auth_type": get_selection_value("auth_type"),
- "fields": ", ".join([f.string for f in missing_fields]),
- }
-
- def _valid_field_parameter(self, field, name):
- extra_params = ("auth_type",)
- return name in extra_params or super()._valid_field_parameter(field, name)
@api.onchange("auth_type")
def _onchange_auth_type(self):
@@ -125,6 +73,10 @@ def write(self, vals):
return res
def call(self, method, *args, **kwargs):
+ if not self.auth_type.startswith("oauth2"):
+ return super().call(method, *args, **kwargs)
+ # NOTE: oauth2 still relies on `component` for now, until it gets
+ # extracted to its own module and reworked to drop that dependency too.
_logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs)
response = getattr(self._get_adapter(), method)(*args, **kwargs)
_logger.debug("backend %s: response: \n%s", self.name, response)
diff --git a/webservice/readme/DESCRIPTION.md b/webservice/readme/DESCRIPTION.md
index cdd44bd7..1acbda1f 100644
--- a/webservice/readme/DESCRIPTION.md
+++ b/webservice/readme/DESCRIPTION.md
@@ -1,3 +1,7 @@
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.
+
+It builds on top of ``webservice_core`` (which provides the ``webservice.backend``
+model with public/username-password/API key authentication) to add OAuth2
+authentication and ``server_environment`` support.
diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html
index 1955b42d..bfd54736 100644
--- a/webservice/static/description/index.html
+++ b/webservice/static/description/index.html
@@ -379,6 +379,10 @@
WebService
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.
+
It builds on top of webservice_core (which provides the
+webservice.backend model with public/username-password/API key
+authentication) to add OAuth2 authentication and server_environment
+support.
Table of contents
diff --git a/webservice/tests/__init__.py b/webservice/tests/__init__.py
index 70d7ce42..38fb5cf0 100644
--- a/webservice/tests/__init__.py
+++ b/webservice/tests/__init__.py
@@ -1,3 +1 @@
from . import test_oauth2
-from . import test_webservice
-from . import test_utils
diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml
index 71daebd1..7c401162 100644
--- a/webservice/views/webservice_backend.xml
+++ b/webservice/views/webservice_backend.xml
@@ -3,146 +3,55 @@
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
- webservice.backend.form (in webservice)
+ webservice.backend.form (oauth2, in webservice)webservice.backend
+
-
-
-
-
-
- webservice.backend.search (in webservice)
- webservice.backend
-
-
-
-
-
-
-
-
-
-
-
- webservice.backend.tree (in webservice)
- webservice.backend
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
-
-
- WebService Backend
- webservice.backend
- list,form
- []
- {}
-
-
-
- WebService Backend
-
-
-
-
diff --git a/webservice_core/README.rst b/webservice_core/README.rst
new file mode 100644
index 00000000..3d6e803c
--- /dev/null
+++ b/webservice_core/README.rst
@@ -0,0 +1,102 @@
+===============
+WebService Core
+===============
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:2fc3afa9011aae31e4b0d0a46e9f061c1faa70cb1a705cec07bea68c5cb7f54b
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
+ :target: https://odoo-community.org/page/development-status
+ :alt: Alpha
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github
+ :target: https://github.com/OCA/web-api/tree/18.0/webservice_core
+ :alt: OCA/web-api
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/web-api-18-0/web-api-18-0-webservice_core
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
+ :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0
+ :alt: Try me on Runboat
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+This module provides the ``webservice.backend`` model with its
+authentication (public, username/password, API key) and HTTP call
+features (``get``/``post``/``put``).
+
+It has no dependency on ``component`` or ``server_environment``, so it
+can be used as a lightweight building block by any module needing to
+configure and call an outbound webservice, without pulling in extra
+frameworks.
+
+The ``webservice`` module builds on top of this one to add OAuth2
+authentication and ``server_environment`` support.
+
+.. IMPORTANT::
+ This is an alpha version, the data model and design can change at any time without warning.
+ Only for development or testing purpose, do not use in production.
+ `More details on development status `_
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+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
+-------
+
+* Creu Blanca
+* Camptocamp
+
+Contributors
+------------
+
+- Enric Tobella
+- Alexandre Fayolle
+- Simone Orsi
+
+Maintainers
+-----------
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+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.
+
+.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px
+ :target: https://github.com/simahawk
+ :alt: simahawk
+
+Current `maintainer `__:
+
+|maintainer-simahawk|
+
+This module is part of the `OCA/web-api `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/webservice_core/__init__.py b/webservice_core/__init__.py
new file mode 100644
index 00000000..6d58305f
--- /dev/null
+++ b/webservice_core/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from .hooks import pre_init_hook
diff --git a/webservice_core/__manifest__.py b/webservice_core/__manifest__.py
new file mode 100644
index 00000000..41efecf6
--- /dev/null
+++ b/webservice_core/__manifest__.py
@@ -0,0 +1,22 @@
+# Copyright 2020 Creu Blanca
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+{
+ "name": "WebService Core",
+ "summary": """Webservice backend: auth & call features, no extra dependencies""",
+ "version": "18.0.1.0.0",
+ "license": "AGPL-3",
+ "development_status": "Production/Stable",
+ "maintainers": ["simahawk"],
+ "author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/web-api",
+ "depends": ["base"],
+ "external_dependencies": {"python": ["requests", "openupgradelib"]},
+ "data": [
+ "security/ir.model.access.csv",
+ "security/ir_rule.xml",
+ "views/webservice_backend.xml",
+ ],
+ "pre_init_hook": "pre_init_hook",
+}
diff --git a/webservice_core/hooks.py b/webservice_core/hooks.py
new file mode 100644
index 00000000..52a5f8ab
--- /dev/null
+++ b/webservice_core/hooks.py
@@ -0,0 +1,26 @@
+# Copyright 2026 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from openupgradelib import openupgrade
+
+# `webservice.backend`'s base access rule, multi-company rule, and
+# non-form views (search/list/action/menu) used to be defined by
+# `webservice`, before this module split the base backend out of it. The
+# form view keeps its own `webservice.webservice_backend_form_view` XMLID
+# (its content changed, but `webservice` still owns and extends it), so it
+# is deliberately not in this list.
+MOVED_XMLIDS = [
+ "access_webservice_backend_edit",
+ "rule_webservice_backend_multi_company",
+ "webservice_backend_search_view",
+ "webservice_backend_tree_view",
+ "webservice_backend_act_window",
+ "webservice_backend_menu",
+]
+
+
+def pre_init_hook(env):
+ """Reuse `webservice`'s pre-split records instead of duplicating them."""
+ openupgrade.rename_xmlids(
+ env.cr,
+ [(f"webservice.{xmlid}", f"webservice_core.{xmlid}") for xmlid in MOVED_XMLIDS],
+ )
diff --git a/webservice_core/models/__init__.py b/webservice_core/models/__init__.py
new file mode 100644
index 00000000..5b6cd501
--- /dev/null
+++ b/webservice_core/models/__init__.py
@@ -0,0 +1,2 @@
+from . import webservice_request_mixin
+from . import webservice_backend
diff --git a/webservice_core/models/webservice_backend.py b/webservice_core/models/webservice_backend.py
new file mode 100644
index 00000000..fde3125a
--- /dev/null
+++ b/webservice_core/models/webservice_backend.py
@@ -0,0 +1,19 @@
+# Copyright 2020 Creu Blanca
+# Copyright 2022 Camptocamp SA
+# @author Simone Orsi
+# @author Alexandre Fayolle
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class WebserviceBackend(models.Model):
+ _name = "webservice.backend"
+ _inherit = ["webservice.request.mixin"]
+ _description = "WebService Backend"
+
+ name = fields.Char(required=True)
+ url = fields.Char(required=True)
+ company_id = fields.Many2one("res.company", string="Company")
+
+ def _get_base_url(self):
+ return self.url
diff --git a/webservice_core/models/webservice_request_mixin.py b/webservice_core/models/webservice_request_mixin.py
new file mode 100644
index 00000000..8c86af3f
--- /dev/null
+++ b/webservice_core/models/webservice_request_mixin.py
@@ -0,0 +1,237 @@
+# Copyright 2020 Creu Blanca
+# Copyright 2022 Camptocamp SA
+# @author Simone Orsi
+# @author Alexandre Fayolle
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import logging
+
+import requests
+
+from odoo import api, exceptions, fields, models
+
+from ..utils import sanitize_url_for_log
+
+_logger = logging.getLogger(__name__)
+
+
+class WebserviceRequestMixin(models.AbstractModel):
+ """Auth configuration + HTTP call features for any webservice-like record.
+
+ Any model inheriting this mixin becomes able to issue authenticated HTTP
+ requests based on its own ``auth_type``/credential fields, resolved via
+ ``self._get_base_url()`` which must be implemented by the model.
+
+ ``call()`` dispatches by ``self._get_protocol()`` (``self.protocol`` by
+ default) to a ``_handle_call_for_`` method that actually
+ performs the request - ``_handle_call_for_http`` today. A new protocol is
+ added by implementing a new ``_handle_call_for_``.
+
+ Extra auth types are added by other modules via plain model inheritance:
+ add the new value to the ``auth_type`` selection, add fields tagged with
+ the matching ``auth_type=`` parameter, and implement
+ ``_get_auth_for_``/``_get_headers_for_`` and/or override
+ ``_request`` when the whole HTTP request flow needs to change (e.g.
+ oauth2).
+ """
+
+ _name = "webservice.request.mixin"
+ _description = "Webservice Request Mixin"
+ _sql_constraints = [
+ (
+ "tech_name_uniq",
+ "unique(tech_name)",
+ "`tech_name` must be unique!",
+ )
+ ]
+
+ tech_name = fields.Char(
+ required=True,
+ copy=False,
+ help="Unique name for technical purposes. "
+ "Automatically generated from the name if left empty.",
+ )
+ protocol = fields.Selection([("http", "HTTP Request")], required=True)
+ auth_type = fields.Selection(
+ selection=[
+ ("none", "Public"),
+ ("user_pwd", "Username & password"),
+ ("api_key", "API Key"),
+ ],
+ required=True,
+ )
+ username = fields.Char(auth_type="user_pwd")
+ password = fields.Char(auth_type="user_pwd")
+ api_key = fields.Char(string="API Key", auth_type="api_key")
+ api_key_header = fields.Char(string="API Key header", auth_type="api_key")
+ content_type = fields.Selection(
+ [
+ ("application/json", "JSON"),
+ ("application/xml", "XML"),
+ ("application/x-www-form-urlencoded", "Form"),
+ ],
+ )
+
+ @api.constrains("auth_type")
+ def _check_auth_type(self):
+ valid_fields = {
+ k: v for k, v in self._fields.items() if hasattr(v, "auth_type")
+ }
+ for rec in self:
+ if rec.auth_type == "none":
+ continue
+ _fields = [v for v in valid_fields.values() if v.auth_type == rec.auth_type]
+ missing = []
+ for _field in _fields:
+ if not rec[_field.name]:
+ missing.append(_field)
+ if missing:
+ raise exceptions.UserError(rec._msg_missing_auth_param(missing))
+
+ def _msg_missing_auth_param(self, missing_fields):
+ def get_selection_value(fname):
+ return self._fields.get(fname).convert_to_export(self[fname], self)
+
+ return self.env._(
+ "Webservice '%(name)s' requires '%(auth_type)s' authentication. "
+ "However, the following field(s) are not valued: %(fields)s"
+ ) % {
+ "name": self.name,
+ "auth_type": get_selection_value("auth_type"),
+ "fields": ", ".join([f.string for f in missing_fields]),
+ }
+
+ def _valid_field_parameter(self, field, name):
+ extra_params = ("auth_type",)
+ return name in extra_params or super()._valid_field_parameter(field, name)
+
+ @api.onchange("name")
+ def _onchange_name_for_tech_name(self):
+ # Keep this specific name for the method to avoid possible overrides
+ # of existing `_onchange_name` methods
+ if self.name and not self.tech_name:
+ self.tech_name = self.name
+
+ @api.onchange("tech_name")
+ def _onchange_tech_name(self):
+ if self.tech_name:
+ # make sure it's normalized
+ self.tech_name = self._normalize_tech_name(self.tech_name)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ self._handle_tech_name(vals)
+ return super().create(vals_list)
+
+ def write(self, vals):
+ self._handle_tech_name(vals)
+ return super().write(vals)
+
+ def _handle_tech_name(self, vals):
+ # make sure technical names are always there
+ if not vals.get("tech_name") and vals.get("name"):
+ vals["tech_name"] = self._normalize_tech_name(vals["name"])
+
+ def _normalize_tech_name(self, name):
+ return self.env["ir.http"]._slugify(name).replace("-", "_")
+
+ def call(self, method, *args, **kwargs):
+ _logger.debug("%s: call %s %s %s", self.display_name, method, args, kwargs)
+ handler = getattr(self, "_handle_call_for_" + self._get_protocol())
+ response = handler(method, *args, **kwargs)
+ _logger.debug("%s: response: \n%s", self.display_name, response)
+ return response
+
+ def _get_protocol(self):
+ return self.protocol
+
+ def _handle_call_for_http(self, method, **kwargs):
+ return self._request(method, **kwargs)
+
+ # shortcuts
+ def call_get(self, **kwargs):
+ return self.call("get", **kwargs)
+
+ def call_post(self, **kwargs):
+ return self.call("post", **kwargs)
+
+ def call_put(self, **kwargs):
+ return self.call("put", **kwargs)
+
+ def call_delete(self, **kwargs):
+ return self.call("delete", **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()
+ new_kwargs.update(
+ {
+ "auth": self._get_auth(**kwargs),
+ "headers": self._get_headers(**kwargs),
+ # TODO: no timeout is enforced here (requests would wait forever).
+ # Consider adding configurable connect/read timeout fields.
+ "timeout": None,
+ }
+ )
+ # 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):
+ return sanitize_url_for_log(url)
+
+ def _get_auth(self, auth=False, **kwargs):
+ if auth:
+ return auth
+ handler = getattr(self, "_get_auth_for_" + self.auth_type, None)
+ return handler(**kwargs) if handler else None
+
+ def _get_auth_for_user_pwd(self, **kw):
+ if self.username and self.password:
+ return self.username, self.password
+ return None
+
+ def _get_headers(self, content_type=False, headers=False, **kwargs):
+ headers = headers or {}
+ if content_type or self.content_type:
+ result = {
+ "Content-Type": content_type or self.content_type,
+ }
+ else:
+ result = {}
+ handler = getattr(self, "_get_headers_for_" + self.auth_type, None)
+ if handler:
+ headers.update(handler(**kwargs))
+ result.update(headers)
+ return result
+
+ def _get_headers_for_api_key(self, **kw):
+ return {self.api_key_header: self.api_key}
+
+ def _get_url(self, url=None, url_params=None, **kwargs):
+ base = self._get_base_url()
+ if not url:
+ url = base
+ elif not url.startswith(base):
+ if not url.startswith("http"):
+ url = f"{base.rstrip('/')}/{url.lstrip('/')}"
+ else:
+ # TODO: if url is given, we should validate the domain
+ # to avoid abusing a webservice backend for different calls.
+ pass
+
+ url_params = url_params or kwargs
+ return url.format(**url_params)
+
+ def _get_base_url(self):
+ """Return the base url requests are relative to.
+
+ To be implemented by models inheriting this mixin.
+ """
+ raise NotImplementedError
diff --git a/webservice_core/pyproject.toml b/webservice_core/pyproject.toml
new file mode 100644
index 00000000..4231d0cc
--- /dev/null
+++ b/webservice_core/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/webservice_core/readme/CONFIGURE.md b/webservice_core/readme/CONFIGURE.md
new file mode 100644
index 00000000..1c71d644
--- /dev/null
+++ b/webservice_core/readme/CONFIGURE.md
@@ -0,0 +1,22 @@
+Go to *Settings > Technical > WebService Backend* (requires the
+*Administration / Settings* group) and create a new backend:
+
+- **Name** / **Technical Name**: a label and a unique technical key you'll
+ use to look the backend up from code (e.g. `env.ref` is not used here;
+ search by `tech_name` instead).
+- **Protocol**: only `HTTP Request` is available in this module.
+- **URL**: the base URL every call is relative to, e.g.
+ `https://api.example.com`. It may contain `{placeholder}` tokens (see
+ *Usage*), e.g. `https://api.example.com/{endpoint}`.
+- **Content-Type**: optional default `Content-Type` header for every call.
+
+Then configure authentication via **Auth Type**:
+
+- **Public**: no credentials needed.
+- **Username & password**: sent as HTTP Basic Auth. Requires **Username**
+ and **Password**.
+- **API Key**: sent as a custom header. Requires **API Key** and
+ **API Key header** (the header name to send it under, e.g. `X-Api-Key`).
+
+Required fields depend on the selected auth type; the form only shows and
+requires the ones that apply, and saving enforces it.
diff --git a/webservice_core/readme/CONTRIBUTORS.md b/webservice_core/readme/CONTRIBUTORS.md
new file mode 100644
index 00000000..d0412679
--- /dev/null
+++ b/webservice_core/readme/CONTRIBUTORS.md
@@ -0,0 +1,3 @@
+- Simone Orsi \<\>
+- Enric Tobella \<\>
+- Alexandre Fayolle \<\>
diff --git a/webservice_core/readme/DESCRIPTION.md b/webservice_core/readme/DESCRIPTION.md
new file mode 100644
index 00000000..bfae3ae6
--- /dev/null
+++ b/webservice_core/readme/DESCRIPTION.md
@@ -0,0 +1,7 @@
+This module provides the ``webservice.backend`` model with its authentication
+(public, username/password, API key) and HTTP call features (``get``/``post``/``put``).
+
+It has no dependency on ``component`` or ``server_environment``, so it can be used
+as a lightweight building block by any module needing to configure and call
+an outbound webservice, without pulling in extra frameworks.
+
diff --git a/webservice_core/readme/USAGE.md b/webservice_core/readme/USAGE.md
new file mode 100644
index 00000000..297c0e62
--- /dev/null
+++ b/webservice_core/readme/USAGE.md
@@ -0,0 +1,58 @@
+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
+```
+
+`call(method, *args, **kwargs)` accepts any of the standard HTTP verbs
+(`get`, `post`, `put`, `delete`) and forwards everything else to
+[requests](https://requests.readthedocs.io/), so any of its keyword
+arguments work too (`data`, `json`, `params`, `files`, ...):
+
+```python
+backend.call("post", data=b"...")
+backend.call("post", json={"foo": "bar"})
+```
+
+**URL**: by default the backend's own `url` is used. Pass `url` to hit a
+different path - relative paths are appended to the backend's URL, a full
+`http(s)://` URL is used as-is:
+
+```python
+backend.call("get", url="orders") # -> /orders
+backend.call("get", url="https://other.example.com/orders")
+```
+
+If the backend's URL (or the `url` passed above) contains `{placeholder}`
+tokens, fill them with `url_params`:
+
+```python
+# backend.url == "https://api.example.com/{endpoint}"
+backend.call("get", url_params={"endpoint": "orders"})
+```
+
+**Headers**: pass `headers` to add/override headers for that call; they are
+merged on top of the backend's own `Content-Type` and auth-derived headers
+(e.g. the API key header):
+
+```python
+backend.call("get", headers={"X-Request-Id": "42"})
+```
+
+**Auth override**: pass `auth` to bypass the backend's configured auth type
+for a single call (same format `requests` itself accepts, e.g. a
+`(user, password)` tuple):
+
+```python
+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
+```
diff --git a/webservice/security/ir.model.access.csv b/webservice_core/security/ir.model.access.csv
similarity index 100%
rename from webservice/security/ir.model.access.csv
rename to webservice_core/security/ir.model.access.csv
diff --git a/webservice/security/ir_rule.xml b/webservice_core/security/ir_rule.xml
similarity index 100%
rename from webservice/security/ir_rule.xml
rename to webservice_core/security/ir_rule.xml
diff --git a/webservice_core/static/description/icon.png b/webservice_core/static/description/icon.png
new file mode 100644
index 00000000..3a0328b5
Binary files /dev/null and b/webservice_core/static/description/icon.png differ
diff --git a/webservice_core/static/description/index.html b/webservice_core/static/description/index.html
new file mode 100644
index 00000000..42a6d155
--- /dev/null
+++ b/webservice_core/static/description/index.html
@@ -0,0 +1,442 @@
+
+
+
+
+
+WebService Core
+
+
+
+
+
WebService Core
+
+
+
+
This module provides the webservice.backend model with its
+authentication (public, username/password, API key) and HTTP call
+features (get/post/put).
+
It has no dependency on component or server_environment, so it
+can be used as a lightweight building block by any module needing to
+configure and call an outbound webservice, without pulling in extra
+frameworks.
+
The webservice module builds on top of this one to add OAuth2
+authentication and server_environment support.
+
+
Important
+
This is an alpha version, the data model and design can change at any time without warning.
+Only for development or testing purpose, do not use in production.
+More details on development status
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.
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.