|
1 | 1 | """Validations module."""
|
| 2 | +import copy |
2 | 3 | import itertools
|
3 | 4 | import json
|
4 | 5 | import logging
|
5 | 6 | import re
|
| 7 | +import time |
6 | 8 | from dataclasses import dataclass
|
7 | 9 | from enum import Enum
|
8 | 10 | from typing import Any
|
|
86 | 88 | class Method(Enum):
|
87 | 89 | GET = "GET"
|
88 | 90 | POST = "POST"
|
| 91 | + PUT = "PUT" |
| 92 | + PATCH = "PATCH" |
| 93 | + DELETE = "DELETE" |
89 | 94 |
|
90 | 95 | def __str__(self) -> str:
|
91 | 96 | return self.value
|
@@ -392,11 +397,14 @@ def retrieve(
|
392 | 397 | if not content_type:
|
393 | 398 | if url.endswith("/search") or url.endswith("/items"):
|
394 | 399 | 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}'" |
396 | 401 | 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" |
398 | 406 | 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}'" |
400 | 408 |
|
401 | 409 | if has_json_content_type(resp.headers) or has_geojson_content_type(
|
402 | 410 | resp.headers
|
@@ -524,6 +532,7 @@ def validate_api(
|
524 | 532 | fields_nested_property: Optional[str],
|
525 | 533 | validate_pagination: bool,
|
526 | 534 | query_config: QueryConfig,
|
| 535 | + transaction_collection: Optional[str], |
527 | 536 | ) -> Tuple[Warnings, Errors]:
|
528 | 537 | warnings = Warnings()
|
529 | 538 | errors = Errors()
|
@@ -593,7 +602,15 @@ def validate_api(
|
593 | 602 | logger.info(
|
594 | 603 | "STAC API - Features - Transaction extension conformance class found."
|
595 | 604 | )
|
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 | + ) |
597 | 614 |
|
598 | 615 | if "features#fields" in ccs_to_validate:
|
599 | 616 | logger.info("STAC API - Features - Fields extension conformance class found.")
|
@@ -3315,3 +3332,203 @@ def validate_item_search_collections(
|
3315 | 3332 | errors,
|
3316 | 3333 | r_session,
|
3317 | 3334 | )
|
| 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