Skip to content

Commit e541439

Browse files
committed
new structure
1 parent 4ee5b3b commit e541439

File tree

11 files changed

+653
-5
lines changed

11 files changed

+653
-5
lines changed

pyproject.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
[tool.poetry]
2-
name = "stapi-fastapi"
2+
name = "stapi-fastapi-planet"
33
# placeholder version filled by poetry-dynamic-versioning
44
version = "0.0.0"
5-
description = "Sensor Tasking API (STAPI) with FastAPI"
6-
authors = ["Christian Wygoda <[email protected]>"]
5+
description = "Sensor Tasking API (STAPI) with FastAPI - Planet proxy"
6+
authors = ["Nicolas Neubauer <[email protected]>", "Christian Wygoda <[email protected]>"]
77
license = "MIT"
88
readme = "README.md"
99
packages = [{include = "stapi_fastapi", from="src"}]

src/planet/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# STAT FastAPI - Planet
2+
3+
This is an example implementation for `https://github.com/stat-utils/stat-fastapi` proxying to the Planet Tasking API.
4+
5+
6+
Start the server locally
7+
8+
```sh
9+
uvicorn planet.application:app --app-dir ./src/planet --reload
10+
```
11+
12+
GET all products
13+
```sh
14+
curl http://127.0.0.1:8000/products
15+
```
16+
17+
POST to opportunities
18+
```sh
19+
export BACKEND_TOKEN=...
20+
curl -d '{"geometry": {"type": "Point", "coordinates": [13.4, 52.5]}, "product_id": "PL-123456:Assured Tasking", "datetime": "2024-05-01T00:00:00Z/2024-05-12T00:00:00Z"}' -H "Content-Type: application/json; Authorization: $BACKEND_TOKEN" -X POST http://127.0.0.1:8000/opportunities
21+
```

src/planet/__init__.py

Whitespace-only changes.

src/planet/application.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os
2+
import sys
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from typing import Any
6+
7+
from fastapi import FastAPI
8+
9+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10+
11+
from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES
12+
from stapi_fastapi.routers.root_router import RootRouter
13+
14+
from backends import (
15+
mock_get_opportunity_search_record,
16+
mock_get_opportunity_search_records,
17+
get_order,
18+
mock_get_order_statuses,
19+
mock_get_orders,
20+
)
21+
from shared import (
22+
InMemoryOpportunityDB,
23+
InMemoryOrderDB,
24+
product_test_planet_sync_opportunity
25+
#product_test_spotlight_sync_async_opportunity,
26+
)
27+
28+
29+
@asynccontextmanager
30+
async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
31+
try:
32+
yield {
33+
"_orders_db": InMemoryOrderDB(),
34+
"_opportunities_db": InMemoryOpportunityDB(),
35+
}
36+
finally:
37+
pass
38+
39+
40+
root_router = RootRouter(
41+
get_orders=mock_get_orders,
42+
get_order=get_order,
43+
get_order_statuses=mock_get_order_statuses,
44+
get_opportunity_search_records=mock_get_opportunity_search_records,
45+
get_opportunity_search_record=mock_get_opportunity_search_record,
46+
conformances=[CORE, OPPORTUNITIES], # , ASYNC_OPPORTUNITIES
47+
)
48+
#root_router.add_product(product_test_spotlight_sync_async_opportunity)
49+
root_router.add_product(product_test_planet_sync_opportunity)
50+
app: FastAPI = FastAPI(lifespan=lifespan)
51+
app.include_router(root_router, prefix="")

src/planet/backends.py

+227
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
from datetime import datetime, timezone
2+
from uuid import uuid4
3+
import logging
4+
5+
logger = logging.getLogger(__name__)
6+
7+
8+
from fastapi import Request
9+
from returns.maybe import Maybe, Nothing, Some
10+
from returns.result import Failure, ResultE, Success
11+
12+
from stapi_fastapi.models.opportunity import (
13+
Opportunity,
14+
OpportunityCollection,
15+
OpportunityPayload,
16+
OpportunitySearchRecord,
17+
OpportunitySearchStatus,
18+
OpportunitySearchStatusCode,
19+
)
20+
from stapi_fastapi.models.order import (
21+
Order,
22+
OrderPayload,
23+
OrderProperties,
24+
OrderSearchParameters,
25+
OrderStatus,
26+
OrderStatusCode,
27+
)
28+
from stapi_fastapi.routers.product_router import ProductRouter
29+
30+
from client import Client
31+
from conversions import planet_order_to_stapi_order, stapi_opportunity_payload_to_planet_iw_search
32+
33+
34+
async def mock_get_orders(
35+
next: str | None, limit: int, request: Request
36+
) -> ResultE[tuple[list[Order], Maybe[str]]]:
37+
"""
38+
Return orders from backend. Handle pagination/limit if applicable
39+
"""
40+
try:
41+
start = 0
42+
limit = min(limit, 100)
43+
44+
order_ids = [*request.state._orders_db._orders.keys()]
45+
46+
if next:
47+
start = order_ids.index(next)
48+
end = start + limit
49+
ids = order_ids[start:end]
50+
orders = [request.state._orders_db.get_order(order_id) for order_id in ids]
51+
52+
if end > 0 and end < len(order_ids):
53+
return Success(
54+
(orders, Some(request.state._orders_db._orders[order_ids[end]].id))
55+
)
56+
return Success((orders, Nothing))
57+
except Exception as e:
58+
return Failure(e)
59+
60+
61+
async def get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]:
62+
"""
63+
Show details for order with `order_id`.
64+
"""
65+
try:
66+
return Success(
67+
Maybe.from_optional(planet_order_to_stapi_order(Client(request).get_order(order_id)))
68+
)
69+
except Exception as e:
70+
return Failure(e)
71+
72+
73+
async def mock_get_order_statuses(
74+
order_id: str, next: str | None, limit: int, request: Request
75+
) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]:
76+
try:
77+
start = 0
78+
limit = min(limit, 100)
79+
statuses = request.state._orders_db.get_order_statuses(order_id)
80+
if statuses is None:
81+
return Success(Nothing)
82+
83+
if next:
84+
start = int(next)
85+
end = start + limit
86+
stati = statuses[start:end]
87+
88+
if end > 0 and end < len(statuses):
89+
return Success(Some((stati, Some(str(end)))))
90+
return Success(Some((stati, Nothing)))
91+
except Exception as e:
92+
return Failure(e)
93+
94+
95+
async def mock_create_order(
96+
product_router: ProductRouter, payload: OrderPayload, request: Request
97+
) -> ResultE[Order]:
98+
"""
99+
Create a new order.
100+
"""
101+
try:
102+
status = OrderStatus(
103+
timestamp=datetime.now(timezone.utc),
104+
status_code=OrderStatusCode.received,
105+
)
106+
order = Order(
107+
id=str(uuid4()),
108+
geometry=payload.geometry,
109+
properties=OrderProperties(
110+
product_id=product_router.product.id,
111+
created=datetime.now(timezone.utc),
112+
status=status,
113+
search_parameters=OrderSearchParameters(
114+
geometry=payload.geometry,
115+
datetime=payload.datetime,
116+
filter=payload.filter,
117+
),
118+
order_parameters=payload.order_parameters.model_dump(),
119+
opportunity_properties={
120+
"datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z",
121+
"off_nadir": 10,
122+
},
123+
),
124+
links=[],
125+
)
126+
127+
request.state._orders_db.put_order(order)
128+
request.state._orders_db.put_order_status(order.id, status)
129+
return Success(order)
130+
except Exception as e:
131+
return Failure(e)
132+
133+
134+
# TODO why does this return a list of Opportunities and not an OpportunityCollection?
135+
async def search_opportunities(
136+
product_router: ProductRouter,
137+
search: OpportunityPayload,
138+
next: str | None,
139+
limit: int,
140+
request: Request,
141+
) -> ResultE[tuple[list[Opportunity], Maybe[str]]]:
142+
try:
143+
iw_request = stapi_opportunity_payload_to_planet_iw_search(product_router.product, search)
144+
imaging_windows = Client(request).get_imaging_windows(iw_request)
145+
146+
opportunities = [
147+
imaging_window_to_opportunity(iw, search)
148+
for iw
149+
in imaging_windows
150+
]
151+
#return OpportunityCollection(features=opportunities)
152+
return Success((opportunities, Nothing))
153+
except Exception as e:
154+
return Failure(e)
155+
156+
157+
async def mock_search_opportunities_async(
158+
product_router: ProductRouter,
159+
search: OpportunityPayload,
160+
request: Request,
161+
) -> ResultE[OpportunitySearchRecord]:
162+
try:
163+
received_status = OpportunitySearchStatus(
164+
timestamp=datetime.now(timezone.utc),
165+
status_code=OpportunitySearchStatusCode.received,
166+
)
167+
search_record = OpportunitySearchRecord(
168+
id=str(uuid4()),
169+
product_id=product_router.product.id,
170+
opportunity_request=search,
171+
status=received_status,
172+
links=[],
173+
)
174+
request.state._opportunities_db.put_search_record(search_record)
175+
return Success(search_record)
176+
except Exception as e:
177+
return Failure(e)
178+
179+
180+
async def mock_get_opportunity_collection(
181+
product_router: ProductRouter, opportunity_collection_id: str, request: Request
182+
) -> ResultE[Maybe[OpportunityCollection]]:
183+
try:
184+
return Success(
185+
Maybe.from_optional(
186+
request.state._opportunities_db.get_opportunity_collection(
187+
opportunity_collection_id
188+
)
189+
)
190+
)
191+
except Exception as e:
192+
return Failure(e)
193+
194+
195+
async def mock_get_opportunity_search_records(
196+
next: str | None,
197+
limit: int,
198+
request: Request,
199+
) -> ResultE[tuple[list[OpportunitySearchRecord], Maybe[str]]]:
200+
try:
201+
start = 0
202+
limit = min(limit, 100)
203+
search_records = request.state._opportunities_db.get_search_records()
204+
205+
if next:
206+
start = int(next)
207+
end = start + limit
208+
page = search_records[start:end]
209+
210+
if end > 0 and end < len(search_records):
211+
return Success((page, Some(str(end))))
212+
return Success((page, Nothing))
213+
except Exception as e:
214+
return Failure(e)
215+
216+
217+
async def mock_get_opportunity_search_record(
218+
search_record_id: str, request: Request
219+
) -> ResultE[Maybe[OpportunitySearchRecord]]:
220+
try:
221+
return Success(
222+
Maybe.from_optional(
223+
request.state._opportunities_db.get_search_record(search_record_id)
224+
)
225+
)
226+
except Exception as e:
227+
return Failure(e)

src/planet/client.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
import time
3+
4+
import requests
5+
from fastapi import Request
6+
7+
from settings import Settings
8+
9+
class Client:
10+
11+
def __init__(self, request: Request):
12+
13+
token = os.environ.get("BACKEND_TOKEN")
14+
if authorization := request.headers.get("authorization"):
15+
token = authorization.replace("Bearer ", "")
16+
17+
self.token = token
18+
self.headers = {
19+
'Content-Type': 'application/json',
20+
'Authorization': f'api-key {self.token}'
21+
}
22+
self.orders_url = f'{Settings().api_base_url}/orders/'
23+
self.iw_search_url = f'{Settings().api_base_url}/imaging-windows/search'
24+
25+
def get_order(self, order_id: str) -> dict:
26+
order_url = f'{self.orders_url}{order_id}'
27+
response = requests.get(order_url, headers=self.headers, allow_redirects=False)
28+
response.raise_for_status()
29+
return response.json()
30+
31+
# todo this is a sync wrapper around an async search, migrate to async
32+
def get_imaging_windows(self, payload: dict) -> dict:
33+
iw_url = f'{self.iw_search_url}'
34+
r = requests.post(iw_url, json=payload, headers=self.headers, allow_redirects=False)
35+
r.raise_for_status()
36+
if 'location' not in r.headers:
37+
raise ValueError(
38+
"Header 'location' not found: %s, status %s, body %s" % (
39+
list(r.headers.keys()), r.status_code, r.text)
40+
)
41+
poll_url = r.headers['location']
42+
43+
while True:
44+
r = requests.get(poll_url, headers=self.headers)
45+
r.raise_for_status()
46+
status = r.json()['status']
47+
if status == "DONE":
48+
return r.json()['imaging_windows']
49+
elif status == 'FAILED':
50+
raise ValueError(
51+
f"Retrieving Imaging Windows failed: {r.json['error_code']} - {r.json['error_message']}'")
52+
time.sleep(1)

0 commit comments

Comments
 (0)