Repository: conan-io/conan
Pull request #17063 (2024-09-25 16:45:04)

refactor Rest layer
Changelog: Omit
Docs: Omit

While debugging one issue related to the tokens, I found the Rest layer with quite some legacy from Conan 1 and some important inefficiencies, for example the same user/token for the same remote is being loaded from the local sqlite3 DB hundreds of times for a normal dependency graph being retrieved from one server.

This pure refactor starts by simplifying some existing classes, but doesn't change anything functional, it keeps doing the same DB load, that will follow in the future, this is just some low hanging fruit to start it step by step:

- Unified the ``RestCommon`` into the ``RestV2`` classes now that there is no commonalities as there is no ``RestV1`` anymore
- Removed the ``RestApiClientFactory``, was very boilerplate.


Patch:
diff --git a/conan/internal/conan_app.py b/conan/internal/conan_app.py
index 28de3d94c..cdb86f783 100644
--- a/conan/internal/conan_app.py
+++ b/conan/internal/conan_app.py
@@ -11,7 +11,6 @@ from conans.client.loader import ConanFileLoader, load_python_file
 from conans.client.remote_manager import RemoteManager
 from conans.client.rest.auth_manager import ConanApiAuthManager
 from conans.client.rest.conan_requester import ConanRequester
-from conans.client.rest.rest_client import RestApiClientFactory
 from conan.internal.api.remotes.localdb import LocalDB
 
 
@@ -52,11 +51,9 @@ class ConanApp:
         # Wraps an http_requester to inject proxies, certs, etc
         self.requester = ConanRequester(global_conf, cache_folder)
         # To handle remote connections
-        rest_client_factory = RestApiClientFactory(self.requester, global_conf)
         # Wraps RestApiClient to add authentication support (same interface)
         self.localdb = LocalDB(cache_folder)
-        auth_manager = ConanApiAuthManager(rest_client_factory, cache_folder, self.localdb,
-                                           global_conf)
+        auth_manager = ConanApiAuthManager(self.requester, cache_folder, self.localdb, global_conf)
         # Handle remote connections
         self.remote_manager = RemoteManager(self.cache, auth_manager, cache_folder)
 
diff --git a/conans/client/rest/auth_manager.py b/conans/client/rest/auth_manager.py
index 35b5c9ddc..8320bb86c 100644
--- a/conans/client/rest/auth_manager.py
+++ b/conans/client/rest/auth_manager.py
@@ -16,18 +16,20 @@ from uuid import getnode as get_mac
 
 from conan.api.output import ConanOutput
 from conans.client.rest.remote_credentials import RemoteCredentials
+from conans.client.rest.rest_client import RestApiClient
 from conans.errors import AuthenticationException, ConanException, ForbiddenException
 
 LOGIN_RETRIES = 3
 
 
-class ConanApiAuthManager(object):
+class ConanApiAuthManager:
 
-    def __init__(self, rest_client_factory, cache_folder, localdb, global_conf):
-        self._rest_client_factory = rest_client_factory
+    def __init__(self, requester, cache_folder, localdb, global_conf):
+        self._requester = requester
         self._localdb = localdb
         self._global_conf = global_conf
         self._cache_folder = cache_folder
+        self._cached_capabilities = {}  # common to all RestApiClient
 
     def call_rest_api_method(self, remote, method_name, *args, **kwargs):
         """Handles AuthenticationException and request user to input a user and a password"""
@@ -89,7 +91,8 @@ class ConanApiAuthManager(object):
         username, token, refresh_token = self._localdb.get_login(remote.url)
         custom_headers = {'X-Client-Anonymous-Id': self._get_mac_digest(),
                           'X-Client-Id': str(username or "")}
-        return self._rest_client_factory.new(remote, token, refresh_token, custom_headers)
+        return RestApiClient(remote, token, refresh_token, custom_headers, self._requester,
+                             self._global_conf, self._cached_capabilities)
 
     def _clear_user_tokens_in_db(self, user, remote):
         try:
diff --git a/conans/client/rest/file_uploader.py b/conans/client/rest/file_uploader.py
index 025f44595..1eadc2e6e 100644
--- a/conans/client/rest/file_uploader.py
+++ b/conans/client/rest/file_uploader.py
@@ -1,6 +1,5 @@
 import os
 import time
-from copy import copy
 
 from conan.api.output import ConanOutput, TimedOutput
 from conans.client.rest import response_to_str
@@ -12,7 +11,6 @@ from conans.util.files import sha1sum
 class FileUploader(object):
 
     def __init__(self, requester, verify, config, source_credentials=None):
-        self._output = ConanOutput()
         self._requester = requester
         self._config = config
         self._verify_ssl = verify
@@ -54,15 +52,14 @@ class FileUploader(object):
         return bool(response.ok)
 
     def upload(self, url, abs_path, auth=None, dedup=False, retry=None, retry_wait=None,
-               headers=None, ref=None):
+               ref=None):
         retry = retry if retry is not None else self._config.get("core.upload:retry", default=1,
                                                                  check_type=int)
         retry_wait = retry_wait if retry_wait is not None else \
             self._config.get("core.upload:retry_wait", default=5, check_type=int)
 
         # Send always the header with the Sha1
-        headers = copy(headers) or {}
-        headers["X-Checksum-Sha1"] = sha1sum(abs_path)
+        headers = {"X-Checksum-Sha1": sha1sum(abs_path)}
         if dedup:
             response = self._dedup(url, headers, auth)
             if response:
@@ -78,9 +75,8 @@ class FileUploader(object):
                 if counter == retry:
                     raise
                 else:
-                    if self._output:
-                        self._output.warning(exc, warn_tag="network")
-                        self._output.info("Waiting %d seconds to retry..." % retry_wait)
+                    ConanOutput().warning(exc, warn_tag="network")
+                    ConanOutput().info("Waiting %d seconds to retry..." % retry_wait)
                     time.sleep(retry_wait)
 
     def _upload_file(self, url, abs_path, headers, auth, ref):
diff --git a/conans/client/rest/rest_client.py b/conans/client/rest/rest_client.py
index 00e8e9b4f..76eade345 100644
--- a/conans/client/rest/rest_client.py
+++ b/conans/client/rest/rest_client.py
@@ -3,21 +3,7 @@ from conans.client.rest.rest_client_v2 import RestV2Methods
 from conans.errors import AuthenticationException, ConanException
 
 
-class RestApiClientFactory(object):
-
-    def __init__(self, requester, config):
-        self._requester = requester
-        self._config = config
-        self._cached_capabilities = {}
-
-    def new(self, remote, token, refresh_token, custom_headers):
-        tmp = RestApiClient(remote, token, refresh_token, custom_headers,
-                            self._requester, self._config,
-                            self._cached_capabilities)
-        return tmp
-
-
-class RestApiClient(object):
+class RestApiClient:
     """
         Rest Api Client for handle remote.
     """
@@ -114,9 +100,6 @@ class RestApiClient(object):
     def remove_packages(self, prefs):
         return self._get_api().remove_packages(prefs)
 
-    def server_capabilities(self):
-        return self._get_api().server_capabilities()
-
     def get_recipe_revisions_references(self, ref):
         return self._get_api().get_recipe_revisions_references(ref)
 
diff --git a/conans/client/rest/rest_client_common.py b/conans/client/rest/rest_client_common.py
deleted file mode 100644
index 9a3f76243..000000000
--- a/conans/client/rest/rest_client_common.py
+++ /dev/null
@@ -1,224 +0,0 @@
-import json
-
-from requests.auth import AuthBase, HTTPBasicAuth
-
-from conans.client.rest import response_to_str
-from conans.errors import (EXCEPTION_CODE_MAPPING, ConanException,
-                           AuthenticationException, RecipeNotFoundException,
-                           PackageNotFoundException)
-from conans.model.recipe_ref import RecipeReference
-
-
-class JWTAuth(AuthBase):
-    """Attaches JWT Authentication to the given Request object."""
-
-    def __init__(self, token):
-        self.token = token
-
-    def __call__(self, request):
-        if self.token:
-            request.headers['Authorization'] = "Bearer %s" % str(self.token)
-        return request
-
-
-def get_exception_from_error(error_code):
-    tmp = {v: k for k, v in EXCEPTION_CODE_MAPPING.items()  # All except NotFound
-           if k not in (RecipeNotFoundException, PackageNotFoundException)}
-    if error_code in tmp:
-        # logger.debug("REST ERROR: %s" % str(tmp[error_code]))
-        return tmp[error_code]
-    else:
-        base_error = int(str(error_code)[0] + "00")
-        # logger.debug("REST ERROR: %s" % str(base_error))
-        try:
-            return tmp[base_error]
-        except KeyError:
-            return None
-
-
-class RestCommonMethods(object):
-
-    def __init__(self, remote_url, token, custom_headers, requester, config, verify_ssl):
-        self.token = token
-        self.remote_url = remote_url
-        self.custom_headers = custom_headers
-        self.requester = requester
-        self._config = config
-        self.verify_ssl = verify_ssl
-
-    @property
-    def auth(self):
-        return JWTAuth(self.token)
-
-    @staticmethod
-    def _check_error_response(ret):
-        if ret.status_code == 401:
-            raise AuthenticationException("Wrong user or password")
-        # Cannot check content-type=text/html, conan server is doing it wrong
-        if not ret.ok or "html>" in str(ret.content):
-            raise ConanException("%s\n\nInvalid server response, check remote URL and "
-                                 "try again" % str(ret.content))
-
-    def authenticate(self, user, password):
-        """Sends user + password to get:
-          - A plain response with a regular token (not supported refresh in the remote) and None
-        """
-        auth = HTTPBasicAuth(user, password)
-        url = self.router.common_authenticate()
-        # logger.debug("REST: Authenticate to get access_token: %s" % url)
-        ret = self.requester.get(url, auth=auth, headers=self.custom_headers,
-                                 verify=self.verify_ssl)
-
-        self._check_error_response(ret)
-        return ret.content.decode()
-
-    def authenticate_oauth(self, user, password):
-        """Sends user + password to get:
-            - A json with an access_token and a refresh token (if supported in the remote)
-                    Artifactory >= 6.13.X
-        """
-        url = self.router.oauth_authenticate()
-        auth = HTTPBasicAuth(user, password)
-        headers = {}
-        headers.update(self.custom_headers)
-        headers["Content-type"] = "application/x-www-form-urlencoded"
-        # logger.debug("REST: Authenticating with OAUTH: %s" % url)
-        ret = self.requester.post(url, auth=auth, headers=headers, verify=self.verify_ssl)
-        self._check_error_response(ret)
-
-        data = ret.json()
-        access_token = data["access_token"]
-        refresh_token = data["refresh_token"]
-        # logger.debug("REST: Obtained refresh and access tokens")
-        return access_token, refresh_token
-
-    def refresh_token(self, token, refresh_token):
-        """Sends access_token and the refresh_token to get a pair of
-        access_token and refresh token
-
-        Artifactory >= 6.13.X
-        """
-        url = self.router.oauth_authenticate()
-        # logger.debug("REST: Refreshing Token: %s" % url)
-        headers = {}
-        headers.update(self.custom_headers)
-        headers["Content-type"] = "application/x-www-form-urlencoded"
-        payload = {'access_token': token, 'refresh_token': refresh_token,
-                   'grant_type': 'refresh_token'}
-        ret = self.requester.post(url, headers=headers, verify=self.verify_ssl, data=payload)
-        self._check_error_response(ret)
-
-        data = ret.json()
-        if "access_token" not in data:
-            # logger.debug("REST: unexpected data from server: {}".format(data))
-            raise ConanException("Error refreshing the token")
-
-        new_access_token = data["access_token"]
-        new_refresh_token = data["refresh_token"]
-        # logger.debug("REST: Obtained new refresh and access tokens")
-        return new_access_token, new_refresh_token
-
-    def check_credentials(self):
-        """If token is not valid will raise AuthenticationException.
-        User will be asked for new user/pass"""
-        url = self.router.common_check_credentials()
-        # logger.debug("REST: Check credentials: %s" % url)
-        ret = self.requester.get(url, auth=self.auth, headers=self.custom_headers,
-                                 verify=self.verify_ssl)
-        if ret.status_code != 200:
-            ret.charset = "utf-8"  # To be able to access ret.text (ret.content are bytes)
-            text = ret.text if ret.status_code != 404 else "404 Not found"
-            raise get_exception_from_error(ret.status_code)(text)
-        return ret.content.decode()
-
-    def server_capabilities(self, user=None, password=None):
-        """Get information about the server: status, version, type and capabilities"""
-        url = self.router.ping()
-        # logger.debug("REST: ping: %s" % url)
-        if user and password:
-            # This can happen in "conan remote login" cmd. Instead of empty token, use HttpBasic
-            auth = HTTPBasicAuth(user, password)
-        else:
-            auth = self.auth
-        ret = self.requester.get(url, auth=auth, headers=self.custom_headers, verify=self.verify_ssl)
-
-        server_capabilities = ret.headers.get('X-Conan-Server-Capabilities')
-        if not server_capabilities and not ret.ok:
-            # Old Artifactory might return 401/403 without capabilities, we don't want
-            # to cache them #5687, so raise the exception and force authentication
-            raise get_exception_from_error(ret.status_code)(response_to_str(ret))
-        if server_capabilities is None:
-            # Some servers returning 200-ok, even if not valid repo
-            raise ConanException(f"Remote {self.remote_url} doesn't seem like a valid Conan remote")
-
-        return [cap.strip() for cap in server_capabilities.split(",") if cap]
-
-    def get_json(self, url, data=None, headers=None):
-        req_headers = self.custom_headers.copy()
-        req_headers.update(headers or {})
-        if data:  # POST request
-            req_headers.update({'Content-type': 'application/json',
-                                'Accept': 'application/json'})
-            # logger.debug("REST: post: %s" % url)
-            response = self.requester.post(url, auth=self.auth, headers=req_headers,
-                                           verify=self.verify_ssl,
-                                           stream=True,
-                                           data=json.dumps(data))
-        else:
-            # logger.debug("REST: get: %s" % url)
-            response = self.requester.get(url, auth=self.auth, headers=req_headers,
-                                          verify=self.verify_ssl,
-                                          stream=True)
-
-        if response.status_code != 200:  # Error message is text
-            response.charset = "utf-8"  # To be able to access ret.text (ret.content are bytes)
-            raise get_exception_from_error(response.status_code)(response_to_str(response))
-
-        content = response.content.decode()
-        content_type = response.headers.get("Content-Type")
-        if content_type != 'application/json' and content_type != 'application/json; charset=utf-8':
-            raise ConanException("%s\n\nResponse from remote is not json, but '%s'"
-                                 % (content, content_type))
-
-        try:  # This can fail, if some proxy returns 200 and an html message
-            result = json.loads(content)
-        except Exception:
-            raise ConanException("Remote responded with broken json: %s" % content)
-        if not isinstance(result, dict):
-            raise ConanException("Unexpected server response %s" % result)
-        return result
-
-    def upload_recipe(self, ref, files_to_upload):
-        if files_to_upload:
-            self._upload_recipe(ref, files_to_upload)
-
-    def upload_package(self, pref, files_to_upload):
-        self._upload_package(pref, files_to_upload)
-
-    def search(self, pattern=None, ignorecase=True):
-        """
-        the_files: dict with relative_path: content
-        """
-        url = self.router.search(pattern, ignorecase)
-        response = self.get_json(url)["results"]
-        # We need to filter the "_/_" user and channel from Artifactory
-        ret = []
-        for reference in response:
-            try:
-                ref = RecipeReference.loads(reference)
-            except TypeError as te:
-                raise ConanException("Unexpected response from server.\n"
-                                     "URL: `{}`\n"
-                                     "Expected an iterable, but got {}.".format(url, type(response)))
-            if ref.user == "_":
-                ref.user = None
-            if ref.channel == "_":
-                ref.channel = None
-            ret.append(ref)
-        return ret
-
-    def search_packages(self, ref):
-        """Client is filtering by the query"""
-        url = self.router.search_packages(ref)
-        package_infos = self.get_json(url)
-        return package_infos
diff --git a/conans/client/rest/rest_client_v2.py b/conans/client/rest/rest_client_v2.py
index 0002e7f05..21d27634a 100644
--- a/conans/client/rest/rest_client_v2.py
+++ b/conans/client/rest/rest_client_v2.py
@@ -1,36 +1,251 @@
 import copy
 import fnmatch
+import json
 import os
 
+from requests.auth import AuthBase, HTTPBasicAuth
+
 from conan.api.output import ConanOutput
 
 from conans.client.downloaders.caching_file_downloader import ConanInternalCacheDownloader
+from conans.client.rest import response_to_str
 from conans.client.rest.client_routes import ClientV2Router
 from conans.client.rest.file_uploader import FileUploader
-from conans.client.rest.rest_client_common import RestCommonMethods, get_exception_from_error
 from conans.errors import ConanException, NotFoundException, PackageNotFoundException, \
-    RecipeNotFoundException, AuthenticationException, ForbiddenException
+    RecipeNotFoundException, AuthenticationException, ForbiddenException, EXCEPTION_CODE_MAPPING
 from conans.model.package_ref import PkgReference
 from conan.internal.paths import EXPORT_SOURCES_TGZ_NAME
+from conans.model.recipe_ref import RecipeReference
 from conans.util.dates import from_iso8601_to_timestamp
 from conans.util.thread import ExceptionThread
 
 
-class RestV2Methods(RestCommonMethods):
+class JWTAuth(AuthBase):
+    """Attaches JWT Authentication to the given Request object."""
+
+    def __init__(self, token):
+        self.token = token
+
+    def __call__(self, request):
+        if self.token:
+            request.headers['Authorization'] = "Bearer %s" % str(self.token)
+        return request
+
+
+def get_exception_from_error(error_code):
+    tmp = {v: k for k, v in EXCEPTION_CODE_MAPPING.items()  # All except NotFound
+           if k not in (RecipeNotFoundException, PackageNotFoundException)}
+    if error_code in tmp:
+        # logger.debug("REST ERROR: %s" % str(tmp[error_code]))
+        return tmp[error_code]
+    else:
+        base_error = int(str(error_code)[0] + "00")
+        # logger.debug("REST ERROR: %s" % str(base_error))
+        try:
+            return tmp[base_error]
+        except KeyError:
+            return None
+
+
+class RestV2Methods:
 
     def __init__(self, remote_url, token, custom_headers, requester, config, verify_ssl,
                  checksum_deploy=False):
-
-        super(RestV2Methods, self).__init__(remote_url, token, custom_headers, requester,
-                                            config, verify_ssl)
+        self.token = token
+        self.remote_url = remote_url
+        self.custom_headers = custom_headers
+        self.requester = requester
+        self._config = config
+        self.verify_ssl = verify_ssl
         self._checksum_deploy = checksum_deploy
 
+    @property
+    def auth(self):
+        return JWTAuth(self.token)
+
+    @staticmethod
+    def _check_error_response(ret):
+        if ret.status_code == 401:
+            raise AuthenticationException("Wrong user or password")
+        # Cannot check content-type=text/html, conan server is doing it wrong
+        if not ret.ok or "html>" in str(ret.content):
+            raise ConanException("%s\n\nInvalid server response, check remote URL and "
+                                 "try again" % str(ret.content))
+
+    def authenticate(self, user, password):
+        """Sends user + password to get:
+          - A plain response with a regular token (not supported refresh in the remote) and None
+        """
+        auth = HTTPBasicAuth(user, password)
+        url = self.router.common_authenticate()
+        # logger.debug("REST: Authenticate to get access_token: %s" % url)
+        ret = self.requester.get(url, auth=auth, headers=self.custom_headers,
+                                 verify=self.verify_ssl)
+
+        self._check_error_response(ret)
+        return ret.content.decode()
+
+    def authenticate_oauth(self, user, password):
+        """Sends user + password to get:
+            - A json with an access_token and a refresh token (if supported in the remote)
+                    Artifactory >= 6.13.X
+        """
+        url = self.router.oauth_authenticate()
+        auth = HTTPBasicAuth(user, password)
+        headers = {}
+        headers.update(self.custom_headers)
+        headers["Content-type"] = "application/x-www-form-urlencoded"
+        # logger.debug("REST: Authenticating with OAUTH: %s" % url)
+        ret = self.requester.post(url, auth=auth, headers=headers, verify=self.verify_ssl)
+        self._check_error_response(ret)
+
+        data = ret.json()
+        access_token = data["access_token"]
+        refresh_token = data["refresh_token"]
+        # logger.debug("REST: Obtained refresh and access tokens")
+        return access_token, refresh_token
+
+    def refresh_token(self, token, refresh_token):
+        """Sends access_token and the refresh_token to get a pair of
+        access_token and refresh token
+
+        Artifactory >= 6.13.X
+        """
+        url = self.router.oauth_authenticate()
+        # logger.debug("REST: Refreshing Token: %s" % url)
+        headers = {}
+        headers.update(self.custom_headers)
+        headers["Content-type"] = "application/x-www-form-urlencoded"
+        payload = {'access_token': token, 'refresh_token': refresh_token,
+                   'grant_type': 'refresh_token'}
+        ret = self.requester.post(url, headers=headers, verify=self.verify_ssl, data=payload)
+        self._check_error_response(ret)
+
+        data = ret.json()
+        if "access_token" not in data:
+            # logger.debug("REST: unexpected data from server: {}".format(data))
+            raise ConanException("Error refreshing the token")
+
+        new_access_token = data["access_token"]
+        new_refresh_token = data["refresh_token"]
+        # logger.debug("REST: Obtained new refresh and access tokens")
+        return new_access_token, new_refresh_token
+
+    def check_credentials(self):
+        """If token is not valid will raise AuthenticationException.
+        User will be asked for new user/pass"""
+        url = self.router.common_check_credentials()
+        # logger.debug("REST: Check credentials: %s" % url)
+        ret = self.requester.get(url, auth=self.auth, headers=self.custom_headers,
+                                 verify=self.verify_ssl)
+        if ret.status_code != 200:
+            ret.charset = "utf-8"  # To be able to access ret.text (ret.content are bytes)
+            text = ret.text if ret.status_code != 404 else "404 Not found"
+            raise get_exception_from_error(ret.status_code)(text)
+        return ret.content.decode()
+
+    def server_capabilities(self, user=None, password=None):
+        """Get information about the server: status, version, type and capabilities"""
+        url = self.router.ping()
+        # logger.debug("REST: ping: %s" % url)
+        if user and password:
+            # This can happen in "conan remote login" cmd. Instead of empty token, use HttpBasic
+            auth = HTTPBasicAuth(user, password)
+        else:
+            auth = self.auth
+        ret = self.requester.get(url, auth=auth, headers=self.custom_headers, verify=self.verify_ssl)
+
+        server_capabilities = ret.headers.get('X-Conan-Server-Capabilities')
+        if not server_capabilities and not ret.ok:
+            # Old Artifactory might return 401/403 without capabilities, we don't want
+            # to cache them #5687, so raise the exception and force authentication
+            raise get_exception_from_error(ret.status_code)(response_to_str(ret))
+        if server_capabilities is None:
+            # Some servers returning 200-ok, even if not valid repo
+            raise ConanException(f"Remote {self.remote_url} doesn't seem like a valid Conan remote")
+
+        return [cap.strip() for cap in server_capabilities.split(",") if cap]
+
+    def _get_json(self, url, data=None, headers=None):
+        req_headers = self.custom_headers.copy()
+        req_headers.update(headers or {})
+        if data:  # POST request
+            req_headers.update({'Content-type': 'application/json',
+                                'Accept': 'application/json'})
+            # logger.debug("REST: post: %s" % url)
+            response = self.requester.post(url, auth=self.auth, headers=req_headers,
+                                           verify=self.verify_ssl,
+                                           stream=True,
+                                           data=json.dumps(data))
+        else:
+            # logger.debug("REST: get: %s" % url)
+            response = self.requester.get(url, auth=self.auth, headers=req_headers,
+                                          verify=self.verify_ssl,
+                                          stream=True)
+
+        if response.status_code != 200:  # Error message is text
+            response.charset = "utf-8"  # To be able to access ret.text (ret.content are bytes)
+            raise get_exception_from_error(response.status_code)(response_to_str(response))
+
+        content = response.content.decode()
+        content_type = response.headers.get("Content-Type")
+        if content_type != 'application/json' and content_type != 'application/json; charset=utf-8':
+            raise ConanException("%s\n\nResponse from remote is not json, but '%s'"
+                                 % (content, content_type))
+
+        try:  # This can fail, if some proxy returns 200 and an html message
+            result = json.loads(content)
+        except Exception:
+            raise ConanException("Remote responded with broken json: %s" % content)
+        if not isinstance(result, dict):
+            raise ConanException("Unexpected server response %s" % result)
+        return result
+
+    def upload_recipe(self, ref, files_to_upload):
+        if files_to_upload:
+            urls = {fn: self.router.recipe_file(ref, fn)
+                    for fn in files_to_upload}
+            self._upload_files(files_to_upload, urls, str(ref))
+
+    def upload_package(self, pref, files_to_upload):
+        urls = {fn: self.router.package_file(pref, fn)
+                for fn in files_to_upload}
+        self._upload_files(files_to_upload, urls, str(pref))
+
+    def search(self, pattern=None, ignorecase=True):
+        """
+        the_files: dict with relative_path: content
+        """
+        url = self.router.search(pattern, ignorecase)
+        response = self._get_json(url)["results"]
+        # We need to filter the "_/_" user and channel from Artifactory
+        ret = []
+        for reference in response:
+            try:
+                ref = RecipeReference.loads(reference)
+            except TypeError:
+                raise ConanException("Unexpected response from server.\n"
+                                     "URL: `{}`\n"
+                                     "Expected an iterable, but got {}.".format(url, type(response)))
+            if ref.user == "_":
+                ref.user = None
+            if ref.channel == "_":
+                ref.channel = None
+            ret.append(ref)
+        return ret
+
+    def search_packages(self, ref):
+        """Client is filtering by the query"""
+        url = self.router.search_packages(ref)
+        package_infos = self._get_json(url)
+        return package_infos
+
     @property
     def router(self):
         return ClientV2Router(self.remote_url.rstrip("/"))
 
     def _get_file_list_json(self, url):
-        data = self.get_json(url)
+        data = self._get_json(url)
         # Discarding (.keys()) still empty metadata for files
         # and make sure the paths like metadata/sign/signature are normalized to /
         data["files"] = list(d.replace("\\", "/") for d in data["files"].keys())
@@ -119,35 +334,22 @@ class RestV2Methods(RestCommonMethods):
                     ret.append(tmp)
         return sorted(ret)
 
-    def _upload_recipe(self, ref, files_to_upload):
-        # Direct upload the recipe
-        urls = {fn: self.router.recipe_file(ref, fn)
-                for fn in files_to_upload}
-        self._upload_files(files_to_upload, urls, str(ref))
-
-    def _upload_package(self, pref, files_to_upload):
-        urls = {fn: self.router.package_file(pref, fn)
-                for fn in files_to_upload}
-        self._upload_files(files_to_upload, urls, str(pref))
-
     def _upload_files(self, files, urls, ref):
         failed = []
         uploader = FileUploader(self.requester, self.verify_ssl, self._config)
         # conan_package.tgz and conan_export.tgz are uploaded first to avoid uploading conaninfo.txt
         # or conanamanifest.txt with missing files due to a network failure
-        output = ConanOutput()
         for filename in sorted(files):
             # As the filenames are sorted, the last one is always "conanmanifest.txt"
             resource_url = urls[filename]
             try:
-                headers = {}
                 uploader.upload(resource_url, files[filename], auth=self.auth,
-                                dedup=self._checksum_deploy,
-                                headers=headers, ref=ref)
+                                dedup=self._checksum_deploy, ref=ref)
             except (AuthenticationException, ForbiddenException):
                 raise
             except Exception as exc:
-                output.error(f"\nError uploading file: {filename}, '{exc}'", error_type="exception")
+                ConanOutput().error(f"\nError uploading file: {filename}, '{exc}'",
+                                    error_type="exception")
                 failed.append(filename)
 
         if failed:
@@ -195,7 +397,7 @@ class RestV2Methods(RestCommonMethods):
             # Double check if it is a 404 because there are no packages
             try:
                 package_search_url = self.router.search_packages(ref)
-                if not self.get_json(package_search_url):
+                if not self._get_json(package_search_url):
                     return
             except Exception as e:
                 pass
@@ -272,7 +474,7 @@ class RestV2Methods(RestCommonMethods):
 
     def get_recipe_revisions_references(self, ref):
         url = self.router.recipe_revisions(ref)
-        tmp = self.get_json(url)["revisions"]
+        tmp = self._get_json(url)["revisions"]
         remote_refs = []
         for item in tmp:
             _tmp = copy.copy(ref)
@@ -286,7 +488,7 @@ class RestV2Methods(RestCommonMethods):
 
     def get_latest_recipe_reference(self, ref):
         url = self.router.recipe_latest(ref)
-        data = self.get_json(url)
+        data = self._get_json(url)
         remote_ref = copy.copy(ref)
         remote_ref.revision = data.get("revision")
         remote_ref.timestamp = from_iso8601_to_timestamp(data.get("time"))
@@ -294,7 +496,7 @@ class RestV2Methods(RestCommonMethods):
 
     def get_package_revisions_references(self, pref, headers=None):
         url = self.router.package_revisions(pref)
-        tmp = self.get_json(url, headers=headers)["revisions"]
+        tmp = self._get_json(url, headers=headers)["revisions"]
         remote_prefs = [PkgReference(pref.ref, pref.package_id, item.get("revision"),
                         from_iso8601_to_timestamp(item.get("time"))) for item in tmp]
 
@@ -307,7 +509,7 @@ class RestV2Methods(RestCommonMethods):
 
     def get_latest_package_reference(self, pref: PkgReference, headers):
         url = self.router.package_latest(pref)
-        data = self.get_json(url, headers=headers)
+        data = self._get_json(url, headers=headers)
         remote_pref = copy.copy(pref)
         remote_pref.revision = data.get("revision")
         remote_pref.timestamp = from_iso8601_to_timestamp(data.get("time"))


Test patch:
diff --git a/test/integration/remote/rest_api_test.py b/test/integration/remote/rest_api_test.py
index 7710d1faa..5566bbbfb 100644
--- a/test/integration/remote/rest_api_test.py
+++ b/test/integration/remote/rest_api_test.py
@@ -1,14 +1,12 @@
 import os
 import unittest
 
-import pytest
 from mock import Mock
 
-from conans import REVISIONS
 from conans.client.remote_manager import Remote
 from conans.client.rest.auth_manager import ConanApiAuthManager
 from conans.client.rest.conan_requester import ConanRequester
-from conans.client.rest.rest_client import RestApiClientFactory
+from conans.client.rest.rest_client import RestApiClient
 from conans.model.conf import ConfDefinition
 from conan.test.utils.env import environment_update
 from conans.client.userio import UserInput
@@ -24,7 +22,6 @@ from conan.test.utils.tools import get_free_port
 from conans.util.files import md5, save
 
 
-@pytest.mark.rest_api
 class RestApiTest(unittest.TestCase):
     """Open a real server (sockets) to test rest_api function."""
 
@@ -42,8 +39,6 @@ class RestApiTest(unittest.TestCase):
                 save(filename, "")
                 config = ConfDefinition()
                 requester = ConanRequester(config)
-                client_factory = RestApiClientFactory(requester=requester,
-                                                      config=config)
                 localdb = LocalDBMock()
 
                 mocked_user_input = UserInput(non_interactive=False)
@@ -51,13 +46,13 @@ class RestApiTest(unittest.TestCase):
                 mocked_user_input.get_password = Mock(return_value="private_pass")
 
                 # FIXME: Missing mock
-                cls.auth_manager = ConanApiAuthManager(client_factory, temp_folder(), localdb, config)
+                cls.auth_manager = ConanApiAuthManager(requester, temp_folder(), localdb, config)
                 cls.remote = Remote("myremote", "http://127.0.0.1:%s" % str(cls.server.port), True,
                                     True)
                 cls.auth_manager._authenticate(cls.remote, user="private_user",
                                                password="private_pass")
-                cls.api = client_factory.new(cls.remote, localdb.access_token, localdb.refresh_token,
-                                             {})
+                cls.api = RestApiClient(cls.remote, localdb.access_token, localdb.refresh_token,
+                                        {}, requester, config, {})
 
     @classmethod
     def tearDownClass(cls):
@@ -66,10 +61,6 @@ class RestApiTest(unittest.TestCase):
     def tearDown(self):
         RestApiTest.server.clean()
 
-    def test_server_capabilities(self):
-        capabilities = self.api.server_capabilities()
-        self.assertEqual(capabilities, ["ImCool", "TooCool", REVISIONS])
-
     def test_get_conan(self):
         # Upload a conans
         ref = RecipeReference.loads("conan1/1.0.0@private_user/testing#myreciperev")
diff --git a/test/integration/remote/token_refresh_test.py b/test/integration/remote/token_refresh_test.py
index 8bae5ab59..b1ef3ce03 100644
--- a/test/integration/remote/token_refresh_test.py
+++ b/test/integration/remote/token_refresh_test.py
@@ -4,7 +4,6 @@ import mock
 
 from conan.api.model import Remote
 from conans.client.rest.auth_manager import ConanApiAuthManager
-from conans.client.rest.rest_client import RestApiClientFactory
 from conans.model.conf import ConfDefinition
 from conans.model.recipe_ref import RecipeReference
 from conan.test.utils.mocks import LocalDBMock
@@ -78,9 +77,8 @@ class TestTokenRefresh(unittest.TestCase):
     def setUp(self):
         requester = RequesterWithTokenMock()
         config = ConfDefinition()
-        self.rest_client_factory = RestApiClientFactory(requester, config=config)
         self.localdb = LocalDBMock()
-        self.auth_manager = ConanApiAuthManager(self.rest_client_factory, temp_folder(), self.localdb, config)
+        self.auth_manager = ConanApiAuthManager(requester, temp_folder(), self.localdb, config)
         self.remote = Remote("myremote", "myurl", True, True)
         self.ref = RecipeReference.loads("lib/1.0@conan/stable#myreciperev")