Skip to content

Commit d36dfcc

Browse files
authored
Merge pull request #52 from Element84/orders
Add `/orders` endpoint
2 parents ea2e923 + f8f7ca9 commit d36dfcc

5 files changed

Lines changed: 106 additions & 14 deletions

File tree

demo/api/api_types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
ProductParameters = Dict[str, Union[Range, List[Any], Dict[str, Any]]]
3434

3535

36+
37+
class Order(BaseModel):
38+
id: str
39+
40+
3641
class Product(BaseModel):
3742
"""https://github.com/Element84/sat-tasking-sprint/tree/main/product-spec"""
3843

demo/api/backends/base.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from typing import Protocol, Optional
2-
3-
from api.api_types import OpportunityCollection, Product, Search
42
import os
53

4+
from api.api_types import OpportunityCollection, Product, Search, Order
5+
66

77
# backend protocol class
88
class Backend(Protocol):
@@ -21,6 +21,13 @@ async def find_products(
2121
) -> list[Product]:
2222
return NotImplemented
2323

24+
async def place_order(
25+
self,
26+
search: Search,
27+
token: str,
28+
) -> Order:
29+
return NotImplemented
30+
2431

2532
def get_token(backend: str) -> Optional[str]:
2633
token_name = f"{backend.upper()}_TOKEN"

demo/api/backends/sentinel_backend.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
import pystac
88
from api.api_types import (Opportunity, OpportunityCollection,
99
OpportunityProperties, Product, ProductConstraints,
10-
Search, Provider)
11-
from pystac import Collection
10+
Search, Order, Provider)
11+
from pystac import Collection, ItemCollection
12+
1213
from pystac_client.client import Client
1314

1415
DEFAULT_MAX_ITEMS = 10
@@ -89,11 +90,7 @@ class HistoricalBackend:
8990
def __init__(self) -> None:
9091
self.catalog = Client.open('https://earth-search.aws.element84.com/v1') # type: ignore
9192

92-
async def find_opportunities(
93-
self,
94-
search: Search,
95-
token: str,
96-
) -> OpportunityCollection:
93+
def _search(self, search) -> ItemCollection:
9794
max_items = min(search.limit, MAX_MAX_ITEMS)
9895

9996
args: dict[str, Any] = {
@@ -114,19 +111,26 @@ async def find_opportunities(
114111
raise Exception('A datetime range must be specified')
115112

116113
search_obj = self.catalog.search(**args)
117-
item_coll = search_obj.item_collection()
114+
return search_obj.item_collection()
118115

116+
async def find_opportunities(
117+
self,
118+
search: Search,
119+
token: str,
120+
) -> OpportunityCollection:
119121
# Convert the STAC items from earth search into opportunities
122+
item_collection = self._search(search)
120123
opportunities: list[Opportunity] = [
121124
stac_item_to_opportunity(item, product_id=search.product_id)
122-
for item in item_coll.items
125+
for item in item_collection.items
123126
]
124127
opportunity_collection = OpportunityCollection(
125128
features=opportunities
126129
)
127130

128131
return opportunity_collection
129132

133+
130134
async def find_products(self, token: str) -> list[Product]:
131135
def safe_get_coll(product_id: str) -> Collection:
132136
coll = self.catalog.get_collection(product_id)
@@ -138,3 +142,17 @@ def safe_get_coll(product_id: str) -> Collection:
138142
stac_collection_to_product(safe_get_coll(product_id))
139143
for product_id in PRODUCT_IDS
140144
]
145+
146+
async def place_order(
147+
self,
148+
search: Search,
149+
token: str,
150+
) -> Order:
151+
"""Get the first item off the search output and return that ID"""
152+
item_collection = self._search(search)
153+
154+
if len(item_collection.items) == 0:
155+
raise ValueError(f"Unable to place an order for this product: '{search.product_id}'")
156+
157+
best_guess = item_collection.items[0]
158+
return Order(id=best_guess.id)

demo/api/main.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from api.backends.base import Backend, get_token
99
from api.backends import BACKENDS
1010

11-
from api.api_types import Search, OpportunityCollection, Product
11+
from api.api_types import Search, OpportunityCollection, Product, Order
1212

1313
app = FastAPI(title="Tasking API")
1414

@@ -19,6 +19,7 @@
1919
async def redirect_home():
2020
return RedirectResponse("/docs")
2121

22+
2223
@app.get("/products", response_model=list[Product])
2324
async def get_products(
2425
request: Request,
@@ -117,3 +118,37 @@ async def post_opportunities(
117118
)
118119

119120
return opportunity_collection
121+
122+
123+
@app.post("/orders", response_model=Order)
124+
async def post_order(
125+
request: Request,
126+
search: Search,
127+
):
128+
# get the right token and backend from the header
129+
backend = request.headers.get("backend", "historical")
130+
131+
token = "this-is-not-a-real-token"
132+
if authorization := request.headers.get("authorization"):
133+
token = authorization.replace("Bearer ", "")
134+
135+
if backend in BACKENDS:
136+
impl: Backend = BACKENDS[backend]
137+
else:
138+
raise HTTPException(
139+
status_code=404,
140+
detail=f"Backend '{backend}' not in options: {list(BACKENDS.keys())}"
141+
)
142+
143+
try:
144+
order = await impl.place_order(
145+
search,
146+
token=token,
147+
)
148+
except Exception as e:
149+
raise HTTPException(
150+
status_code=500,
151+
detail=str(e),
152+
)
153+
154+
return order

demo/api/tests/test_api.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"datetime": "2025-01-01T00:00:00Z/2025-01-02T00:00:00Z",
2222
"geometry": {
2323
"type": "Point",
24-
"coordinates": [39.95, 75.16]
24+
"coordinates": [-75.16, 39.95]
2525
}
2626
}
2727

@@ -94,7 +94,7 @@ def test_post_to_opportunities_with_opportunities_body_and_header_authenticated(
9494
"datetime": f"{start_time.isoformat()}/{end_time.isoformat()}",
9595
"geometry": {
9696
"type": "Point",
97-
"coordinates": [39.95, 75.16]
97+
"coordinates": [-75.16, 39.95]
9898
}
9999
}
100100

@@ -118,3 +118,30 @@ def test_post_to_opportunities_with_bad_backend_raises():
118118
assert response.json()["detail"] == f"Backend 'foo' not in options: {list(BACKENDS.keys())}"
119119

120120

121+
def test_post_to_orders_raises_if_not_possible():
122+
product_id = "landsat-c2-l2"
123+
response = client.post(
124+
"/orders",
125+
headers={"Backend": "historical"},
126+
json={"product_id": product_id, **VALID_SEARCH_BODY},
127+
)
128+
assert response.status_code == 500
129+
assert response.json()["detail"] == f"Unable to place an order for this product: '{product_id}'"
130+
131+
132+
def test_post_to_orders():
133+
json_body = {
134+
"datetime": "2025-01-01T00:00:00Z/2025-05-02T00:00:00Z",
135+
"geometry": {
136+
"type": "Point",
137+
"coordinates": [-75.16, 39.95]
138+
},
139+
"product_id": "landsat-c2-l2",
140+
}
141+
response = client.post(
142+
"/orders",
143+
headers={"Backend": "historical"},
144+
json=json_body,
145+
)
146+
assert response.status_code == 200
147+
assert response.json()["id"] == "LC09_L2SP_014032_20220501_02_T1"

0 commit comments

Comments
 (0)