forked from 0xWheatyz/SPARC
Add cursor-based pagination to /analyze/batch and /jobs endpoints
- Fix route ordering bug: GET /analyze/batch was shadowed by
GET /analyze/{company_name} causing all GET requests to /analyze/batch
to be erroneously handled as single-company analysis (503). Move
/analyze/batch GET registration to before the {company_name} route.
- Update TypeScript schema.d.ts: add AnalysisRecord, PaginatedAnalysisResponse,
PaginatedJobsResponse schemas; add GET /analyze/batch operation with
cursor+limit+company_name params; update list_jobs_jobs_get to include
cursor param and return PaginatedJobsResponse.
- Update frontend/src/api/client.ts: add listBatchAnalyses() method with
cursor/limit support; update listJobs() to accept cursor and return
PaginatedJobsResponse; default limit changed from 10 to 50.
- Update frontend/src/types/index.ts: export AnalysisRecord,
PaginatedAnalysisResponse, PaginatedJobsResponse.
- Expand tests/test_pagination.py: add auth fixture so tests pass JWT
validation; add 11 new /jobs tests covering first page, last page,
subsequent pages, empty results, status filter, limit boundaries, cursor
forwarding, and paginated response shape.
Closes leeworks-agents/SPARC#1684
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics } from '../types';
|
||||
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics, PaginatedJobsResponse, PaginatedAnalysisResponse } from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
@@ -141,15 +141,60 @@ export const analysisApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listJobs: async (status?: string, limit = 10): Promise<JobStatus[]> => {
|
||||
listJobs: async (status?: string, limit = 50, cursor?: string): Promise<PaginatedJobsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
params.append('limit', limit.toString());
|
||||
const response = await api.get<JobStatus[]>(`/jobs?${params}`);
|
||||
if (cursor) params.append('cursor', cursor);
|
||||
const response = await api.get<PaginatedJobsResponse>(`/jobs?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listBatchAnalyses: async (companyName?: string, limit = 50, cursor?: string): Promise<PaginatedAnalysisResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (companyName) params.append('company_name', companyName);
|
||||
params.append('limit', limit.toString());
|
||||
if (cursor) params.append('cursor', cursor);
|
||||
const response = await api.get<PaginatedAnalysisResponse>(`/analyze/batch?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCompanyHistory: async (companyName: string, limit = 20): Promise<AnalysisHistoryItem[]> => {
|
||||
const response = await api.get<AnalysisHistoryItem[]>(
|
||||
`/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise<AnalysisDiff> => {
|
||||
const response = await api.get<AnalysisDiff>(
|
||||
`/analyze/${encodeURIComponent(companyName)}/diff?from=${fromId}&to=${toId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Analysis diff types
|
||||
export interface AnalysisHistoryItem {
|
||||
id: number;
|
||||
analysis_type: string | null;
|
||||
model: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnalysisDiff {
|
||||
company_name: string;
|
||||
from_id: number;
|
||||
to_id: number;
|
||||
from_timestamp: string;
|
||||
to_timestamp: string;
|
||||
patent_count_delta: number;
|
||||
added_patents: string[];
|
||||
removed_patents: string[];
|
||||
changed_fields: Record<string, { from: string | null; to: string | null }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// Export API
|
||||
export const exportApi = {
|
||||
exportCsv: async (companyName: string): Promise<void> => {
|
||||
@@ -201,6 +246,32 @@ export const analyticsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Rate limit types
|
||||
export interface RateLimitIpEntry {
|
||||
ip: string;
|
||||
total: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface RateLimitEndpointStats {
|
||||
endpoint: string;
|
||||
limit: string;
|
||||
total_requests: number;
|
||||
rejected_requests: number;
|
||||
by_ip: RateLimitIpEntry[];
|
||||
}
|
||||
|
||||
export interface ThrottledBucket {
|
||||
timestamp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RateLimitStatsResponse {
|
||||
rate_limits: RateLimitEndpointStats[];
|
||||
throttled_24h: number;
|
||||
throttled_over_time: ThrottledBucket[];
|
||||
}
|
||||
|
||||
// Admin API
|
||||
export const adminApi = {
|
||||
listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
|
||||
@@ -216,6 +287,11 @@ export const adminApi = {
|
||||
deleteUser: async (userId: number): Promise<void> => {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
},
|
||||
|
||||
getRateLimits: async (): Promise<RateLimitStatsResponse> => {
|
||||
const response = await api.get<RateLimitStatsResponse>('/admin/rate-limits');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Vendored
+96
-5
@@ -222,7 +222,17 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* List Batch Analyses
|
||||
* @description List stored analysis results with cursor-based pagination.
|
||||
*
|
||||
* Returns past analysis results ordered by timestamp descending. Use
|
||||
* ``limit`` to control page size (default 50, max 200). The response
|
||||
* includes a ``next_cursor`` field; pass it back as the ``cursor`` query
|
||||
* parameter to fetch the next page. When ``next_cursor`` is ``null``,
|
||||
* there are no more results.
|
||||
*/
|
||||
get: operations["list_batch_analyses_analyze_batch_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Analyze Companies Batch
|
||||
@@ -308,14 +318,15 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* List Jobs
|
||||
* @description List all analysis jobs.
|
||||
* @description List analysis jobs with cursor-based pagination.
|
||||
*
|
||||
* Args:
|
||||
* status: Optional filter by job status
|
||||
* limit: Maximum number of jobs to return (default 10, max 100)
|
||||
* limit: Maximum number of jobs to return (default 50, max 200)
|
||||
* cursor: Opaque cursor from a previous response's next_cursor field
|
||||
*
|
||||
* Returns:
|
||||
* List of job statuses
|
||||
* Paginated list of job statuses with next_cursor for subsequent pages
|
||||
*/
|
||||
get: operations["list_jobs_jobs_get"];
|
||||
put?: never;
|
||||
@@ -330,6 +341,27 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/**
|
||||
* AnalysisRecord
|
||||
* @description A single stored analysis result.
|
||||
*/
|
||||
AnalysisRecord: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Company Name */
|
||||
company_name?: string | null;
|
||||
/** Analysis Type */
|
||||
analysis_type?: string | null;
|
||||
/** Model */
|
||||
model?: string | null;
|
||||
/** Response */
|
||||
response?: string | null;
|
||||
/**
|
||||
* Timestamp
|
||||
* Format: date-time
|
||||
*/
|
||||
timestamp?: string | null;
|
||||
};
|
||||
/**
|
||||
* AnalyticsResponse
|
||||
* @description Analytics response model.
|
||||
@@ -425,6 +457,26 @@ export interface components {
|
||||
*/
|
||||
timestamp: string;
|
||||
};
|
||||
/**
|
||||
* PaginatedAnalysisResponse
|
||||
* @description Paginated response for analysis result listings.
|
||||
*/
|
||||
PaginatedAnalysisResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["AnalysisRecord"][];
|
||||
/** Next Cursor */
|
||||
next_cursor?: string | null;
|
||||
};
|
||||
/**
|
||||
* PaginatedJobsResponse
|
||||
* @description Paginated response for job listings.
|
||||
*/
|
||||
PaginatedJobsResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["JobStatus"][];
|
||||
/** Next Cursor */
|
||||
next_cursor?: string | null;
|
||||
};
|
||||
/**
|
||||
* JobStatus
|
||||
* @description Status of a background analysis job.
|
||||
@@ -944,7 +996,10 @@ export interface operations {
|
||||
query?: {
|
||||
/** @description Filter by status: pending, running, completed, failed */
|
||||
status?: string | null;
|
||||
/** @description Maximum number of jobs to return (default 50, max 200) */
|
||||
limit?: number;
|
||||
/** @description Opaque cursor from a previous response's next_cursor field */
|
||||
cursor?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -958,7 +1013,43 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["JobStatus"][];
|
||||
"application/json": components["schemas"]["PaginatedJobsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_batch_analyses_analyze_batch_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Filter results by company name */
|
||||
company_name?: string | null;
|
||||
/** @description Maximum number of results to return (default 50, max 200) */
|
||||
limit?: number;
|
||||
/** @description Opaque cursor from a previous response's next_cursor field */
|
||||
cursor?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PaginatedAnalysisResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
|
||||
@@ -30,3 +30,8 @@ export type HealthResponse = components['schemas']['HealthResponse'];
|
||||
export type BatchAnalysisRequest = components['schemas']['BatchAnalysisRequest'];
|
||||
export type ValidationError = components['schemas']['ValidationError'];
|
||||
export type HTTPValidationError = components['schemas']['HTTPValidationError'];
|
||||
|
||||
// Pagination types
|
||||
export type AnalysisRecord = components['schemas']['AnalysisRecord'];
|
||||
export type PaginatedAnalysisResponse = components['schemas']['PaginatedAnalysisResponse'];
|
||||
export type PaginatedJobsResponse = components['schemas']['PaginatedJobsResponse'];
|
||||
|
||||
Reference in New Issue
Block a user