Skip to content

Commit 87c4a2a

Browse files
gadomskijohn-dupuy
andauthored
Allow for generic auth headers with pystac + validators (#392)
* feat: allow generic auth headers with pystac + validators * lint: formatting * fix: update requests version in fixtures --------- Co-authored-by: john-dupuy <[email protected]>
1 parent 0237a1f commit 87c4a2a

File tree

7 files changed

+290
-9
lines changed

7 files changed

+290
-9
lines changed

noxfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def mypy(session: Session) -> None:
152152
"""Type-check using mypy."""
153153
args = session.posargs or ["src", "tests", "docs/conf.py"]
154154
session.install(".")
155-
session.install("mypy", "pytest", "types-requests", "types-PyYAML")
155+
session.install("mypy", "pytest", "types-requests", "types-PyYAML", "typeguard")
156156
session.run("mypy", *args)
157157
if not session.posargs:
158158
session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py")
@@ -162,7 +162,7 @@ def mypy(session: Session) -> None:
162162
def tests(session: Session) -> None:
163163
"""Run the test suite."""
164164
session.install(".")
165-
session.install("coverage[toml]", "pytest", "pygments")
165+
session.install("coverage[toml]", "pytest", "pygments", "typeguard")
166166
try:
167167
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
168168
finally:

src/stac_api_validator/__main__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@
131131
"--transaction-collection",
132132
help="The name of the collection to use for Transaction Extension tests.",
133133
)
134+
@click.option(
135+
"-H",
136+
"--headers",
137+
multiple=True,
138+
help="Headers to attach to the main request and dependent pystac requests, curl syntax",
139+
)
134140
def main(
135141
log_level: str,
136142
root_url: str,
@@ -155,11 +161,21 @@ def main(
155161
query_in_field: Optional[str] = None,
156162
query_in_values: Optional[str] = None,
157163
transaction_collection: Optional[str] = None,
164+
headers: Optional[List[str]] = None,
158165
) -> int:
159166
"""STAC API Validator."""
160167
logging.basicConfig(stream=sys.stdout, level=log_level)
161168

162169
try:
170+
processed_headers = {}
171+
if headers:
172+
processed_headers.update(
173+
{
174+
key.strip(): value.strip()
175+
for key, value in (header.split(":") for header in headers)
176+
}
177+
)
178+
163179
(warnings, errors) = validate_api(
164180
root_url=root_url,
165181
ccs_to_validate=conformance_classes,
@@ -185,6 +201,7 @@ def main(
185201
query_in_values,
186202
),
187203
transaction_collection=transaction_collection,
204+
headers=processed_headers,
188205
)
189206
except Exception as e:
190207
click.secho(

src/stac_api_validator/validations.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from pystac import Collection
2828
from pystac import Item
2929
from pystac import ItemCollection
30+
from pystac import StacIO
3031
from pystac import STACValidationError
3132
from pystac_client import Client
3233
from requests import Request
@@ -298,6 +299,16 @@ def is_geojson_type(maybe_type: Optional[str]) -> bool:
298299
)
299300

300301

302+
def get_catalog(data_dict: Dict[str, Any], r_session: Session) -> Catalog:
303+
stac_io = StacIO.default()
304+
if r_session.headers and r_session.headers.get("Authorization"):
305+
stac_io.headers = r_session.headers # noqa, type: ignore
306+
stac_io.headers["Accept-Encoding"] = "*"
307+
catalog = Catalog.from_dict(data_dict)
308+
catalog._stac_io = stac_io
309+
return catalog
310+
311+
301312
# def is_json_or_geojson_type(maybe_type: Optional[str]) -> bool:
302313
# return maybe_type and (is_json_type(maybe_type) or is_geojson_type(maybe_type))
303314

@@ -381,9 +392,8 @@ def retrieve(
381392
additional: Optional[str] = "",
382393
content_type: Optional[str] = None,
383394
) -> Tuple[int, Optional[Dict[str, Any]], Optional[Mapping[str, str]]]:
384-
resp = r_session.send(
385-
Request(method.value, url, headers=headers, params=params, json=body).prepare()
386-
)
395+
request = Request(method.value, url, headers=headers, params=params, json=body)
396+
resp = r_session.send(r_session.prepare_request(request))
387397

388398
# todo: handle connection exception, etc.
389399
# todo: handle timeout
@@ -537,6 +547,7 @@ def validate_api(
537547
validate_pagination: bool,
538548
query_config: QueryConfig,
539549
transaction_collection: Optional[str],
550+
headers: Optional[Dict[str, str]],
540551
) -> Tuple[Warnings, Errors]:
541552
warnings = Warnings()
542553
errors = Errors()
@@ -548,6 +559,9 @@ def validate_api(
548559
if auth_query_parameter and (xs := auth_query_parameter.split("=", 1)):
549560
r_session.params = {xs[0]: xs[1]}
550561

562+
if headers:
563+
r_session.headers.update(headers)
564+
551565
_, landing_page_body, landing_page_headers = retrieve(
552566
Method.GET, root_url, errors, Context.CORE, r_session
553567
)
@@ -704,7 +718,7 @@ def validate_api(
704718

705719
if not errors:
706720
try:
707-
catalog = Client.open(root_url)
721+
catalog = Client.open(root_url, headers=headers)
708722
catalog.validate()
709723
for child in catalog.get_children():
710724
child.validate()
@@ -811,7 +825,8 @@ def validate_core(
811825
# this validates, among other things, that the child and item link relations reference
812826
# valid STAC Catalogs, Collections, and/or Items
813827
try:
814-
list(take(1000, Catalog.from_dict(root_body).get_all_items()))
828+
catalog = get_catalog(root_body, r_session)
829+
list(take(1000, catalog.get_all_items()))
815830
except pystac.errors.STACTypeError as e:
816831
errors += (
817832
f"[{Context.CORE}] Error while traversing Catalog child/item links to find Items: {e} "
@@ -839,14 +854,15 @@ def validate_browseable(
839854
# check that at least a few of the items that can be reached from child/item link relations
840855
# can be found through search
841856
try:
842-
for item in take(10, Catalog.from_dict(root_body).get_all_items()):
857+
catalog = get_catalog(root_body, r_session)
858+
for item in take(10, catalog.get_all_items()):
843859
if link := link_by_rel(root_body.get("links"), "search"):
844860
_, body, _ = retrieve(
845861
Method.GET,
846862
link["href"],
847863
errors,
848864
Context.BROWSEABLE,
849-
params={"ids": item.id, "collections": item.collection},
865+
params={"ids": item.id, "collections": item.collection_id},
850866
r_session=r_session,
851867
)
852868
if body and len(body.get("features", [])) != 1:

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import importlib.metadata
2+
3+
import pytest
4+
5+
6+
@pytest.fixture
7+
def requests_version() -> str:
8+
return importlib.metadata.version("requests")

tests/resources/sample-item.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"type": "Feature",
3+
"stac_version": "1.0.0",
4+
"id": "CS3-20160503_132131_05",
5+
"properties": {
6+
"datetime": "2016-05-03T13:22:30.040000Z",
7+
"title": "A CS3 item",
8+
"license": "PDDL-1.0",
9+
"providers": [
10+
{
11+
"name": "CoolSat",
12+
"roles": ["producer", "licensor"],
13+
"url": "https://cool-sat.com/"
14+
}
15+
]
16+
},
17+
"geometry": {
18+
"type": "Polygon",
19+
"coordinates": [
20+
[
21+
[-122.308150179, 37.488035566],
22+
[-122.597502109, 37.538869539],
23+
[-122.576687533, 37.613537207],
24+
[-122.2880486, 37.562818007],
25+
[-122.308150179, 37.488035566]
26+
]
27+
]
28+
},
29+
"links": [
30+
{
31+
"rel": "collection",
32+
"href": "https://raw.githubusercontent.com/radiantearth/stac-spec/v0.8.1/collection-spec/examples/sentinel2.json"
33+
}
34+
],
35+
"assets": {
36+
"analytic": {
37+
"href": "http://cool-sat.com/catalog/CS3-20160503_132130_04/analytic.tif",
38+
"title": "4-Band Analytic",
39+
"product": "http://cool-sat.com/catalog/products/analytic.json",
40+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
41+
"roles": ["data", "analytic"]
42+
},
43+
"thumbnail": {
44+
"href": "http://cool-sat.com/catalog/CS3-20160503_132130_04/thumbnail.png",
45+
"title": "Thumbnail",
46+
"type": "image/png",
47+
"roles": ["thumbnail"]
48+
}
49+
},
50+
"bbox": [-122.59750209, 37.48803556, -122.2880486, 37.613537207],
51+
"stac_extensions": [],
52+
"collection": "CS3"
53+
}

tests/test_main.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Test cases for the __main__ module."""
22

3+
import unittest.mock
4+
35
import pytest
46
from click.testing import CliRunner
57

@@ -15,3 +17,38 @@ def runner() -> CliRunner:
1517
def test_main_fails(runner: CliRunner) -> None:
1618
result = runner.invoke(__main__.main)
1719
assert result.exit_code == 2
20+
21+
22+
def test_retrieve_called_with_auth_headers(
23+
request: pytest.FixtureRequest, runner: CliRunner, requests_version: str
24+
) -> None:
25+
if request.config.getoption("typeguard_packages"):
26+
pytest.skip(
27+
"The import hook that typeguard uses seems to break the mock below."
28+
)
29+
30+
expected_headers = {
31+
"User-Agent": f"python-requests/{requests_version}",
32+
"Accept-Encoding": "gzip, deflate",
33+
"Accept": "*/*",
34+
"Connection": "keep-alive",
35+
"Authorization": "api-key fake-api-key-value",
36+
}
37+
38+
with unittest.mock.patch(
39+
"stac_api_validator.validations.retrieve"
40+
) as retrieve_mock:
41+
runner.invoke(
42+
__main__.main,
43+
args=[
44+
"--root-url",
45+
"https://invalid",
46+
"--conformance",
47+
"core",
48+
"-H",
49+
"Authorization: api-key fake-api-key-value",
50+
],
51+
)
52+
assert retrieve_mock.call_count == 1
53+
r_session = retrieve_mock.call_args.args[-1]
54+
assert r_session.headers == expected_headers

0 commit comments

Comments
 (0)