Skip to content

Commit 43d5a38

Browse files
author
Joe Reed
committed
added request_modifier hook to StacApiIO to enable AWS SigV4 signing
A modifier provides the ability to modify the request immediately before sending, a requirement for AWS SigV4. This allows users to plug in their signing method. Also, added the `stac_io` parameter to `Client.open()` allowing for easy usage of a custom `StacApiIO` instance.
1 parent a7fdec1 commit 43d5a38

File tree

4 files changed

+87
-4
lines changed

4 files changed

+87
-4
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Added
1111

1212
- Python 3.11 support [#347](https://github.com/stac-utils/pystac-client/pull/347)
13+
- Added `modifier` to `StacApiIO` to allow for additional authentication mechanisms (e.g. AWS SigV4) [#371](https://github.com/stac-utils/pystac-client/issues/371).
1314

1415
### Changed
1516

pystac_client/client.py

+29-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pystac
1515
import pystac.validation
1616
from pystac import CatalogType, Collection
17+
from requests import Request
1718

1819
from pystac_client._utils import Modifiable, call_modifier
1920
from pystac_client.collection_client import CollectionClient
@@ -93,6 +94,8 @@ def open(
9394
parameters: Optional[Dict[str, Any]] = None,
9495
ignore_conformance: bool = False,
9596
modifier: Optional[Callable[[Modifiable], None]] = None,
97+
request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None,
98+
stac_io: Optional[StacApiIO] = None,
9699
) -> "Client":
97100
"""Opens a STAC Catalog or API
98101
This function will read the root catalog of a STAC Catalog or API
@@ -128,12 +131,31 @@ def open(
128131
After getting a child collection with, e.g.
129132
:meth:`Client.get_collection`, the child items of that collection
130133
will still be signed with ``modifier``.
134+
request_modifier: A callable that eitehr modifies a `Request` instance or
135+
returns a new one. This can be useful for injecting Authentication
136+
headers and/or signing fully-formed requests (e.g. signing requests
137+
using AWS SigV4).
138+
139+
The callable should expect a single argument, which will be an instance
140+
of :class:`requests.Request`.
141+
142+
If the callable returns a `requests.Request`, that will be used.
143+
Alternately, the calable may simply modify the provided request object
144+
and return `None`.
145+
stac_io: A `StacApiIO` object to use for I/O requests. Generally, leave
146+
this to the default. However in cases where customized I/O processing
147+
is required, a custom instance can be provided here.
131148
132149
Return:
133150
catalog : A :class:`Client` instance for this Catalog/API
134151
"""
135152
client: Client = cls.from_file(
136-
url, headers=headers, parameters=parameters, modifier=modifier
153+
url,
154+
headers=headers,
155+
parameters=parameters,
156+
modifier=modifier,
157+
request_modifier=request_modifier,
158+
stac_io=stac_io,
137159
)
138160
search_link = client.get_search_link()
139161
# if there is a search link, but no conformsTo advertised, ignore
@@ -161,14 +183,19 @@ def from_file( # type: ignore
161183
headers: Optional[Dict[str, str]] = None,
162184
parameters: Optional[Dict[str, Any]] = None,
163185
modifier: Optional[Callable[[Modifiable], None]] = None,
186+
request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None,
164187
) -> "Client":
165188
"""Open a STAC Catalog/API
166189
167190
Returns:
168191
Client: A Client (PySTAC Catalog) of the root Catalog for this Catalog/API
169192
"""
170193
if stac_io is None:
171-
stac_io = StacApiIO(headers=headers, parameters=parameters)
194+
stac_io = StacApiIO(
195+
headers=headers,
196+
parameters=parameters,
197+
request_modifier=request_modifier,
198+
)
172199

173200
client: Client = super().from_file(href, stac_io) # type: ignore
174201

pystac_client/stac_api_io.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import re
44
from copy import deepcopy
5-
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional
5+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
66
from urllib.parse import urlparse
77

88
import pystac
@@ -34,6 +34,7 @@ def __init__(
3434
headers: Optional[Dict[str, str]] = None,
3535
conformance: Optional[List[str]] = None,
3636
parameters: Optional[Dict[str, Any]] = None,
37+
request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None,
3738
):
3839
"""Initialize class for API IO
3940
@@ -43,6 +44,10 @@ def __init__(
4344
<https://github.com/radiantearth/stac-api-spec/blob/master/overview.md#conformance-classes>`__.
4445
parameters: Optional dictionary of query string parameters to
4546
include in all requests.
47+
request_modifier: Optional callable that can be used to modify Request
48+
objects before they are sent. If provided, the callable receives a
49+
`request.Request` and must either modify the object directly or return
50+
a new / modified request instance.
4651
4752
Return:
4853
StacApiIO : StacApiIO instance
@@ -54,6 +59,8 @@ def __init__(
5459

5560
self._conformance = conformance
5661

62+
self._req_modifier = request_modifier
63+
5764
def read_text(self, source: pystac.link.HREF, *args: Any, **kwargs: Any) -> str:
5865
"""Read text from the given URI.
5966
@@ -132,7 +139,8 @@ def request(
132139
params["intersects"] = json.dumps(params["intersects"])
133140
request = Request(method="GET", url=href, headers=headers, params=params)
134141
try:
135-
prepped = self.session.prepare_request(request)
142+
modified = self._req_modifier(request) if self._req_modifier else None
143+
prepped = self.session.prepare_request(modified or request)
136144
msg = f"{prepped.method} {prepped.url} Headers: {prepped.headers}"
137145
if method == "POST":
138146
msg += f" Payload: {json.dumps(request.json)}"

tests/test_stac_api_io.py

+47
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import typing
12
from pathlib import Path
23
from urllib.parse import parse_qs, urlsplit
34

@@ -85,6 +86,52 @@ def test_custom_headers(self, requests_mock: Mocker) -> None:
8586
assert header_name in history[0].headers
8687
assert history[0].headers[header_name] == header_value
8788

89+
def test_modifier(self, requests_mock: Mocker) -> None:
90+
"""Verify the modifier is correctly called with a returned object."""
91+
header_name = "x-my-header"
92+
header_value = "Some Value"
93+
url = "https://some-url.com/some-file.json"
94+
95+
def custom_modifier(request: typing.Any) -> typing.Union[typing.Any, None]:
96+
request.headers["x-pirate-name"] = "yellowbeard"
97+
return request
98+
99+
stac_api_io = StacApiIO(
100+
headers={header_name: header_value}, request_modifier=custom_modifier
101+
)
102+
103+
requests_mock.get(url, status_code=200, json={})
104+
105+
stac_api_io.read_json(url)
106+
107+
history = requests_mock.request_history
108+
assert len(history) == 1
109+
assert header_name in history[0].headers
110+
assert history[0].headers["x-pirate-name"] == "yellowbeard"
111+
112+
def test_modifier_noreturn(self, requests_mock: Mocker) -> None:
113+
"""Verify the modifier is correctly called when None is returned."""
114+
header_name = "x-my-header"
115+
header_value = "Some Value"
116+
url = "https://some-url.com/some-file.json"
117+
118+
def custom_modifier(request: typing.Any) -> typing.Union[typing.Any, None]:
119+
request.headers["x-pirate-name"] = "yellowbeard"
120+
return None
121+
122+
stac_api_io = StacApiIO(
123+
headers={header_name: header_value}, request_modifier=custom_modifier
124+
)
125+
126+
requests_mock.get(url, status_code=200, json={})
127+
128+
stac_api_io.read_json(url)
129+
130+
history = requests_mock.request_history
131+
assert len(history) == 1
132+
assert header_name in history[0].headers
133+
assert history[0].headers["x-pirate-name"] == "yellowbeard"
134+
88135
def test_custom_query_params(self, requests_mock: Mocker) -> None:
89136
"""Checks that query params passed to the init method are added to requests."""
90137
init_qp_name = "my-param"

0 commit comments

Comments
 (0)