Skip to content

Commit 89fd553

Browse files
Initial implementation of HTTP connector
1 parent 662266a commit 89fd553

File tree

12 files changed

+400
-27
lines changed

12 files changed

+400
-27
lines changed

noxfile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
test_dependencies = [
3535
"coverage[toml]",
3636
"pytest",
37-
"pytest-snapshot",
3837
"pytest-durations",
38+
"pytest-httpserver",
39+
"pytest-snapshot",
3940
"freezegun",
4041
"pandas",
4142
"pyarrow",

poetry.lock

Lines changed: 37 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ types-simplejson = "^3.18.0"
113113
types-PyYAML = "^6.0.12"
114114
coverage = {extras = ["toml"], version = "^7.2"}
115115
pyarrow = "^11.0.0"
116+
pytest-httpserver = "^1.0.6"
116117
pytest-snapshot = "^0.9.0"
117118

118119
# Cookiecutter tests

singer_sdk/authenticators.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import requests
1414
from cryptography.hazmat.backends import default_backend
1515
from cryptography.hazmat.primitives import serialization
16+
from requests.auth import AuthBase
1617

1718
from singer_sdk.helpers._util import utc_now
1819

@@ -589,3 +590,52 @@ def oauth_request_payload(self) -> dict:
589590
"RS256",
590591
),
591592
}
593+
594+
595+
class NoopAuth(AuthBase):
596+
"""No-op authenticator."""
597+
598+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
599+
"""Do nothing.
600+
601+
Args:
602+
r: The prepared request.
603+
604+
Returns:
605+
The unmodified prepared request.
606+
"""
607+
return r
608+
609+
610+
class HeaderAuth(AuthBase):
611+
"""Header-based authenticator."""
612+
613+
def __init__(
614+
self,
615+
keyword: str,
616+
value: str,
617+
header: str = "Authorization",
618+
) -> None:
619+
"""Initialize the authenticator.
620+
621+
Args:
622+
keyword: The keyword to use in the header, e.g. "Bearer".
623+
value: The value to use in the header, e.g. "my-token".
624+
header: The header to add the keyword and value to, defaults to
625+
``"Authorization"``.
626+
"""
627+
self.keyword = keyword
628+
self.value = value
629+
self.header = header
630+
631+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
632+
"""Add the header to the request.
633+
634+
Args:
635+
r: The prepared request.
636+
637+
Returns:
638+
The prepared request with the header added.
639+
"""
640+
r.headers[self.header] = f"{self.keyword} {self.value}"
641+
return r

singer_sdk/connectors/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from ._http import HTTPConnector
56
from .sql import SQLConnector
67

7-
__all__ = ["SQLConnector"]
8+
__all__ = ["HTTPConnector", "SQLConnector"]

singer_sdk/connectors/_http.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""HTTP-based tap class for Singer SDK."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
7+
import requests
8+
9+
from singer_sdk.authenticators import NoopAuth
10+
from singer_sdk.connectors.base import BaseConnector
11+
12+
if t.TYPE_CHECKING:
13+
import sys
14+
15+
from requests.adapters import BaseAdapter
16+
17+
if sys.version_info >= (3, 10):
18+
from typing import TypeAlias # noqa: ICN003
19+
else:
20+
from typing_extensions import TypeAlias
21+
22+
_Auth: TypeAlias = t.Callable[[requests.PreparedRequest], requests.PreparedRequest]
23+
24+
25+
class HTTPConnector(BaseConnector[requests.Session]):
26+
"""Base class for all HTTP-based connectors."""
27+
28+
def __init__(self, config: t.Mapping[str, t.Any] | None) -> None:
29+
"""Initialize the HTTP connector.
30+
31+
Args:
32+
config: Connector configuration parameters.
33+
"""
34+
super().__init__(config)
35+
self._session = self.get_session()
36+
self.refresh_auth()
37+
38+
def get_connection(self, *, authenticate: bool = True) -> requests.Session:
39+
"""Return a new HTTP session object.
40+
41+
Adds adapters and optionally authenticates the session.
42+
43+
Args:
44+
authenticate: Whether to authenticate the request.
45+
46+
Returns:
47+
A new HTTP session object.
48+
"""
49+
for prefix, adapter in self.adapters.items():
50+
self._session.mount(prefix, adapter)
51+
52+
self._session.auth = self._auth if authenticate else None
53+
54+
return self._session
55+
56+
def get_session(self) -> requests.Session:
57+
"""Return a new HTTP session object.
58+
59+
Returns:
60+
A new HTTP session object.
61+
"""
62+
return requests.Session()
63+
64+
def get_authenticator(self) -> _Auth:
65+
"""Authenticate the HTTP session.
66+
67+
Returns:
68+
An auth callable.
69+
"""
70+
return NoopAuth()
71+
72+
def refresh_auth(self) -> None:
73+
"""Refresh the HTTP session authentication."""
74+
self._auth = self.get_authenticator()
75+
76+
@property
77+
def adapters(self) -> dict[str, BaseAdapter]:
78+
"""Return a mapping of URL prefixes to adapter objects.
79+
80+
Returns:
81+
A mapping of URL prefixes to adapter objects.
82+
"""
83+
return {}
84+
85+
@property
86+
def default_request_kwargs(self) -> dict[str, t.Any]:
87+
"""Return default kwargs for HTTP requests.
88+
89+
Returns:
90+
A mapping of default kwargs for HTTP requests.
91+
"""
92+
return {}
93+
94+
def request(
95+
self,
96+
*args: t.Any,
97+
authenticate: bool = True,
98+
**kwargs: t.Any,
99+
) -> requests.Response:
100+
"""Make an HTTP request.
101+
102+
Args:
103+
*args: Positional arguments to pass to the request method.
104+
authenticate: Whether to authenticate the request.
105+
**kwargs: Keyword arguments to pass to the request method.
106+
107+
Returns:
108+
The HTTP response object.
109+
"""
110+
with self._connect(authenticate=authenticate) as session:
111+
kwargs = {**self.default_request_kwargs, **kwargs}
112+
return session.request(*args, **kwargs)

singer_sdk/connectors/base.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Base class for all connectors."""
2+
3+
from __future__ import annotations
4+
5+
import abc
6+
import typing as t
7+
from contextlib import contextmanager
8+
9+
from singer_sdk.helpers._compat import Protocol
10+
11+
_T = t.TypeVar("_T", covariant=True)
12+
13+
14+
class ContextManagerProtocol(Protocol[_T]):
15+
"""Protocol for context manager enter/exit."""
16+
17+
def __enter__(self) -> _T: # noqa: D105
18+
... # pragma: no cover
19+
20+
def __exit__(self, *args: t.Any) -> None: # noqa: D105
21+
... # pragma: no cover
22+
23+
24+
_C = t.TypeVar("_C", bound=ContextManagerProtocol)
25+
26+
27+
class BaseConnector(abc.ABC, t.Generic[_C]):
28+
"""Base class for all connectors."""
29+
30+
def __init__(self, config: t.Mapping[str, t.Any] | None) -> None:
31+
"""Initialize the connector.
32+
33+
Args:
34+
config: Plugin configuration parameters.
35+
"""
36+
self._config = config or {}
37+
38+
@property
39+
def config(self) -> t.Mapping:
40+
"""Return the connector configuration.
41+
42+
Returns:
43+
A mapping of configuration parameters.
44+
"""
45+
return self._config
46+
47+
@contextmanager
48+
def _connect(self, *args: t.Any, **kwargs: t.Any) -> t.Generator[_C, None, None]:
49+
"""Connect to the destination.
50+
51+
Args:
52+
args: Positional arguments to pass to the connection method.
53+
kwargs: Keyword arguments to pass to the connection method.
54+
55+
Yields:
56+
A connection object.
57+
"""
58+
with self.get_connection(*args, **kwargs) as connection:
59+
yield connection
60+
61+
@abc.abstractmethod
62+
def get_connection(self, *args: t.Any, **kwargs: t.Any) -> _C:
63+
"""Connect to the destination.
64+
65+
Args:
66+
args: Positional arguments to pass to the connection method.
67+
kwargs: Keyword arguments to pass to the connection method.
68+
"""
69+
...

0 commit comments

Comments
 (0)