Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/tr/pagination #123

Merged
merged 36 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
152a227
feat: adding input parameters to endpoint functions for /orders for p…
theodorehreuter Jan 2, 2025
0dcf05b
feat: applying new Orders class and test is breaking in the expected,…
theodorehreuter Jan 2, 2025
7734317
feat: correctly adding pagination link object to top level collection…
theodorehreuter Jan 3, 2025
16f4dc2
feat: tighened up logic in root_router.py to only add pagination link…
theodorehreuter Jan 3, 2025
e0fa4e7
poetry.lock
theodorehreuter Jan 3, 2025
90d0ebd
feat: updating lock file header fix: fix previous commit message
theodorehreuter Jan 3, 2025
53afb66
feat: rework test backend to make code smoother feat: use default f…
theodorehreuter Jan 7, 2025
723e645
feat: update Failure logic in root_router get_orders to return 404 fo…
theodorehreuter Jan 8, 2025
7b3453e
tests: tweak test name
theodorehreuter Jan 8, 2025
5db53c1
tests: reworked pagination test to iterate using next tokens to keep …
theodorehreuter Jan 8, 2025
b690cf1
feat: tweaking mock backend to use list index look up and return empt…
theodorehreuter Jan 9, 2025
068e4b2
Merge branch 'main' into feat/tr/pagination
theodorehreuter Jan 9, 2025
55e1c60
tests: moving tests around to work around issue of stapi_client fixtu…
theodorehreuter Jan 9, 2025
8886988
feat: clean up root backend model after messy main merge tests: fixe…
theodorehreuter Jan 9, 2025
2fca5c7
tests: add limit check assertion in pagination test
theodorehreuter Jan 9, 2025
d3c5aa8
feat: adding pagination to GET /products endpoint in root_router.py …
theodorehreuter Jan 10, 2025
ac661d9
feat: adding pagination for GET orders/order_id/statuses inputs to ro…
theodorehreuter Jan 10, 2025
690e44f
feat: refactoring get_products pagination based on feedback, refactor…
theodorehreuter Jan 14, 2025
e562d56
fix: remove unnecessar 'from e' in get_products()
theodorehreuter Jan 14, 2025
1cdc145
fix: fix bug in get_products where end was not being propery calculat…
theodorehreuter Jan 14, 2025
af2e2ad
fix: load mock data into in memory db more cleanly for order status t…
theodorehreuter Jan 14, 2025
2759ec2
feat: adding pagination for POST search_opportunities
theodorehreuter Jan 14, 2025
0d5d837
tests: abstracted pagination testing out into a more generalized pagi…
theodorehreuter Jan 16, 2025
fb9710e
tests: extend pagination tester to inlcude rebuilding and passing POS…
theodorehreuter Jan 16, 2025
adf1f5f
tests: updating pagination tests and pagination_tester to check exact…
theodorehreuter Jan 16, 2025
cc8fd62
feat: ensure that paginated endpoints can handle limit=0 and return e…
theodorehreuter Jan 16, 2025
a5999bd
fix: removing uneeded comment that was used in development
theodorehreuter Jan 17, 2025
9cfd146
feat: creating methods to make Links to improve readability feat: ma…
theodorehreuter Jan 24, 2025
35c8f53
calculate limit override more cleanly)
theodorehreuter Jan 24, 2025
c148b7d
feat: slightly tweaking the body that is returned by the post request…
theodorehreuter Jan 24, 2025
3a2d9c1
feat: small fixes based on PR feedback. Creating more link creation …
theodorehreuter Jan 27, 2025
7949857
feat: changed so /opportunities no longer can accept query params and…
theodorehreuter Jan 29, 2025
a19bd34
docs: updating README.md with pagination information
theodorehreuter Jan 29, 2025
4d17e1c
set python version to 3.12
theodorehreuter Jan 29, 2025
3ef8146
tests: small fixes based on PR comments
theodorehreuter Jan 30, 2025
33a9d38
test: add fail case to make_request match/case
theodorehreuter Jan 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions src/stapi_fastapi/backends/root_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@


class RootBackend[T: OrderStatusPayload, U: OrderStatus](Protocol): # pragma: nocover
async def get_orders(self, request: Request) -> ResultE[OrderCollection]:
async def get_orders(
self, request: Request, next: str | None, limit: int
) -> ResultE[tuple[OrderCollection, str]]:
"""
Return a list of existing orders.
Return a list of existing orders and pagination token if applicable
No pagination will return empty string for token
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
"""
...

Expand Down
30 changes: 20 additions & 10 deletions src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,12 @@ def get_products(self, request: Request) -> ProductsCollection:
],
)

async def get_orders(self, request: Request) -> OrderCollection:
match await self.backend.get_orders(request):
case Success(orders):
for order in orders:
async def get_orders(
self, request: Request, next: str | None = None, limit: int = 10
) -> OrderCollection:
match await self.backend.get_orders(request, next, limit):
case Success((collections, token)):
Copy link
Member

@jkeifer jkeifer Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change the name of orders to collections? backend.get_orders should be returning a list of Order objects to be composed into a single OrderCollection.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I was wrong here. The typing on backend.get_orders say it is returning an OrderCollection, not list[Order]. I am uncertain why this is the case and it feels wrong to me. I'd expect the OrderCollection to be assembled here in this function from a list[Order]. I'm am curious to go back and see if/when that changed, and, if it did, if I can figure out why.

Unless the backend has a really good reason to manage collection-type objects, I don't think it should. And I don't think there are any good reasons for it to do so.

In any case, collections is not an appropriate name for this variable.

Copy link
Contributor Author

@theodorehreuter theodorehreuter Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe. it was initially done here df6d1f0#diff-6d77ccddcf13b627327fd192d593943db5acb4be7bc636427a9ce7726b184a4b when it seems the root and product backends were spun off separately, and it was initially set up to return OrderCollection. In that same PR it seems that OrderCollection was also created, not sure why it was set as the return value. Interestingly the docstring in root_backend there specifically says list of existing orders which suggests a return type of list[Order]. I agree with your logic and this change was easy to apply, so root backend returns list[Orders] and the OrderCollection is constructed in root_router.py

Renamed variable too

for order in collections:
order.links.append(
Link(
href=str(
Expand All @@ -174,13 +176,21 @@ async def get_orders(self, request: Request) -> OrderCollection:
type=TYPE_JSON,
)
)
return orders
if token: # pagination link if backend returns token
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this comment provides any value. If you feel this code block is not obviously pertaining to pagination then I'd look at ways to make the code more self-documenting instead of adding comments. For example, if you don't see this immediately as handling pagination then what if you changed the name of token to pagination_token?

Copy link
Contributor Author

@theodorehreuter theodorehreuter Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great point, I like the renaming idea to make it more strongly self-documenting. Removed comment as well. Might've been a legacy from my initial work.

updated_url = request.url.include_query_params(next=token)
collections.links.append(
Link(href=str(updated_url), rel="next", type=TYPE_JSON)
)
return collections
case Failure(e):
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
logging.exception("An error occurred while retrieving orders", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error finding Orders",
)
logging.exception(f"An error occurred while retrieving orders: {e}")
philvarner marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(e, IndexError):
raise NotFoundException(detail="Error finding pagination token")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error finding Orders",
)
case _:
raise AssertionError("Expected code to be unreachable")

Expand Down
35 changes: 32 additions & 3 deletions tests/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,40 @@ class MockRootBackend(RootBackend):
def __init__(self, orders: InMemoryOrderDB) -> None:
self._orders_db: InMemoryOrderDB = orders

async def get_orders(self, request: Request) -> ResultE[OrderCollection]:
async def get_orders(
self, request: Request, next: str | None, limit: int
) -> ResultE[tuple[OrderCollection, str]]:
"""
Show all orders.
Return orders from backend. Handle pagination/limit if applicable
"""
return Success(OrderCollection(features=list(self._orders_db._orders.values())))
try:
start = 0
if limit > 100:
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
limit = 100

order_ids = [*self._orders_db._orders.keys()]
if not order_ids: # no data in db
return Success(
(
OrderCollection(
features=list(self._orders_db._orders.values())
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
),
"",
)
)

if next:
start = [i for i, x in enumerate(order_ids) if x == next][0]
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
end = start + limit
ids = order_ids[start:end]
feats = [self._orders_db._orders[order_id] for order_id in ids]

next = ""
if end < len(order_ids):
next = self._orders_db._orders[order_ids[end]].id
return Success((OrderCollection(features=feats), next))
except Exception as e:
return Failure(e)

async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]:
"""
Expand Down
128 changes: 128 additions & 0 deletions tests/test_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,131 @@ def test_order_status_after_update(
assert s["reason_text"] is None
assert s["status_code"] == "received"
assert s["timestamp"]


@pytest.fixture
def create_order_payloads() -> list[OrderPayload]:
datetimes = [
("2024-10-09T18:55:33Z", "2024-10-12T18:55:33Z"),
("2024-10-15T18:55:33Z", "2024-10-18T18:55:33Z"),
("2024-10-20T18:55:33Z", "2024-10-23T18:55:33Z"),
]
payloads = []
for start, end in datetimes:
payload = OrderPayload(
geometry=Point(
type="Point", coordinates=Position2D(longitude=14.4, latitude=56.5)
),
datetime=(
datetime.fromisoformat(start),
datetime.fromisoformat(end),
),
filter=None,
order_parameters=MyOrderParameters(s3_path="s3://my-bucket"),
)
payloads.append(payload)
return payloads


@pytest.fixture
def prepare_order_pagination(
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
stapi_client: TestClient, create_order_payloads: list[OrderPayload]
) -> tuple[str, str, str]:
# product_backend._allowed_payloads = create_order_payloads
product_id = "test-spotlight"

# # check empty
# res = stapi_client.get("/orders")
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
# default_orders = {"type": "FeatureCollection", "features": [], "links": []}
# assert res.status_code == status.HTTP_200_OK
# assert res.headers["Content-Type"] == "application/geo+json"
# assert res.json() == default_orders

# get uuids created to use as pagination tokens
order_ids = []
for payload in create_order_payloads:
res = stapi_client.post(
f"products/{product_id}/orders",
json=payload.model_dump(),
)
assert res.status_code == status.HTTP_201_CREATED, res.text
assert res.headers["Content-Type"] == "application/geo+json"
order_ids.append(res.json()["id"])

# res = stapi_client.get("/orders")
# checker = res.json()
# assert len(checker['features']) == 3
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved

return tuple(order_ids)


@pytest.mark.parametrize(
"product_id,expected_status,limit,id_retrieval,token_back",
[
pytest.param(
"test-spotlight",
status.HTTP_200_OK,
1,
0,
True,
id="input frst order_id token get new token back",
),
pytest.param(
"test-spotlight",
status.HTTP_200_OK,
1,
2,
False,
id="input last order_id token get NO token back",
),
pytest.param(
"test-spotlight",
status.HTTP_404_NOT_FOUND,
1,
"BAD_TOKEN",
False,
id="input bad token get 404 back",
),
pytest.param(
"test-spotlight",
status.HTTP_200_OK,
1,
1000000,
False,
id="high limit handled and returns valid records",
),
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
],
)
def test_order_pagination(
prepare_order_pagination,
stapi_client: TestClient,
product_id: str,
expected_status: int,
limit: int,
id_retrieval: int | str,
token_back: bool,
) -> None:
order_ids = prepare_order_pagination

res = stapi_client.get(
"/orders", params={"next": order_ids[id_retrieval], "limit": limit}
)
assert res.status_code == expected_status

body = res.json()
theodorehreuter marked this conversation as resolved.
Show resolved Hide resolved
for link in body["features"][0]["links"]:
assert link["rel"] != "next"
assert body["links"] != []

# check to make sure new token in link
if token_back:
assert order_ids[id_retrieval] not in body["links"][0]["href"]

assert len(body["features"]) == limit


# test cases to check
# 1. Input token and get last record. Should not return a token if we are returning the last record - 'last' record being what is sorted
# 2. Input a crzy high limit - how to handle? Default to max or all records if less than max
# 3. Input token and get some intermediate records - return a token for next records
# 4. handle requesting an orderid/token that does't exist and returns 400/404. Bad token --> bad request.