From ccc76da7a30427151dfaaab00090fb3c53c0b0f0 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 2 Dec 2025 18:01:10 +0000 Subject: [PATCH] Centralize stac api requests. --- src/context/StacApiProvider.test.tsx | 15 ++--- src/context/context.ts | 4 +- src/hooks/useCollection.test.ts | 35 +++++++----- src/hooks/useCollection.ts | 28 +--------- src/hooks/useCollections.test.ts | 22 ++++---- src/hooks/useCollections.ts | 28 +--------- src/hooks/useItem.test.ts | 22 ++++---- src/hooks/useItem.ts | 28 +--------- src/hooks/useStacApi.ts | 21 ++----- src/hooks/useStacSearch.test.ts | 28 +++++----- src/hooks/useStacSearch.ts | 41 ++------------ src/stac-api/index.ts | 84 ++++++++++++++++------------ 12 files changed, 129 insertions(+), 227 deletions(-) diff --git a/src/context/StacApiProvider.test.tsx b/src/context/StacApiProvider.test.tsx index 14c7517..b886e82 100644 --- a/src/context/StacApiProvider.test.tsx +++ b/src/context/StacApiProvider.test.tsx @@ -6,16 +6,11 @@ import { useStacApiContext } from './useStacApiContext'; // Mock fetch for testing - returns a successful response beforeEach(() => { - (global.fetch as jest.Mock) = jest.fn((url: string) => - Promise.resolve({ - ok: true, - url, // Return the requested URL - json: () => - Promise.resolve({ - links: [], - }), - }) - ); + (global.fetch as jest.Mock) = jest.fn((url: string) => { + const response = new Response(JSON.stringify({ links: [] })); + Object.defineProperty(response, 'url', { value: url }); + return Promise.resolve(response); + }); }); // Component to test that hooks work inside StacApiProvider diff --git a/src/context/context.ts b/src/context/context.ts index 1e644c0..6a9cada 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,8 +1,8 @@ import { createContext } from 'react'; +import StacApi from '../stac-api'; export type StacApiContextType = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stacApi?: any; + stacApi?: StacApi; }; export const StacApiContext = createContext({} as StacApiContextType); diff --git a/src/hooks/useCollection.test.ts b/src/hooks/useCollection.test.ts index bd97a8e..7e8228d 100644 --- a/src/hooks/useCollection.test.ts +++ b/src/hooks/useCollection.test.ts @@ -2,6 +2,7 @@ import fetch from 'jest-fetch-mock'; import { renderHook, act, waitFor } from '@testing-library/react'; import useCollection from './useCollection'; import wrapper from './wrapper'; +import { ApiError } from '../utils/ApiError'; describe('useCollection', () => { beforeEach(() => { @@ -31,11 +32,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('nonexistent'), { wrapper }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 404, - statusText: 'Not Found', - detail: { error: 'Collection not found' }, - }) + expect(result.current.error).toEqual( + new ApiError( + 'Not Found', + 404, + { error: 'Collection not found' }, + 'https://fake-stac-api.net/collections/nonexistent' + ) + ) ); }); @@ -49,11 +53,14 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('abc'), { wrapper }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: { error: 'Wrong query' }, - }) + expect(result.current.error).toEqual( + new ApiError( + 'Bad Request', + 400, + { error: 'Wrong query' }, + 'https://fake-stac-api.net/search' + ) + ) ); }); @@ -64,11 +71,9 @@ describe('useCollection', () => { const { result } = renderHook(() => useCollection('abc'), { wrapper }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: 'Wrong query', - }) + expect(result.current.error).toEqual( + new ApiError('Bad Request', 400, 'Wrong query', 'https://fake-stac-api.net/search') + ) ); }); diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index 44465c9..dd02c78 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,7 +1,6 @@ import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import type { ApiErrorType } from '../types'; import type { Collection } from '../types/stac'; -import { ApiError } from '../utils/ApiError'; import { generateCollectionQueryKey } from '../utils/queryKeys'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -16,31 +15,6 @@ type StacCollectionHook = { function useCollection(collectionId: string): StacCollectionHook { const { stacApi } = useStacApiContext(); - const fetchCollection = async (): Promise => { - if (!stacApi) throw new Error('No STAC API configured'); - const response: Response = await stacApi.getCollection(collectionId); - if (!response.ok) { - let detail; - try { - detail = await response.json(); - } catch { - detail = await response.text(); - } - - throw new ApiError(response.statusText, response.status, detail, response.url); - } - try { - return await response.json(); - } catch (error) { - throw new ApiError( - 'Invalid JSON Response', - response.status, - `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - response.url - ); - } - }; - const { data: collection, error, @@ -49,7 +23,7 @@ function useCollection(collectionId: string): StacCollectionHook { refetch, } = useQuery({ queryKey: generateCollectionQueryKey(collectionId), - queryFn: fetchCollection, + queryFn: () => stacApi!.getCollection(collectionId), enabled: !!stacApi, retry: false, }); diff --git a/src/hooks/useCollections.test.ts b/src/hooks/useCollections.test.ts index 2d2c96b..dce60dd 100644 --- a/src/hooks/useCollections.test.ts +++ b/src/hooks/useCollections.test.ts @@ -2,6 +2,7 @@ import fetch from 'jest-fetch-mock'; import { renderHook, act, waitFor } from '@testing-library/react'; import useCollections from './useCollections'; import wrapper from './wrapper'; +import { ApiError } from '../utils/ApiError'; describe('useCollections', () => { beforeEach(() => { @@ -50,11 +51,14 @@ describe('useCollections', () => { const { result } = renderHook(() => useCollections(), { wrapper }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: { error: 'Wrong query' }, - }) + expect(result.current.error).toEqual( + new ApiError( + 'Bad Request', + 400, + { error: 'Wrong query' }, + 'https://fake-stac-api.net/search' + ) + ) ); }); @@ -65,11 +69,9 @@ describe('useCollections', () => { const { result } = renderHook(() => useCollections(), { wrapper }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: 'Wrong query', - }) + expect(result.current.error).toEqual( + new ApiError('Bad Request', 400, 'Wrong query', 'https://fake-stac-api.net/search') + ) ); }); }); diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 1219a40..656e981 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -1,7 +1,6 @@ import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import { type ApiErrorType } from '../types'; import type { CollectionsResponse } from '../types/stac'; -import { ApiError } from '../utils/ApiError'; import { generateCollectionsQueryKey } from '../utils/queryKeys'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -16,31 +15,6 @@ type StacCollectionsHook = { function useCollections(): StacCollectionsHook { const { stacApi } = useStacApiContext(); - const fetchCollections = async (): Promise => { - if (!stacApi) throw new Error('No STAC API configured'); - const response: Response = await stacApi.getCollections(); - if (!response.ok) { - let detail; - try { - detail = await response.json(); - } catch { - detail = await response.text(); - } - - throw new ApiError(response.statusText, response.status, detail, response.url); - } - try { - return await response.json(); - } catch (error) { - throw new ApiError( - 'Invalid JSON Response', - response.status, - `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - response.url - ); - } - }; - const { data: collections, error, @@ -49,7 +23,7 @@ function useCollections(): StacCollectionsHook { refetch, } = useQuery({ queryKey: generateCollectionsQueryKey(), - queryFn: fetchCollections, + queryFn: () => stacApi!.getCollections(), enabled: !!stacApi, retry: false, }); diff --git a/src/hooks/useItem.test.ts b/src/hooks/useItem.test.ts index 318d861..b89048b 100644 --- a/src/hooks/useItem.test.ts +++ b/src/hooks/useItem.test.ts @@ -2,6 +2,7 @@ import fetch from 'jest-fetch-mock'; import { renderHook, act, waitFor } from '@testing-library/react'; import useItem from './useItem'; import wrapper from './wrapper'; +import { ApiError } from '../utils/ApiError'; describe('useItem', () => { beforeEach(() => { @@ -32,11 +33,14 @@ describe('useItem', () => { wrapper, }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: { error: 'Wrong query' }, - }) + expect(result.current.error).toEqual( + new ApiError( + 'Bad Request', + 400, + { error: 'Wrong query' }, + 'https://fake-stac-api.net/search' + ) + ) ); }); @@ -49,11 +53,9 @@ describe('useItem', () => { wrapper, }); await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: 'Wrong query', - }) + expect(result.current.error).toEqual( + new ApiError('Bad Request', 400, 'Wrong query', 'https://fake-stac-api.net/search') + ) ); }); diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 16f7f1e..8c182f0 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -2,7 +2,6 @@ import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import { Item } from '../types/stac'; import { type ApiErrorType } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; -import { ApiError } from '../utils/ApiError'; import { generateItemQueryKey } from '../utils/queryKeys'; type ItemHook = { @@ -16,31 +15,6 @@ type ItemHook = { function useItem(url: string): ItemHook { const { stacApi } = useStacApiContext(); - const fetchItem = async (): Promise => { - if (!stacApi) throw new Error('No STAC API configured'); - const response: Response = await stacApi.get(url); - if (!response.ok) { - let detail; - try { - detail = await response.json(); - } catch { - detail = await response.text(); - } - - throw new ApiError(response.statusText, response.status, detail, response.url); - } - try { - return await response.json(); - } catch (error) { - throw new ApiError( - 'Invalid JSON Response', - response.status, - `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - response.url - ); - } - }; - const { data: item, error, @@ -49,7 +23,7 @@ function useItem(url: string): ItemHook { refetch, } = useQuery({ queryKey: generateItemQueryKey(url), - queryFn: fetchItem, + queryFn: () => stacApi!.get(url), enabled: !!stacApi, retry: false, }); diff --git a/src/hooks/useStacApi.ts b/src/hooks/useStacApi.ts index d357d7b..41eb71a 100644 --- a/src/hooks/useStacApi.ts +++ b/src/hooks/useStacApi.ts @@ -14,32 +14,23 @@ function useStacApi(url: string, options?: GenericObject): StacApiHook { const { data, isSuccess, isLoading, isError } = useQuery({ queryKey: generateStacApiQueryKey(url, options), queryFn: async () => { - let searchMode = SearchMode.GET; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...options?.headers, }, }); - const baseUrl = response.url; - let json; - try { - json = await response.json(); - } catch (error) { - throw new Error( - `Invalid JSON response from STAC API: ${error instanceof Error ? error.message : String(error)}` - ); - } - const doesPost = json.links?.find( + const stacData = await StacApi.handleResponse<{ links?: Link[] }>(response); + + const doesPost = stacData.links?.find( ({ rel, method }: Link) => rel === 'search' && method === 'POST' ); - if (doesPost) { - searchMode = SearchMode.POST; - } - return new StacApi(baseUrl, searchMode, options); + + return new StacApi(response.url, doesPost ? SearchMode.POST : SearchMode.GET, options); }, staleTime: Infinity, }); + return { stacApi: isSuccess ? data : undefined, isLoading, isError }; } diff --git a/src/hooks/useStacSearch.test.ts b/src/hooks/useStacSearch.test.ts index 741df3a..944b637 100644 --- a/src/hooks/useStacSearch.test.ts +++ b/src/hooks/useStacSearch.test.ts @@ -2,6 +2,7 @@ import fetch from 'jest-fetch-mock'; import { renderHook, act, waitFor } from '@testing-library/react'; import useStacSearch from './useStacSearch'; import wrapper from './wrapper'; +import { ApiError } from '../utils/ApiError'; function parseRequestPayload(mockApiCall?: RequestInit) { if (!mockApiCall) { @@ -268,13 +269,16 @@ describe('useStacSearch — API supports POST', () => { // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for error to be set in state - await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: { error: 'Wrong query' }, - }) - ); + await waitFor(() => { + expect(result.current.error).toEqual( + new ApiError( + 'Bad Request', + 400, + { error: 'Wrong query' }, + 'https://fake-stac-api.net/search' + ) + ); + }); }); it('handles error with non-JSON response', async () => { @@ -299,11 +303,9 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for error to be set in state await waitFor(() => - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: 'Wrong query', - }) + expect(result.current.error).toEqual( + new ApiError('Bad Request', 400, 'Wrong query', 'https://fake-stac-api.net/search') + ) ); }); @@ -551,7 +553,7 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.results).toEqual({ data: '12345' }); const postHeader = fetch.mock.calls[2][1]?.headers; - expect(postHeader).toEqual({ 'Content-Type': 'application/json', next: '123abc' }); + expect(postHeader).toEqual({ next: '123abc' }); }); it('loads next-page from GET request', async () => { diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index 05c1bbd..3a2e4be 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import debounce from '../utils/debounce'; import { generateStacSearchQueryKey } from '../utils/queryKeys'; import { type ApiErrorType } from '../types'; -import { ApiError } from '../utils/ApiError'; import type { Link, Bbox, @@ -106,39 +105,6 @@ function useStacSearch(): StacSearchHook { [ids, bbox, collections, dateRangeFrom, dateRangeTo, sortby, limit] ); - /** - * Fetch function for searches using TanStack Query - */ - const fetchRequest = async (request: FetchRequest): Promise => { - if (!stacApi) throw new Error('No STAC API configured'); - - const response = - request.type === 'search' - ? await stacApi.search(request.payload, request.headers) - : await stacApi.get(request.url); - - if (!response.ok) { - let detail; - try { - detail = await response.json(); - } catch { - detail = await response.text(); - } - - throw new ApiError(response.statusText, response.status, detail, response.url); - } - try { - return await response.json(); - } catch (error) { - throw new ApiError( - 'Invalid JSON Response', - response.status, - `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, - response.url - ); - } - }; - /** * useQuery for search and pagination with caching */ @@ -149,8 +115,11 @@ function useStacSearch(): StacSearchHook { isFetching, } = useQuery({ queryKey: currentRequest ? generateStacSearchQueryKey(currentRequest) : ['stacSearch', 'idle'], - queryFn: () => fetchRequest(currentRequest!), - enabled: currentRequest !== null, + queryFn: () => + currentRequest!.type === 'search' + ? stacApi!.search(currentRequest!.payload, currentRequest!.headers) + : stacApi!.get(currentRequest!.url), + enabled: !!stacApi && currentRequest !== null, retry: false, }); diff --git a/src/stac-api/index.ts b/src/stac-api/index.ts index 4abfa54..d18f2fb 100644 --- a/src/stac-api/index.ts +++ b/src/stac-api/index.ts @@ -1,5 +1,13 @@ -import type { ApiErrorType, GenericObject } from '../types'; -import type { Bbox, SearchPayload, DateRange } from '../types/stac'; +import type { GenericObject } from '../types'; +import type { + Bbox, + SearchPayload, + DateRange, + CollectionsResponse, + Collection, + SearchResponse, +} from '../types/stac'; +import { ApiError } from '../utils/ApiError'; type RequestPayload = SearchPayload; type FetchOptions = { @@ -85,48 +93,51 @@ class StacApi { return new URLSearchParams(queryObj).toString(); } - - async handleError(response: Response) { - const { status, statusText } = response; - const e: ApiErrorType = { - status, - statusText, - }; - + static async handleResponse(response: Response): Promise { // Some STAC APIs return errors as JSON others as string. // Clone the response so we can read the body as text if json fails. const clone = response.clone(); + + if (!response.ok) { + let detail; + try { + detail = await response.json(); + } catch { + detail = await clone.text(); + } + throw new ApiError(response.statusText, response.status, detail, response.url); + } + try { - e.detail = await response.json(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - e.detail = await clone.text(); + return await response.json(); + } catch (_) { + throw new ApiError( + 'Invalid JSON Response', + response.status, + await clone.text(), + response.url + ); } - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - return Promise.reject(e); } - fetch(url: string, options: Partial = {}): Promise { + async fetch(url: string, options: Partial = {}) { const { method = 'GET', payload, headers = {} } = options; - return fetch(url, { + // Fetch can also throw errors on network failure, but we don't want to + // catch those here. + const response = await fetch(url, { method, headers: { - 'Content-Type': 'application/json', + ...(this.options?.headers || {}), ...headers, - ...this.options?.headers, }, body: payload ? JSON.stringify(payload) : undefined, - }).then(async (response) => { - if (response.ok) { - return response; - } - - return this.handleError(response); }); + + return StacApi.handleResponse(response); } - search(payload: SearchPayload, headers = {}): Promise { + search(payload: SearchPayload, headers = {}) { const { ids, bbox, dateRange, collections, ...restPayload } = payload; const requestPayload = { ...restPayload, @@ -137,27 +148,30 @@ class StacApi { }; if (this.searchMode === SearchMode.POST) { - return this.fetch(`${this.baseUrl}/search`, { + return this.fetch(`${this.baseUrl}/search`, { method: 'POST', payload: requestPayload, headers, }); } else { const query = this.payloadToQuery(requestPayload); - return this.fetch(`${this.baseUrl}/search?${query}`, { method: 'GET', headers }); + return this.fetch(`${this.baseUrl}/search?${query}`, { + method: 'GET', + headers, + }); } } - getCollections(): Promise { - return this.fetch(`${this.baseUrl}/collections`); + getCollections() { + return this.fetch(`${this.baseUrl}/collections`); } - getCollection(collectionId: string): Promise { - return this.fetch(`${this.baseUrl}/collections/${collectionId}`); + getCollection(collectionId: string) { + return this.fetch(`${this.baseUrl}/collections/${collectionId}`); } - get(href: string, headers = {}): Promise { - return this.fetch(href, { headers }); + get(href: string, headers = {}) { + return this.fetch(href, { headers }); } }