diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_runs.py b/src/sentry/seer/endpoints/organization_seer_explorer_runs.py index 2380c1b5545e83..7436af14dffd64 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_runs.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_runs.py @@ -1,10 +1,10 @@ from __future__ import annotations import logging +from typing import Any import orjson from django.conf import settings -from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -13,6 +13,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import GenericOffsetPaginator from sentry.models.organization import Organization from sentry.net.http import connection_from_url from sentry.seer.seer_setup import has_seer_access_with_detail @@ -29,21 +30,6 @@ class OrganizationSeerExplorerRunsPermission(OrganizationPermission): } -class ExplorerRunsRequestSerializer(serializers.Serializer): - offset = serializers.IntegerField(required=False, allow_null=False) - limit = serializers.IntegerField(required=False, allow_null=False) - - def validate_offset(self, value: int) -> int: - if value < 0: - raise serializers.ValidationError("Offset must be non-negative") - return value - - def validate_limit(self, value: int) -> int: - if value < 1: - raise serializers.ValidationError("Limit must be greater than 0") - return value - - @region_silo_endpoint class OrganizationSeerExplorerRunsEndpoint(OrganizationEndpoint): publish_status = { @@ -72,34 +58,26 @@ def get(self, request: Request, organization: Organization) -> Response: status=403, ) - serializer = ExplorerRunsRequestSerializer(data=request.GET) - if not serializer.is_valid(): - return Response(serializer.errors, status=400) - - offset: int | None = serializer.validated_data.get("offset") - limit: int | None = serializer.validated_data.get("limit") - - path = "/v1/automation/explorer/runs" - body = orjson.dumps( - { - "organization_id": organization.id, - "user_id": request.user.id, - "offset": offset, - "limit": limit, - }, - option=orjson.OPT_NON_STR_KEYS, - ) - - response = make_signed_seer_api_request(autofix_connection_pool, path, body) - if response.status < 200 or response.status >= 300: - logger.error( - "Seer explorer runs endpoint failed", - extra={ - "path": path, - "status_code": response.status, - "response_data": response.data, + def make_seer_runs_request(offset: int, limit: int) -> dict[str, Any]: + path = "/v1/automation/explorer/runs" + body = orjson.dumps( + { + "organization_id": organization.id, + "user_id": request.user.id, + "offset": offset, + "limit": limit, }, + option=orjson.OPT_NON_STR_KEYS, ) - return Response({"detail": "Internal Server Error"}, status=502) - return Response(response.json(), status=response.status) + response = make_signed_seer_api_request(autofix_connection_pool, path, body) + if response.status < 200 or response.status >= 300: + raise Exception(f"Seer explorer runs endpoint failed with status {response.status}") + + return response.json() + + return self.paginate( + request=request, + paginator=GenericOffsetPaginator(data_fn=make_seer_runs_request), + default_per_page=100, + ) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_runs.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_runs.py index fc29bb8b281e80..6fb93558178c9c 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_runs.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_runs.py @@ -5,6 +5,7 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature +from sentry.utils.cursors import Cursor @with_feature("organizations:seer-explorer") @@ -35,11 +36,11 @@ def tearDown(self) -> None: def test_get_simple(self) -> None: self.make_seer_request.return_value.status = 200 self.make_seer_request.return_value.json.return_value = { - "hello": "world", + "data": [{"run_id": "1"}, {"run_id": "2"}], } response = self.client.get(self.url) assert response.status_code == 200 - assert response.json() == {"hello": "world"} + assert response.json()["data"] == [{"run_id": "1"}, {"run_id": "2"}] self.make_seer_request.assert_called_once() call_args = self.make_seer_request.call_args @@ -48,18 +49,21 @@ def test_get_simple(self) -> None: assert body_json == { "organization_id": self.organization.id, "user_id": self.user.id, - "limit": None, - "offset": None, + "limit": 101, # Default per_page of 100 + 1 for has_more + "offset": 0, } - def test_get_with_offset_and_limit(self) -> None: + def test_get_cursor_pagination(self) -> None: self.make_seer_request.return_value.status = 200 + # Mock seer response for offset 0, limit 3. self.make_seer_request.return_value.json.return_value = { - "hello": "world", + "data": [{"run_id": "1"}, {"run_id": "2"}, {"run_id": "3"}], } - response = self.client.get(self.url + "?offset=1&limit=11") + cursor = str(Cursor(0, 0)) + response = self.client.get(self.url + f"?per_page=2&cursor={cursor}") assert response.status_code == 200 - assert response.json() == {"hello": "world"} + assert response.json()["data"] == [{"run_id": "1"}, {"run_id": "2"}] + assert 'rel="next"; results="true"' in response.headers["Link"] self.make_seer_request.assert_called_once() call_args = self.make_seer_request.call_args @@ -68,26 +72,34 @@ def test_get_with_offset_and_limit(self) -> None: assert body_json == { "organization_id": self.organization.id, "user_id": self.user.id, - "limit": 11, - "offset": 1, + "limit": 3, # +1 for has_more + "offset": 0, } - def test_get_with_invalid_limit(self) -> None: - for value in ["invalid", "-1", "0"]: - response = self.client.get(self.url + f"?limit={value}") - assert response.status_code == 400 - assert "limit" in response.json() + # Second page - mock seer response for offset 2, limit 3. + self.make_seer_request.return_value.json.return_value = { + "data": [{"run_id": "3"}, {"run_id": "4"}], + } + cursor = str(Cursor(0, 2)) + response = self.client.get(self.url + f"?per_page=2&cursor={cursor}") + assert response.status_code == 200 + assert response.json()["data"] == [{"run_id": "3"}, {"run_id": "4"}] + assert 'rel="next"; results="false"' in response.headers["Link"] - def test_get_with_invalid_offset(self) -> None: - for value in ["invalid", "-1"]: - response = self.client.get(self.url + f"?offset={value}") - assert response.status_code == 400 - assert "offset" in response.json() + call_args = self.make_seer_request.call_args + assert call_args[0][1] == "/v1/automation/explorer/runs" + body_json = orjson.loads(call_args[0][2]) + assert body_json == { + "organization_id": self.organization.id, + "user_id": self.user.id, + "limit": 3, # +1 for has_more + "offset": 2, + } def test_get_with_seer_error(self) -> None: - self.make_seer_request.return_value.status = 500 + self.make_seer_request.return_value.status = 404 response = self.client.get(self.url) - assert response.status_code == 502 + assert response.status_code == 500 class TestOrganizationSeerExplorerRunsEndpointFeatureFlags(APITestCase):