eoapi-cdk 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.devcontainer/devcontainer.json +4 -0
- package/.github/workflows/build.yaml +73 -0
- package/.github/workflows/conventional-pr.yaml +26 -0
- package/.github/workflows/distribute.yaml +45 -0
- package/.github/workflows/docs.yaml +26 -0
- package/.github/workflows/test.yaml +13 -0
- package/.github/workflows/tox.yaml +24 -0
- package/.jsii +5058 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +195 -0
- package/README.md +50 -0
- package/diagrams/bastion_diagram.excalidraw +1416 -0
- package/diagrams/bastion_diagram.png +0 -0
- package/diagrams/ingestor_diagram.excalidraw +2274 -0
- package/diagrams/ingestor_diagram.png +0 -0
- package/lib/bastion-host/index.d.ts +117 -0
- package/lib/bastion-host/index.js +162 -0
- package/lib/bootstrapper/index.d.ts +57 -0
- package/lib/bootstrapper/index.js +73 -0
- package/lib/bootstrapper/runtime/Dockerfile +18 -0
- package/lib/bootstrapper/runtime/handler.py +235 -0
- package/lib/database/index.d.ts +60 -0
- package/lib/database/index.js +84 -0
- package/lib/database/instance-memory.json +525 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +19 -0
- package/lib/ingestor-api/index.d.ts +54 -0
- package/lib/ingestor-api/index.js +147 -0
- package/lib/ingestor-api/runtime/dev_requirements.txt +2 -0
- package/lib/ingestor-api/runtime/requirements.txt +12 -0
- package/lib/ingestor-api/runtime/src/__init__.py +0 -0
- package/lib/ingestor-api/runtime/src/collection.py +36 -0
- package/lib/ingestor-api/runtime/src/config.py +46 -0
- package/lib/ingestor-api/runtime/src/dependencies.py +94 -0
- package/lib/ingestor-api/runtime/src/handler.py +9 -0
- package/lib/ingestor-api/runtime/src/ingestor.py +82 -0
- package/lib/ingestor-api/runtime/src/loader.py +21 -0
- package/lib/ingestor-api/runtime/src/main.py +125 -0
- package/lib/ingestor-api/runtime/src/schemas.py +148 -0
- package/lib/ingestor-api/runtime/src/services.py +44 -0
- package/lib/ingestor-api/runtime/src/utils.py +52 -0
- package/lib/ingestor-api/runtime/src/validators.py +72 -0
- package/lib/ingestor-api/runtime/tests/__init__.py +0 -0
- package/lib/ingestor-api/runtime/tests/conftest.py +271 -0
- package/lib/ingestor-api/runtime/tests/test_collection.py +35 -0
- package/lib/ingestor-api/runtime/tests/test_collection_endpoint.py +41 -0
- package/lib/ingestor-api/runtime/tests/test_ingestor.py +60 -0
- package/lib/ingestor-api/runtime/tests/test_registration.py +198 -0
- package/lib/ingestor-api/runtime/tests/test_utils.py +35 -0
- package/lib/stac-api/index.d.ts +50 -0
- package/lib/stac-api/index.js +60 -0
- package/lib/stac-api/runtime/requirements.txt +8 -0
- package/lib/stac-api/runtime/src/__init__.py +0 -0
- package/lib/stac-api/runtime/src/app.py +58 -0
- package/lib/stac-api/runtime/src/config.py +96 -0
- package/lib/stac-api/runtime/src/handler.py +9 -0
- package/lib/titiler-pgstac-api/index.d.ts +33 -0
- package/lib/titiler-pgstac-api/index.js +67 -0
- package/lib/titiler-pgstac-api/runtime/Dockerfile +20 -0
- package/lib/titiler-pgstac-api/runtime/dev_requirements.txt +1 -0
- package/lib/titiler-pgstac-api/runtime/requirements.txt +3 -0
- package/lib/titiler-pgstac-api/runtime/src/__init__.py +3 -0
- package/lib/titiler-pgstac-api/runtime/src/handler.py +23 -0
- package/lib/titiler-pgstac-api/runtime/src/utils.py +26 -0
- package/package.json +81 -0
- package/tox.ini +52 -0
- package/tsconfig.tsbuildinfo +18116 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from fastapi.encoders import jsonable_encoder
|
|
10
|
+
from fastapi.exceptions import RequestValidationError
|
|
11
|
+
from pydantic import (
|
|
12
|
+
BaseModel,
|
|
13
|
+
Json,
|
|
14
|
+
PositiveInt,
|
|
15
|
+
dataclasses,
|
|
16
|
+
error_wrappers,
|
|
17
|
+
validator,
|
|
18
|
+
)
|
|
19
|
+
from stac_pydantic import Collection, Item, shared
|
|
20
|
+
|
|
21
|
+
from . import validators
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from . import services
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AccessibleAsset(shared.Asset):
|
|
28
|
+
@validator("href")
|
|
29
|
+
def is_accessible(cls, href):
|
|
30
|
+
url = urlparse(href)
|
|
31
|
+
|
|
32
|
+
if url.scheme in ["https", "http"]:
|
|
33
|
+
validators.url_is_accessible(href=href)
|
|
34
|
+
elif url.scheme in ["s3"]:
|
|
35
|
+
validators.s3_object_is_accessible(
|
|
36
|
+
bucket=url.hostname, key=url.path.lstrip("/")
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
ValueError(f"Unsupported scheme: {url.scheme}")
|
|
40
|
+
|
|
41
|
+
return href
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AccessibleItem(Item):
|
|
45
|
+
assets: Dict[str, AccessibleAsset]
|
|
46
|
+
|
|
47
|
+
@validator("collection")
|
|
48
|
+
def exists(cls, collection):
|
|
49
|
+
validators.collection_exists(collection_id=collection)
|
|
50
|
+
return collection
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StacCollection(Collection):
|
|
54
|
+
id: str
|
|
55
|
+
item_assets: Dict
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Status(str, enum.Enum):
|
|
59
|
+
started = "started"
|
|
60
|
+
queued = "queued"
|
|
61
|
+
failed = "failed"
|
|
62
|
+
succeeded = "succeeded"
|
|
63
|
+
cancelled = "cancelled"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Ingestion(BaseModel):
|
|
67
|
+
id: str
|
|
68
|
+
status: Status
|
|
69
|
+
message: Optional[str]
|
|
70
|
+
created_by: str
|
|
71
|
+
created_at: datetime = None
|
|
72
|
+
updated_at: datetime = None
|
|
73
|
+
|
|
74
|
+
item: Union[Item, Json[Item]]
|
|
75
|
+
|
|
76
|
+
@validator("created_at", pre=True, always=True, allow_reuse=True)
|
|
77
|
+
@validator("updated_at", pre=True, always=True, allow_reuse=True)
|
|
78
|
+
def set_ts_now(cls, v):
|
|
79
|
+
return v or datetime.now()
|
|
80
|
+
|
|
81
|
+
def enqueue(self, db: "services.Database"):
|
|
82
|
+
self.status = Status.queued
|
|
83
|
+
return self.save(db)
|
|
84
|
+
|
|
85
|
+
def cancel(self, db: "services.Database"):
|
|
86
|
+
self.status = Status.cancelled
|
|
87
|
+
return self.save(db)
|
|
88
|
+
|
|
89
|
+
def save(self, db: "services.Database"):
|
|
90
|
+
self.updated_at = datetime.now()
|
|
91
|
+
db.write(self)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def dynamodb_dict(self):
|
|
95
|
+
"""DynamoDB-friendly serialization"""
|
|
96
|
+
# convert to dictionary
|
|
97
|
+
output = self.dict(exclude={"item"})
|
|
98
|
+
|
|
99
|
+
# add STAC item as string
|
|
100
|
+
output["item"] = self.item.json()
|
|
101
|
+
|
|
102
|
+
# make JSON-friendly (will be able to do with Pydantic V2, https://github.com/pydantic/pydantic/issues/1409#issuecomment-1423995424)
|
|
103
|
+
return jsonable_encoder(output)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclasses.dataclass
|
|
107
|
+
class ListIngestionRequest:
|
|
108
|
+
status: Status = Status.queued
|
|
109
|
+
limit: PositiveInt = None
|
|
110
|
+
next: Optional[str] = None
|
|
111
|
+
|
|
112
|
+
def __post_init_post_parse__(self) -> None:
|
|
113
|
+
# https://github.com/tiangolo/fastapi/issues/1474#issuecomment-1049987786
|
|
114
|
+
if self.next is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
self.next = json.loads(base64.b64decode(self.next))
|
|
119
|
+
except (UnicodeDecodeError, binascii.Error):
|
|
120
|
+
raise RequestValidationError(
|
|
121
|
+
[
|
|
122
|
+
error_wrappers.ErrorWrapper(
|
|
123
|
+
ValueError(
|
|
124
|
+
"Unable to decode next token. Should be base64 encoded JSON"
|
|
125
|
+
),
|
|
126
|
+
"query.next",
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ListIngestionResponse(BaseModel):
|
|
133
|
+
items: List[Ingestion]
|
|
134
|
+
next: Optional[str]
|
|
135
|
+
|
|
136
|
+
@validator("next", pre=True)
|
|
137
|
+
def b64_encode_next(cls, next):
|
|
138
|
+
"""
|
|
139
|
+
Base64 encode next parameter for easier transportability
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(next, dict):
|
|
142
|
+
return base64.b64encode(json.dumps(next).encode())
|
|
143
|
+
return next
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class UpdateIngestionRequest(BaseModel):
|
|
147
|
+
status: Status = None
|
|
148
|
+
message: str = None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, List
|
|
2
|
+
|
|
3
|
+
from boto3.dynamodb import conditions
|
|
4
|
+
from pydantic import parse_obj_as
|
|
5
|
+
|
|
6
|
+
from . import schemas
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from mypy_boto3_dynamodb.service_resource import Table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Database:
|
|
13
|
+
def __init__(self, table: "Table"):
|
|
14
|
+
self.table = table
|
|
15
|
+
|
|
16
|
+
def write(self, ingestion: schemas.Ingestion):
|
|
17
|
+
self.table.put_item(Item=ingestion.dynamodb_dict())
|
|
18
|
+
|
|
19
|
+
def fetch_one(self, username: str, ingestion_id: str):
|
|
20
|
+
response = self.table.get_item(
|
|
21
|
+
Key={"created_by": username, "id": ingestion_id},
|
|
22
|
+
)
|
|
23
|
+
try:
|
|
24
|
+
return schemas.Ingestion.parse_obj(response["Item"])
|
|
25
|
+
except KeyError:
|
|
26
|
+
raise NotInDb("Record not found")
|
|
27
|
+
|
|
28
|
+
def fetch_many(
|
|
29
|
+
self, status: str, next: dict = None, limit: int = None
|
|
30
|
+
) -> schemas.ListIngestionResponse:
|
|
31
|
+
response = self.table.query(
|
|
32
|
+
IndexName="status",
|
|
33
|
+
KeyConditionExpression=conditions.Key("status").eq(status),
|
|
34
|
+
**{"Limit": limit} if limit else {},
|
|
35
|
+
**{"ExclusiveStartKey": next} if next else {},
|
|
36
|
+
)
|
|
37
|
+
return {
|
|
38
|
+
"items": parse_obj_as(List[schemas.Ingestion], response["Items"]),
|
|
39
|
+
"next": response.get("LastEvaluatedKey"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotInDb(Exception):
|
|
44
|
+
...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Sequence
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import pydantic
|
|
5
|
+
from pypgstac.db import PgstacDB
|
|
6
|
+
from pypgstac.load import Methods
|
|
7
|
+
from fastapi.encoders import jsonable_encoder
|
|
8
|
+
|
|
9
|
+
from .loader import Loader
|
|
10
|
+
from .schemas import Ingestion
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DbCreds(pydantic.BaseModel):
|
|
14
|
+
username: str
|
|
15
|
+
password: str
|
|
16
|
+
host: str
|
|
17
|
+
port: int
|
|
18
|
+
dbname: str
|
|
19
|
+
engine: str
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def dsn_string(self) -> str:
|
|
23
|
+
return f"{self.engine}://{self.username}:{self.password}@{self.host}:{self.port}/{self.dbname}" # noqa
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_db_credentials(secret_arn: str) -> DbCreds:
|
|
27
|
+
"""
|
|
28
|
+
Load pgSTAC database credentials from AWS Secrets Manager.
|
|
29
|
+
"""
|
|
30
|
+
print("Fetching DB credentials...")
|
|
31
|
+
session = boto3.session.Session(region_name=secret_arn.split(":")[3])
|
|
32
|
+
client = session.client(service_name="secretsmanager")
|
|
33
|
+
response = client.get_secret_value(SecretId=secret_arn)
|
|
34
|
+
return DbCreds.parse_raw(response["SecretString"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_items(creds: DbCreds, ingestions: Sequence[Ingestion]):
|
|
38
|
+
"""
|
|
39
|
+
Bulk insert STAC records into pgSTAC.
|
|
40
|
+
"""
|
|
41
|
+
with PgstacDB(dsn=creds.dsn_string, debug=True) as db:
|
|
42
|
+
loader = Loader(db=db)
|
|
43
|
+
|
|
44
|
+
# serialize to JSON-friendly dicts (won't be necessary in Pydantic v2, https://github.com/pydantic/pydantic/issues/1409#issuecomment-1423995424)
|
|
45
|
+
items = jsonable_encoder(i.item for i in ingestions)
|
|
46
|
+
loading_result = loader.load_items(
|
|
47
|
+
file=items,
|
|
48
|
+
# use insert_ignore to avoid overwritting existing items or upsert to replace
|
|
49
|
+
insert_mode=Methods.upsert,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return loading_result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@functools.cache
|
|
8
|
+
def get_s3_credentials():
|
|
9
|
+
from .config import settings
|
|
10
|
+
|
|
11
|
+
print("Fetching S3 Credentials...")
|
|
12
|
+
|
|
13
|
+
response = boto3.client("sts").assume_role(
|
|
14
|
+
RoleArn=settings.data_access_role,
|
|
15
|
+
RoleSessionName="stac-ingestor-data-validation",
|
|
16
|
+
)
|
|
17
|
+
return {
|
|
18
|
+
"aws_access_key_id": response["Credentials"]["AccessKeyId"],
|
|
19
|
+
"aws_secret_access_key": response["Credentials"]["SecretAccessKey"],
|
|
20
|
+
"aws_session_token": response["Credentials"]["SessionToken"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def s3_object_is_accessible(bucket: str, key: str):
|
|
25
|
+
"""
|
|
26
|
+
Ensure we can send HEAD requests to S3 objects.
|
|
27
|
+
"""
|
|
28
|
+
from .config import settings
|
|
29
|
+
|
|
30
|
+
client = boto3.client("s3", **get_s3_credentials())
|
|
31
|
+
try:
|
|
32
|
+
client.head_object(
|
|
33
|
+
Bucket=bucket,
|
|
34
|
+
Key=key,
|
|
35
|
+
**{"RequestPayer": "requester"} if settings.requester_pays else {},
|
|
36
|
+
)
|
|
37
|
+
except client.exceptions.ClientError as e:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Asset not accessible: {e.__dict__['response']['Error']['Message']}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def url_is_accessible(href: str):
|
|
44
|
+
"""
|
|
45
|
+
Ensure URLs are accessible via HEAD requests.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
requests.head(href).raise_for_status()
|
|
49
|
+
except requests.exceptions.HTTPError as e:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Asset not accessible: {e.response.status_code} {e.response.reason}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@functools.cache
|
|
56
|
+
def collection_exists(collection_id: str) -> bool:
|
|
57
|
+
"""
|
|
58
|
+
Ensure collection exists in STAC
|
|
59
|
+
"""
|
|
60
|
+
from .config import settings
|
|
61
|
+
|
|
62
|
+
url = "/".join(
|
|
63
|
+
f'{url.strip("/")}' for url in [settings.stac_url, "collections", collection_id]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (response := requests.get(url)).ok:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid collection '{collection_id}', received "
|
|
71
|
+
f"{response.status_code} response code from STAC API"
|
|
72
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import pytest
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
from moto import mock_dynamodb, mock_ssm
|
|
7
|
+
from stac_pydantic import Item
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def test_environ():
|
|
12
|
+
# Mocked AWS Credentials for moto (best practice recommendation from moto)
|
|
13
|
+
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
|
|
14
|
+
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
|
|
15
|
+
os.environ["AWS_SECURITY_TOKEN"] = "testing"
|
|
16
|
+
os.environ["AWS_SESSION_TOKEN"] = "testing"
|
|
17
|
+
|
|
18
|
+
# Config mocks
|
|
19
|
+
os.environ["DYNAMODB_TABLE"] = "test_table"
|
|
20
|
+
os.environ["JWKS_URL"] = "https://test-jwks.url"
|
|
21
|
+
os.environ["STAC_URL"] = "https://test-stac.url"
|
|
22
|
+
os.environ["DATA_ACCESS_ROLE"] = "arn:aws:iam::123456789012:role/test-role"
|
|
23
|
+
os.environ["DB_SECRET_ARN"] = "testing"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def mock_ssm_parameter_store():
|
|
28
|
+
with mock_ssm():
|
|
29
|
+
yield boto3.client("ssm")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def app(test_environ, mock_ssm_parameter_store):
|
|
34
|
+
from src.main import app
|
|
35
|
+
|
|
36
|
+
return app
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def api_client(app):
|
|
41
|
+
return TestClient(app)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def mock_table(app, test_environ):
|
|
46
|
+
from src import dependencies
|
|
47
|
+
from src.config import settings
|
|
48
|
+
|
|
49
|
+
with mock_dynamodb():
|
|
50
|
+
client = boto3.resource("dynamodb")
|
|
51
|
+
mock_table = client.create_table(
|
|
52
|
+
TableName=settings.dynamodb_table,
|
|
53
|
+
AttributeDefinitions=[
|
|
54
|
+
{"AttributeName": "created_by", "AttributeType": "S"},
|
|
55
|
+
{"AttributeName": "id", "AttributeType": "S"},
|
|
56
|
+
{"AttributeName": "status", "AttributeType": "S"},
|
|
57
|
+
{"AttributeName": "created_at", "AttributeType": "S"},
|
|
58
|
+
],
|
|
59
|
+
KeySchema=[
|
|
60
|
+
{"AttributeName": "created_by", "KeyType": "HASH"},
|
|
61
|
+
{"AttributeName": "id", "KeyType": "RANGE"},
|
|
62
|
+
],
|
|
63
|
+
BillingMode="PAY_PER_REQUEST",
|
|
64
|
+
GlobalSecondaryIndexes=[
|
|
65
|
+
{
|
|
66
|
+
"IndexName": "status",
|
|
67
|
+
"KeySchema": [
|
|
68
|
+
{"AttributeName": "status", "KeyType": "HASH"},
|
|
69
|
+
{"AttributeName": "created_at", "KeyType": "RANGE"},
|
|
70
|
+
],
|
|
71
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
app.dependency_overrides[dependencies.get_table] = lambda: mock_table
|
|
76
|
+
yield mock_table
|
|
77
|
+
app.dependency_overrides.pop(dependencies.get_table)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.fixture
|
|
81
|
+
def example_stac_item():
|
|
82
|
+
return {
|
|
83
|
+
"stac_version": "1.0.0",
|
|
84
|
+
"stac_extensions": [],
|
|
85
|
+
"type": "Feature",
|
|
86
|
+
"id": "20201211_223832_CS2",
|
|
87
|
+
"bbox": [
|
|
88
|
+
172.91173669923782,
|
|
89
|
+
1.3438851951615003,
|
|
90
|
+
172.95469614953714,
|
|
91
|
+
1.3690476620161975,
|
|
92
|
+
],
|
|
93
|
+
"geometry": {
|
|
94
|
+
"type": "Polygon",
|
|
95
|
+
"coordinates": [
|
|
96
|
+
[
|
|
97
|
+
[172.91173669923782, 1.3438851951615003],
|
|
98
|
+
[172.95469614953714, 1.3438851951615003],
|
|
99
|
+
[172.95469614953714, 1.3690476620161975],
|
|
100
|
+
[172.91173669923782, 1.3690476620161975],
|
|
101
|
+
[172.91173669923782, 1.3438851951615003],
|
|
102
|
+
]
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
"properties": {
|
|
106
|
+
"datetime": "2020-12-11T22:38:32.125000Z",
|
|
107
|
+
"eo:cloud_cover": 1,
|
|
108
|
+
},
|
|
109
|
+
"collection": "simple-collection",
|
|
110
|
+
"links": [
|
|
111
|
+
{
|
|
112
|
+
"rel": "collection",
|
|
113
|
+
"href": "./collection.json",
|
|
114
|
+
"type": "application/json",
|
|
115
|
+
"title": "Simple Example Collection",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"rel": "root",
|
|
119
|
+
"href": "./collection.json",
|
|
120
|
+
"type": "application/json",
|
|
121
|
+
"title": "Simple Example Collection",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"rel": "parent",
|
|
125
|
+
"href": "./collection.json",
|
|
126
|
+
"type": "application/json",
|
|
127
|
+
"title": "Simple Example Collection",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
"assets": {
|
|
131
|
+
"visual": {
|
|
132
|
+
"href": "https://TEST_API.com/open-cogs/stac-examples/20201211_223832_CS2.tif", # noqa
|
|
133
|
+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
|
|
134
|
+
"title": "3-Band Visual",
|
|
135
|
+
"roles": ["visual"],
|
|
136
|
+
},
|
|
137
|
+
"thumbnail": {
|
|
138
|
+
"href": "https://TEST_API.com/open-cogs/stac-examples/20201211_223832_CS2.jpg", # noqa
|
|
139
|
+
"title": "Thumbnail",
|
|
140
|
+
"type": "image/jpeg",
|
|
141
|
+
"roles": ["thumbnail"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.fixture
|
|
148
|
+
def example_stac_collection():
|
|
149
|
+
return {
|
|
150
|
+
"id": "simple-collection",
|
|
151
|
+
"type": "Collection",
|
|
152
|
+
"stac_extensions": [
|
|
153
|
+
"https://stac-extensions.github.io/eo/v1.0.0/schema.json",
|
|
154
|
+
"https://stac-extensions.github.io/projection/v1.0.0/schema.json",
|
|
155
|
+
"https://stac-extensions.github.io/view/v1.0.0/schema.json",
|
|
156
|
+
],
|
|
157
|
+
"item_assets": {
|
|
158
|
+
"data": {
|
|
159
|
+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
|
|
160
|
+
"roles": ["data"],
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"stac_version": "1.0.0",
|
|
164
|
+
"description": "A simple collection demonstrating core catalog fields with links to a couple of items",
|
|
165
|
+
"title": "Simple Example Collection",
|
|
166
|
+
"providers": [
|
|
167
|
+
{
|
|
168
|
+
"name": "Remote Data, Inc",
|
|
169
|
+
"description": "Producers of awesome spatiotemporal assets",
|
|
170
|
+
"roles": ["producer", "processor"],
|
|
171
|
+
"url": "http://remotedata.io",
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"extent": {
|
|
175
|
+
"spatial": {
|
|
176
|
+
"bbox": [
|
|
177
|
+
[
|
|
178
|
+
172.91173669923782,
|
|
179
|
+
1.3438851951615003,
|
|
180
|
+
172.95469614953714,
|
|
181
|
+
1.3690476620161975,
|
|
182
|
+
]
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
"temporal": {
|
|
186
|
+
"interval": [["2020-12-11T22:38:32.125Z", "2020-12-14T18:02:31.437Z"]]
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"license": "CC-BY-4.0",
|
|
190
|
+
"summaries": {
|
|
191
|
+
"platform": ["cool_sat1", "cool_sat2"],
|
|
192
|
+
"constellation": ["ion"],
|
|
193
|
+
"instruments": ["cool_sensor_v1", "cool_sensor_v2"],
|
|
194
|
+
"gsd": {"minimum": 0.512, "maximum": 0.66},
|
|
195
|
+
"eo:cloud_cover": {"minimum": 1.2, "maximum": 1.2},
|
|
196
|
+
"proj:epsg": {"minimum": 32659, "maximum": 32659},
|
|
197
|
+
"view:sun_elevation": {"minimum": 54.9, "maximum": 54.9},
|
|
198
|
+
"view:off_nadir": {"minimum": 3.8, "maximum": 3.8},
|
|
199
|
+
"view:sun_azimuth": {"minimum": 135.7, "maximum": 135.7},
|
|
200
|
+
},
|
|
201
|
+
"links": [
|
|
202
|
+
{
|
|
203
|
+
"rel": "root",
|
|
204
|
+
"href": "./collection.json",
|
|
205
|
+
"type": "application/json",
|
|
206
|
+
"title": "Simple Example Collection",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"rel": "item",
|
|
210
|
+
"href": "./simple-item.json",
|
|
211
|
+
"type": "application/geo+json",
|
|
212
|
+
"title": "Simple Item",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"rel": "item",
|
|
216
|
+
"href": "./core-item.json",
|
|
217
|
+
"type": "application/geo+json",
|
|
218
|
+
"title": "Core Item",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"rel": "item",
|
|
222
|
+
"href": "./extended-item.json",
|
|
223
|
+
"type": "application/geo+json",
|
|
224
|
+
"title": "Extended Item",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"rel": "self",
|
|
228
|
+
"href": "https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0/examples/collection.json",
|
|
229
|
+
"type": "application/json",
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pytest.fixture
|
|
236
|
+
def client(app):
|
|
237
|
+
"""
|
|
238
|
+
Return an API Client
|
|
239
|
+
"""
|
|
240
|
+
app.dependency_overrides = {}
|
|
241
|
+
return TestClient(app)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.fixture
|
|
245
|
+
def client_authenticated(app):
|
|
246
|
+
"""
|
|
247
|
+
Returns an API client which skips the authentication
|
|
248
|
+
"""
|
|
249
|
+
from src.dependencies import get_username
|
|
250
|
+
|
|
251
|
+
app.dependency_overrides[get_username] = lambda: "test_user"
|
|
252
|
+
return TestClient(app)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.fixture
|
|
256
|
+
def stac_collection(example_stac_collection):
|
|
257
|
+
from src import schemas
|
|
258
|
+
|
|
259
|
+
return schemas.StacCollection(**example_stac_collection)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.fixture
|
|
263
|
+
def example_ingestion(example_stac_item):
|
|
264
|
+
from src import schemas
|
|
265
|
+
|
|
266
|
+
return schemas.Ingestion(
|
|
267
|
+
id=example_stac_item["id"],
|
|
268
|
+
created_by="test-user",
|
|
269
|
+
status=schemas.Status.queued,
|
|
270
|
+
item=Item.parse_obj(example_stac_item),
|
|
271
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from unittest.mock import Mock, patch
|
|
2
|
+
import pytest
|
|
3
|
+
from pypgstac.load import Methods
|
|
4
|
+
from src.utils import DbCreds
|
|
5
|
+
import src.collection as collection
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture()
|
|
10
|
+
def loader():
|
|
11
|
+
with patch("src.collection.Loader", autospec=True) as m:
|
|
12
|
+
yield m
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture()
|
|
16
|
+
def pgstacdb():
|
|
17
|
+
with patch("src.collection.PgstacDB", autospec=True) as m:
|
|
18
|
+
m.return_value.__enter__.return_value = Mock()
|
|
19
|
+
yield m
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_load_collections(stac_collection, loader, pgstacdb):
|
|
23
|
+
with patch(
|
|
24
|
+
"src.collection.get_db_credentials",
|
|
25
|
+
return_value=DbCreds(
|
|
26
|
+
username="", password="", host="", port=1, dbname="", engine=""
|
|
27
|
+
),
|
|
28
|
+
):
|
|
29
|
+
os.environ["DB_SECRET_ARN"] = ""
|
|
30
|
+
collection.ingest(stac_collection)
|
|
31
|
+
|
|
32
|
+
loader.return_value.load_collections.assert_called_once_with(
|
|
33
|
+
file=[stac_collection.to_dict()],
|
|
34
|
+
insert_mode=Methods.upsert,
|
|
35
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
publish_collections_endpoint = "/collections"
|
|
4
|
+
delete_collection_endpoint = "/collections/{collection_id}"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@patch("src.collection.ingest")
|
|
8
|
+
def test_auth_publish_collection(
|
|
9
|
+
ingest, stac_collection, example_stac_collection, client_authenticated
|
|
10
|
+
):
|
|
11
|
+
token = "token"
|
|
12
|
+
response = client_authenticated.post(
|
|
13
|
+
publish_collections_endpoint,
|
|
14
|
+
headers={"Authorization": f"bearer {token}"},
|
|
15
|
+
json=example_stac_collection,
|
|
16
|
+
)
|
|
17
|
+
ingest.assert_called_once_with(stac_collection)
|
|
18
|
+
assert response.status_code == 201
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_unauth_publish_collection(client, example_stac_collection):
|
|
22
|
+
response = client.post(publish_collections_endpoint, json=example_stac_collection)
|
|
23
|
+
assert response.status_code == 403
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@patch("src.collection.delete")
|
|
27
|
+
def test_auth_delete_collection(delete, example_stac_collection, client_authenticated):
|
|
28
|
+
token = "token"
|
|
29
|
+
response = client_authenticated.delete(
|
|
30
|
+
delete_collection_endpoint.format(collection_id=example_stac_collection["id"]),
|
|
31
|
+
headers={"Authorization": f"bearer {token}"},
|
|
32
|
+
)
|
|
33
|
+
delete.assert_called_once_with(collection_id=example_stac_collection["id"])
|
|
34
|
+
assert response.status_code == 200
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_unauth_delete_collection(client, example_stac_collection):
|
|
38
|
+
response = client.delete(
|
|
39
|
+
delete_collection_endpoint.format(collection_id=example_stac_collection["id"]),
|
|
40
|
+
)
|
|
41
|
+
assert response.status_code == 403
|