Skip to content

Commit 4fea3f9

Browse files
Initial implementation of HTTP connector
1 parent dfcec5f commit 4fea3f9

17 files changed

+899
-330
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
singer_sdk.connectors.BaseConnector
2+
===================================
3+
4+
.. currentmodule:: singer_sdk.connectors
5+
6+
.. autoclass:: BaseConnector
7+
:members:
8+
:special-members: __init__, __call__

docs/guides/custom-connector.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Using a custom connector class
2+
3+
The Singer SDK has a few built-in connector classes that are designed to work with a variety of sources:
4+
5+
* [`SQLConnector`](../../classes/singer_sdk.SQLConnector) for SQL databases
6+
7+
If you need to connect to a source that is not supported by one of these built-in connectors, you can create your own connector class. This guide will walk you through the process of creating a custom connector class.
8+
9+
## Subclass `BaseConnector`
10+
11+
The first step is to create a subclass of [`BaseConnector`](../../classes/singer_sdk.connectors.BaseConnector). This class is responsible for creating streams and handling the connection to the source.
12+
13+
```python
14+
from singer_sdk.connectors import BaseConnector
15+
16+
17+
class MyConnector(BaseConnector):
18+
pass
19+
```
20+
21+
## Implement `get_connection`
22+
23+
The [`get_connection`](http://127.0.0.1:5500/build/classes/singer_sdk.connectors.BaseConnector.html#singer_sdk.connectors.BaseConnector.get_connection) method is responsible for creating a connection to the source. It should return an object that implements the [context manager protocol](https://docs.python.org/3/reference/datamodel.html#with-statement-context-managers), e.g. it has `__enter__` and `__exit__` methods.
24+
25+
```python
26+
from singer_sdk.connectors import BaseConnector
27+
28+
29+
class MyConnector(BaseConnector):
30+
def get_connection(self):
31+
return MyConnection()
32+
```

docs/guides/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ The following pages contain useful information for developers building on top of
77
88
porting
99
pagination-classes
10+
custom-connector
1011
```

docs/reference.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,12 @@ Pagination
130130
pagination.BaseOffsetPaginator
131131
pagination.LegacyPaginatedStreamProtocol
132132
pagination.LegacyStreamPaginator
133+
134+
Abstract Connector Classes
135+
--------------------------
136+
137+
.. autosummary::
138+
:toctree: classes
139+
:template: class.rst
140+
141+
connectors.BaseConnector

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: 465 additions & 302 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,<13"
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

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

singer_sdk/connectors/__init__.py

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

33
from __future__ import annotations
44

5+
from ._http import HTTPConnector
6+
from .base import BaseConnector
57
from .sql import SQLConnector
68

7-
__all__ = ["SQLConnector"]
9+
__all__ = ["BaseConnector", "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)