Skip to content

Commit 004ee97

Browse files
committed
add Transaction Extension support
1 parent f7b2adc commit 004ee97

File tree

4 files changed

+231
-4
lines changed

4 files changed

+231
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Added support for Fields Extension validation of Item Search
1313
- Add parameter set `--validate-pagination/--no-validate-pagination` to conditionally run the pagination tests, which may take a while to run.
1414
- Added support for Query Extension validation of Item Search
15+
- Added support for Transaction Extension validation
1516

1617
## [0.5.0] - 2023-02-21
1718

COMPLIANCE_REPORT.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ Date: 17-Mar-2022
100100
Output:
101101

102102
```text
103+
poetry run stac-api-validator --root-url http://localhost:3000 \
104+
--conformance transaction \
105+
--transaction-collection sentinel-2-l2a-test
103106
Validating http://localhost:3000
104107
STAC API - Core conformance class found.
105108
STAC API - Features conformance class found.

src/stac_api_validator/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@
126126
"--query-in-values",
127127
help="Query Extension: comma-separated values of field to use for 'in' operator tests",
128128
)
129+
@click.option(
130+
"--transaction-collection",
131+
help="The name of the collection to use for Transaction Extension tests.",
132+
)
129133
def main(
130134
log_level: str,
131135
root_url: str,
@@ -149,6 +153,7 @@ def main(
149153
query_contains_value: Optional[str] = None,
150154
query_in_field: Optional[str] = None,
151155
query_in_values: Optional[str] = None,
156+
transaction_collection: Optional[str] = None,
152157
) -> int:
153158
"""STAC API Validator."""
154159
logging.basicConfig(stream=sys.stdout, level=log_level)
@@ -178,6 +183,7 @@ def main(
178183
query_in_field,
179184
query_in_values,
180185
),
186+
transaction_collection=transaction_collection,
181187
)
182188
except Exception as e:
183189
click.secho(

src/stac_api_validator/validations.py

Lines changed: 221 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Validations module."""
2+
import copy
23
import itertools
34
import json
45
import logging
56
import re
7+
import time
68
from dataclasses import dataclass
79
from enum import Enum
810
from typing import Any
@@ -86,6 +88,9 @@
8688
class Method(Enum):
8789
GET = "GET"
8890
POST = "POST"
91+
PUT = "PUT"
92+
PATCH = "PATCH"
93+
DELETE = "DELETE"
8994

9095
def __str__(self) -> str:
9196
return self.value
@@ -392,11 +397,14 @@ def retrieve(
392397
if not content_type:
393398
if url.endswith("/search") or url.endswith("/items"):
394399
if not has_content_type(resp.headers, geojson_mt):
395-
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is not '{geojson_mt}'"
400+
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is {resp.headers.get('content-type')} instead of '{geojson_mt}'"
396401
elif not has_content_type(resp.headers, "application/json"):
397-
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is not 'application/json'"
402+
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is {resp.headers.get('content-type')} instead of 'application/json'"
403+
elif content_type == "undefined":
404+
if resp.headers.get("content-type"):
405+
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is {resp.headers.get('content-type')} instead of undefined"
398406
elif not has_content_type(resp.headers, content_type):
399-
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is not '{content_type}'"
407+
errors += f"[{context}] : {method} {url} params={params} body={body} content-type header is {resp.headers.get('content-type')} instead of '{content_type}'"
400408

401409
if has_json_content_type(resp.headers) or has_geojson_content_type(
402410
resp.headers
@@ -524,6 +532,7 @@ def validate_api(
524532
fields_nested_property: Optional[str],
525533
validate_pagination: bool,
526534
query_config: QueryConfig,
535+
transaction_collection: Optional[str],
527536
) -> Tuple[Warnings, Errors]:
528537
warnings = Warnings()
529538
errors = Errors()
@@ -593,7 +602,15 @@ def validate_api(
593602
logger.info(
594603
"STAC API - Features - Transaction extension conformance class found."
595604
)
596-
logger.info("STAC API - Features - Transaction extension is not yet supported.")
605+
validate_transaction(
606+
context=Context.FEATURES_TXN,
607+
landing_page_body=landing_page_body,
608+
collection=collection,
609+
errors=errors,
610+
warnings=warnings,
611+
r_session=r_session,
612+
transaction_collection=transaction_collection,
613+
)
597614

598615
if "features#fields" in ccs_to_validate:
599616
logger.info("STAC API - Features - Fields extension conformance class found.")
@@ -3315,3 +3332,203 @@ def validate_item_search_collections(
33153332
errors,
33163333
r_session,
33173334
)
3335+
3336+
3337+
def validate_transaction(
3338+
landing_page_body: Dict[str, Any],
3339+
collection: str,
3340+
errors: Errors,
3341+
warnings: Warnings,
3342+
r_session: Session,
3343+
context: Context,
3344+
transaction_collection: Optional[str],
3345+
) -> None:
3346+
if not transaction_collection:
3347+
errors += f"[{context}] : cannot validate Transaction Extension because -- transaction-collection is not set"
3348+
return
3349+
3350+
# todo: spec should advertise this rather than it just being known
3351+
3352+
collections_url = [
3353+
x.get("href") for x in links_by_rel(landing_page_body.get("links"), "data")
3354+
][0]
3355+
3356+
create_url = f"{collections_url}/{transaction_collection}/items"
3357+
3358+
item = {
3359+
"type": "Feature",
3360+
"stac_version": "1.0.0",
3361+
"id": "S2A_47XNF_20230423_0_L2A",
3362+
"properties": {
3363+
"eo:cloud_cover": 0.142999,
3364+
"datetime": "2023-04-23T06:47:03.048000Z",
3365+
"remove_me": "x",
3366+
},
3367+
"geometry": {
3368+
"type": "Polygon",
3369+
"coordinates": [
3370+
[
3371+
[98.99921502683155, 77.47731704519707],
3372+
[103.52623455002798, 77.4393697038252],
3373+
[103.28971588368059, 76.73571563595313],
3374+
[102.04264523308017, 76.47502013800678],
3375+
[98.99927124149437, 76.4933939950606],
3376+
[98.99921502683155, 77.47731704519707],
3377+
]
3378+
],
3379+
},
3380+
"assets": {
3381+
"aot": {
3382+
"href": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/47/X/NF/2023/4/S2A_47XNF_20230423_0_L2A/AOT.tif",
3383+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
3384+
"title": "Aerosol optical thickness (AOT)",
3385+
"roles": ["data", "reflectance"],
3386+
},
3387+
},
3388+
"bbox": [
3389+
98.99921502683155,
3390+
76.47502013800678,
3391+
103.52623455002798,
3392+
77.47731704519707,
3393+
],
3394+
"stac_extensions": [
3395+
"https://stac-extensions.github.io/eo/v1.0.0/schema.json",
3396+
],
3397+
"collection": transaction_collection,
3398+
}
3399+
3400+
item_url = f"{create_url}/{item['id']}"
3401+
3402+
# DELETE the item if it exists
3403+
retrieve(
3404+
Method.DELETE,
3405+
item_url,
3406+
status_code=204,
3407+
content_type="undefined",
3408+
errors=errors,
3409+
context=context,
3410+
r_session=r_session,
3411+
)
3412+
3413+
# POST create
3414+
# todo: test ItemCollection creation
3415+
retrieve(
3416+
Method.POST,
3417+
create_url,
3418+
body=item,
3419+
status_code=201,
3420+
errors=errors,
3421+
context=context,
3422+
r_session=r_session,
3423+
)
3424+
3425+
time.sleep(2)
3426+
3427+
retrieve(
3428+
Method.GET,
3429+
item_url,
3430+
errors=errors,
3431+
context=context,
3432+
r_session=r_session,
3433+
content_type=geojson_mt,
3434+
)
3435+
3436+
# PUT - change and add a field
3437+
item_put = copy.deepcopy(item)
3438+
item_put["properties"]["eo:cloud_cover"] = "3.14"
3439+
item_put["properties"]["foo"] = "bar"
3440+
item_put["properties"].pop("remove_me")
3441+
3442+
retrieve(
3443+
Method.PUT,
3444+
item_url,
3445+
body=item_put,
3446+
errors=errors,
3447+
context=context,
3448+
r_session=r_session,
3449+
status_code=204,
3450+
content_type="undefined",
3451+
)
3452+
3453+
time.sleep(2)
3454+
3455+
_, body, _ = retrieve(
3456+
Method.GET,
3457+
item_url,
3458+
errors=errors,
3459+
context=context,
3460+
r_session=r_session,
3461+
content_type=geojson_mt,
3462+
)
3463+
3464+
if body.get("properties", {}).get("datetime") != item["properties"]["datetime"]:
3465+
errors += f"[{context}] : PUT - datetime value did not match"
3466+
3467+
if body["properties"]["eo:cloud_cover"] != item_put["properties"]["eo:cloud_cover"]:
3468+
errors += f"[{context}] : PUT - eo:cloud_cover value did not match"
3469+
3470+
if body["properties"]["foo"] != item_put["properties"]["foo"]:
3471+
errors += f"[{context}] : PUT - property 'foo' was not added"
3472+
3473+
if body["properties"].get("remove_me"):
3474+
errors += f"[{context}] : PUT - field 'remove_me' was not removed"
3475+
3476+
# PATCH - add one field, modify another field
3477+
item_patch = {"properties": {"eo:cloud_cover": "12.4", "a_patch_field": "bar"}}
3478+
3479+
retrieve(
3480+
Method.PATCH,
3481+
item_url,
3482+
body=item_patch,
3483+
errors=errors,
3484+
context=context,
3485+
r_session=r_session,
3486+
status_code=204,
3487+
content_type="undefined",
3488+
)
3489+
3490+
time.sleep(2)
3491+
3492+
_, body, _ = retrieve(
3493+
Method.GET,
3494+
item_url,
3495+
errors=errors,
3496+
context=context,
3497+
r_session=r_session,
3498+
content_type=geojson_mt,
3499+
)
3500+
3501+
if body["properties"]["datetime"] != item["properties"]["datetime"]:
3502+
errors += f"[{context}] : PUT - datetime value did not match"
3503+
3504+
if (
3505+
body["properties"]["eo:cloud_cover"]
3506+
!= item_patch["properties"]["eo:cloud_cover"]
3507+
):
3508+
errors += f"[{context}] : PUT - eo:cloud_cover value did not match"
3509+
3510+
if body["properties"]["a_patch_field"] != item_patch["properties"]["a_patch_field"]:
3511+
errors += f"[{context}] : PUT - property 'foo' was not added"
3512+
3513+
# DELETE
3514+
3515+
retrieve(
3516+
Method.DELETE,
3517+
item_url,
3518+
status_code=204,
3519+
errors=errors,
3520+
context=context,
3521+
r_session=r_session,
3522+
content_type="undefined",
3523+
)
3524+
3525+
time.sleep(2)
3526+
3527+
retrieve(
3528+
Method.GET,
3529+
item_url,
3530+
status_code=404,
3531+
errors=errors,
3532+
context=context,
3533+
r_session=r_session,
3534+
)

0 commit comments

Comments
 (0)