diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 2bae249..5678f35 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -1,11 +1,16 @@
name: Checks
+
on:
- push:
- branches:
- - main
pull_request:
- branches:
- - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
prep:
@@ -13,31 +18,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- - name: Cancel Previous Runs
- uses: styfle/cancel-workflow-action@0.8.0
- with:
- access_token: ${{ github.token }}
-
- name: Checkout
- uses: actions/checkout@v2
-
- - name: Read .nvmrc
- run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV
+ uses: actions/checkout@v6
- - name: Use Node.js ${{ env.NVMRC }}
- uses: actions/setup-node@v1
+ - name: Use Node.js
+ uses: actions/setup-node@v6
with:
- node-version: ${{ env.NVMRC }}
+ node-version-file: '.nvmrc'
- name: Cache node_modules
- uses: actions/cache@v2
+ uses: actions/cache@v5
id: cache-node-modules
with:
path: node_modules
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }}
+ key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }}
- name: Install
- run: yarn install
+ run: corepack enable && yarn install
lint:
needs: prep
@@ -45,25 +42,22 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v6
- - name: Read .nvmrc
- run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV
-
- - name: Use Node.js ${{ env.NVMRC }}
- uses: actions/setup-node@v1
+ - name: Use Node.js
+ uses: actions/setup-node@v6
with:
- node-version: ${{ env.NVMRC }}
+ node-version-file: '.nvmrc'
- name: Cache node_modules
- uses: actions/cache@v2
+ uses: actions/cache@v5
id: cache-node-modules
with:
path: node_modules
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }}
+ key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }}
- name: Install
- run: yarn install
+ run: corepack enable && yarn install
- name: Lint
run: yarn lint
@@ -74,54 +68,48 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v2
-
- - name: Read .nvmrc
- run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV
+ uses: actions/checkout@v6
- - name: Use Node.js ${{ env.NVMRC }}
- uses: actions/setup-node@v1
+ - name: Use Node.js
+ uses: actions/setup-node@v6
with:
- node-version: ${{ env.NVMRC }}
+ node-version-file: '.nvmrc'
- name: Cache node_modules
- uses: actions/cache@v2
+ uses: actions/cache@v5
id: cache-node-modules
with:
path: node_modules
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }}
+ key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }}
- name: Install
- run: yarn install
+ run: corepack enable && yarn install
- name: Test
run: yarn test
build:
- needs: test
+ needs: prep
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
-
- - name: Read .nvmrc
- run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV
+ uses: actions/checkout@v6
- - name: Use Node.js ${{ env.NVMRC }}
- uses: actions/setup-node@v1
+ - name: Use Node.js
+ uses: actions/setup-node@v6
with:
- node-version: ${{ env.NVMRC }}
+ node-version-file: '.nvmrc'
- name: Cache node_modules
- uses: actions/cache@v2
+ uses: actions/cache@v5
id: cache-node-modules
with:
path: node_modules
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }}
+ key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }}
- name: Install
- run: yarn install
+ run: corepack enable && yarn install
- name: Test
- run: yarn build
+ run: yarn test
\ No newline at end of file
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index b3aaf53..b9244c4 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -157,8 +157,6 @@ if (error && error.status === 404) {
**After:**
```typescript
-import type { ApiErrorType } from 'stac-react';
-
const { error } = useCollection('my-collection');
if (error?.status === 404) {
@@ -176,13 +174,11 @@ if (error?.status === 404) {
**What's different?**
- Errors are now proper class instances with `ApiError`
-- Better TypeScript support with `ApiErrorType`
- Includes HTTP status codes and response details
- Consistent across all hooks
**Migration checklist:**
-- [ ] Import `ApiErrorType` from 'stac-react'
- [ ] Update error checks to use typed properties
- [ ] Test error scenarios (404, 500, network failures)
@@ -196,7 +192,7 @@ The most significant changes are in `useStacSearch`:
#### Before: Manual State Management
-```typescript
+```tsx
function MySearch() {
const {
state,
@@ -226,7 +222,7 @@ function MySearch() {
#### After: TanStack Query Integration
-```typescript
+```tsx
function MySearch() {
const {
isLoading,
@@ -347,7 +343,7 @@ You now must configure TanStack Query. The library doesn't do this for you (to a
#### Before
-```jsx
+```tsx
import { StacApiProvider } from 'stac-react';
function App() {
@@ -357,7 +353,7 @@ function App() {
#### After
-```jsx
+```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StacApiProvider } from 'stac-react';
@@ -426,7 +422,7 @@ const { collection: col2 } = useCollection('landsat-8');
### Request Deduplication
-```typescript
+```tsx
// Multiple components request same collection
// Only ONE network request is made, even if 5 components use the hook
@@ -438,7 +434,7 @@ const { collection: col2 } = useCollection('landsat-8');
### Invalidation on API Change
-```typescript
+```tsx
// If API URL changes, all queries are automatically invalidated
// Switch to new API:
@@ -469,7 +465,7 @@ test('loads collections', async () => {
### After
-```typescript
+```tsx
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCollections } from 'stac-react';
@@ -518,13 +514,13 @@ If you're using TypeScript, new types are available:
```typescript
import type {
- ApiErrorType, // Error response
FetchRequest, // Request types for useStacSearch
SearchRequestPayload, // Search parameters
} from 'stac-react';
// Your hook usage
-const { error }: { error?: ApiErrorType } = useCollection('id');
+// Error is of type ApiError
+const { error } = useCollection('id');
if (error) {
console.log(error.status); // number
@@ -541,7 +537,7 @@ if (error) {
**Before:**
-```typescript
+```tsx
{state === 'LOADING' && }
{state === 'IDLE' && results && }
{error && }
@@ -549,7 +545,7 @@ if (error) {
**After:**
-```typescript
+```tsx
{isLoading && }
{!isLoading && results && }
{error && }
@@ -559,7 +555,7 @@ if (error) {
**Before:**
-```typescript
+```tsx
@@ -567,7 +563,7 @@ if (error) {
**After:**
-```typescript
+```tsx
@@ -678,7 +674,6 @@ const testClient = new QueryClient({
- [ ] Replace `state` with `isLoading`/`isFetching` in all components
- [ ] Rename `reload` to `refetch` in all components
- [ ] Replace context data access with individual hooks
-- [ ] Update error handling to use typed `ApiErrorType`
- [ ] Update tests to use test QueryClient
- [ ] Remove context data subscriptions
- [ ] Review caching strategy for your app
diff --git a/example/src/App.jsx b/example/src/App.jsx
index 16e473a..536beb6 100644
--- a/example/src/App.jsx
+++ b/example/src/App.jsx
@@ -27,6 +27,7 @@ function App() {
// Debug: Verify QueryClient configuration
if (isDevelopment && typeof window !== 'undefined') {
+ // eslint-disable-next-line no-console
console.log('[App] QueryClient defaults:', queryClient.getDefaultOptions());
}
diff --git a/example/src/pages/Main/ItemDetails.jsx b/example/src/pages/Main/ItemDetails.jsx
index 1fa89d0..c276458 100644
--- a/example/src/pages/Main/ItemDetails.jsx
+++ b/example/src/pages/Main/ItemDetails.jsx
@@ -4,7 +4,9 @@ import { H2 } from '../../components/headers';
import Panel from '../../layout/Panel';
import { Button } from '../../components/buttons';
+// eslint-disable-next-line react/prop-types
function ItemDetails({ item, onClose }) {
+ // eslint-disable-next-line react/prop-types
const itemUrl = item.links.find((r) => r.rel === 'self')?.href;
const { item: newItem, isLoading, error, reload } = useItem(itemUrl);
diff --git a/jest.utils.ts b/jest.utils.ts
new file mode 100644
index 0000000..3ff97fe
--- /dev/null
+++ b/jest.utils.ts
@@ -0,0 +1,5 @@
+export function makeMockResponse(responseData: string, url: string, init?: ResponseInit): Response {
+ const response = new Response(responseData, init);
+ Object.defineProperty(response, 'url', { value: url });
+ return response;
+}
diff --git a/src/context/StacApiProvider.test.tsx b/src/context/StacApiProvider.test.tsx
index 14c7517..6dfd809 100644
--- a/src/context/StacApiProvider.test.tsx
+++ b/src/context/StacApiProvider.test.tsx
@@ -3,19 +3,13 @@ import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { StacApiProvider } from './index';
import { useStacApiContext } from './useStacApiContext';
+import { makeMockResponse } from '../../jest.utils';
// 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) => {
+ return Promise.resolve(makeMockResponse(JSON.stringify({ links: [] }), url));
+ });
});
// Component to test that hooks work inside StacApiProvider
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 e623ef0..d3e2398 100644
--- a/src/hooks/useCollection.ts
+++ b/src/hooks/useCollection.ts
@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query';
-import type { StacHook, StacRefetchFn, ApiErrorType } from '../types';
+import type { StacHook, StacRefetchFn } from '../types';
import type { Collection } from '../types/stac';
import { handleStacResponse } from '../utils/handleStacResponse';
import { generateCollectionQueryKey } from '../utils/queryKeys';
import { useStacApiContext } from '../context/useStacApiContext';
+import { ApiError } from '../utils/ApiError';
interface StacCollectionHook extends StacHook {
collection?: Collection;
@@ -25,7 +26,7 @@ function useCollection(collectionId: string): StacCollectionHook {
isLoading,
isFetching,
refetch,
- } = useQuery({
+ } = useQuery({
queryKey: generateCollectionQueryKey(collectionId),
queryFn: fetchCollection,
enabled: !!stacApi,
@@ -37,7 +38,7 @@ function useCollection(collectionId: string): StacCollectionHook {
isLoading,
isFetching,
refetch,
- error: error as ApiErrorType,
+ error,
};
}
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 319b848..1ac2961 100644
--- a/src/hooks/useCollections.ts
+++ b/src/hooks/useCollections.ts
@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query';
-import type { StacHook, StacRefetchFn, ApiErrorType } from '../types';
+import type { StacHook, StacRefetchFn } from '../types';
import type { CollectionsResponse } from '../types/stac';
import { handleStacResponse } from '../utils/handleStacResponse';
import { generateCollectionsQueryKey } from '../utils/queryKeys';
import { useStacApiContext } from '../context/useStacApiContext';
+import { ApiError } from '../utils/ApiError';
interface StacCollectionsHook extends StacHook {
collections?: CollectionsResponse;
@@ -25,7 +26,7 @@ function useCollections(): StacCollectionsHook {
isLoading,
isFetching,
refetch,
- } = useQuery({
+ } = useQuery({
queryKey: generateCollectionsQueryKey(),
queryFn: fetchCollections,
enabled: !!stacApi,
@@ -37,7 +38,7 @@ function useCollections(): StacCollectionsHook {
refetch,
isLoading,
isFetching,
- error: error as ApiErrorType,
+ error,
};
}
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 aaa5ff3..207c428 100644
--- a/src/hooks/useItem.ts
+++ b/src/hooks/useItem.ts
@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query';
-import type { StacHook, StacRefetchFn, ApiErrorType } from '../types';
+import type { StacHook, StacRefetchFn } from '../types';
import type { Item } from '../types/stac';
import { useStacApiContext } from '../context/useStacApiContext';
import { handleStacResponse } from '../utils/handleStacResponse';
import { generateItemQueryKey } from '../utils/queryKeys';
+import { ApiError } from '../utils/ApiError';
interface StacItemHook extends StacHook {
item?: Item;
@@ -25,7 +26,7 @@ function useItem(url: string): StacItemHook {
isLoading,
isFetching,
refetch,
- } = useQuery- ({
+ } = useQuery
- ({
queryKey: generateItemQueryKey(url),
queryFn: fetchItem,
enabled: !!stacApi,
@@ -36,7 +37,7 @@ function useItem(url: string): StacItemHook {
item,
isLoading,
isFetching,
- error: error as ApiErrorType,
+ error,
refetch,
};
}
diff --git a/src/hooks/useStacApi.ts b/src/hooks/useStacApi.ts
index d357d7b..44dd15b 100644
--- a/src/hooks/useStacApi.ts
+++ b/src/hooks/useStacApi.ts
@@ -3,6 +3,7 @@ import StacApi, { SearchMode } from '../stac-api';
import { Link } from '../types/stac';
import { GenericObject } from '../types';
import { generateStacApiQueryKey } from '../utils/queryKeys';
+import { handleStacResponse } from '../utils/handleStacResponse';
type StacApiHook = {
stacApi?: StacApi;
@@ -14,29 +15,18 @@ 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 handleStacResponse<{ 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,
});
diff --git a/src/hooks/useStacSearch.test.ts b/src/hooks/useStacSearch.test.ts
index 741df3a..726b793 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')
+ )
);
});
diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts
index ecac3a9..3a4488a 100644
--- a/src/hooks/useStacSearch.ts
+++ b/src/hooks/useStacSearch.ts
@@ -2,7 +2,7 @@ import { useCallback, useState, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import debounce from '../utils/debounce';
import { generateStacSearchQueryKey } from '../utils/queryKeys';
-import type { StacHook, ApiErrorType } from '../types';
+import type { StacHook } from '../types';
import { handleStacResponse } from '../utils/handleStacResponse';
import type {
Link,
@@ -13,6 +13,7 @@ import type {
FetchRequest,
} from '../types/stac';
import { useStacApiContext } from '../context/useStacApiContext';
+import { ApiError } from '../utils/ApiError';
type PaginationHandler = () => void;
@@ -125,7 +126,7 @@ function useStacSearch(): StacSearchHook {
error,
isLoading,
isFetching,
- } = useQuery({
+ } = useQuery({
queryKey: currentRequest ? generateStacSearchQueryKey(currentRequest) : ['stacSearch', 'idle'],
queryFn: () => fetchRequest(currentRequest!),
enabled: currentRequest !== null,
@@ -211,7 +212,7 @@ function useStacSearch(): StacSearchHook {
results,
isLoading,
isFetching,
- error: error as ApiErrorType,
+ error,
sortby,
setSortby,
limit,
diff --git a/src/stac-api/StacApi.test.ts b/src/stac-api/StacApi.test.ts
index e43f753..8433292 100644
--- a/src/stac-api/StacApi.test.ts
+++ b/src/stac-api/StacApi.test.ts
@@ -101,9 +101,6 @@ describe('StacApi', () => {
'https://api.example.com/search?collections=sentinel-2-l2a&datetime=2025-12-01%2F2025-12-31',
expect.objectContaining({
method: 'GET',
- headers: expect.objectContaining({
- 'Content-Type': 'application/json',
- }),
})
);
});
diff --git a/src/stac-api/index.ts b/src/stac-api/index.ts
index 4abfa54..598c499 100644
--- a/src/stac-api/index.ts
+++ b/src/stac-api/index.ts
@@ -1,4 +1,4 @@
-import type { ApiErrorType, GenericObject } from '../types';
+import type { GenericObject } from '../types';
import type { Bbox, SearchPayload, DateRange } from '../types/stac';
type RequestPayload = SearchPayload;
@@ -86,43 +86,16 @@ class StacApi {
return new URLSearchParams(queryObj).toString();
}
- async handleError(response: Response) {
- const { status, statusText } = response;
- const e: ApiErrorType = {
- status,
- statusText,
- };
-
- // 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();
- try {
- e.detail = await response.json();
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- } catch (err) {
- e.detail = await clone.text();
- }
- // 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 = {}): Promise {
const { method = 'GET', payload, headers = {} } = options;
return fetch(url, {
method,
headers: {
- 'Content-Type': 'application/json',
- ...headers,
...this.options?.headers,
+ ...headers,
},
body: payload ? JSON.stringify(payload) : undefined,
- }).then(async (response) => {
- if (response.ok) {
- return response;
- }
-
- return this.handleError(response);
});
}
@@ -140,7 +113,7 @@ class StacApi {
return this.fetch(`${this.baseUrl}/search`, {
method: 'POST',
payload: requestPayload,
- headers,
+ headers: { 'Content-Type': 'application/json', ...headers },
});
} else {
const query = this.payloadToQuery(requestPayload);
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 09dee83..1d5daa5 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -1,16 +1,10 @@
import type { QueryObserverResult } from '@tanstack/react-query';
+import { ApiError } from '../utils/ApiError';
export type GenericObject = {
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
};
-export type ApiErrorType = {
- detail?: GenericObject | string;
- status: number;
- statusText: string;
- url?: string;
-};
-
/**
* Base interface for all STAC hooks providing common loading state and error handling.
* All data-fetching hooks (useCollection, useCollections, useItem, useStacSearch)
@@ -22,11 +16,11 @@ export interface StacHook {
/** True during any fetch operation (including background refetches) */
isFetching: boolean;
/** Error information if the last request was unsuccessful */
- error?: ApiErrorType;
+ error: ApiError | null;
}
/**
* Generic refetch function type for STAC hooks.
* Returns a Promise with the query result including data and error information.
*/
-export type StacRefetchFn = () => Promise>;
+export type StacRefetchFn = () => Promise>;
diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts
index e5e5ef6..d0d1477 100644
--- a/src/utils/ApiError.ts
+++ b/src/utils/ApiError.ts
@@ -6,15 +6,13 @@ import type { GenericObject } from '../types';
*/
export class ApiError extends Error {
status: number;
- statusText: string;
detail?: GenericObject | string;
url?: string;
- constructor(statusText: string, status: number, detail?: GenericObject | string, url?: string) {
- super(statusText);
+ constructor(message: string, status: number, detail?: GenericObject | string, url?: string) {
+ super(message);
this.name = 'ApiError';
this.status = status;
- this.statusText = statusText;
this.detail = detail;
this.url = url;
diff --git a/src/utils/handleStacResponse.test.ts b/src/utils/handleStacResponse.test.ts
index ef6ca06..dabe2bc 100644
--- a/src/utils/handleStacResponse.test.ts
+++ b/src/utils/handleStacResponse.test.ts
@@ -6,16 +6,19 @@ describe('handleStacResponse', () => {
it('should parse and return JSON data', async () => {
const mockData = { id: 'collection-1', type: 'Collection' };
const jsonFn = jest.fn().mockResolvedValue(mockData);
+ const cloneFn = jest.fn();
const mockResponse = {
ok: true,
status: 200,
url: 'https://api.example.com/collections/collection-1',
json: jsonFn,
+ clone: cloneFn,
} as unknown as Response;
const result = await handleStacResponse(mockResponse);
expect(result).toEqual(mockData);
expect(jsonFn).toHaveBeenCalledTimes(1);
+ expect(cloneFn).toHaveBeenCalledTimes(1);
});
it('should handle different data types', async () => {
@@ -25,6 +28,7 @@ describe('handleStacResponse', () => {
status: 200,
url: 'https://api.example.com/search',
json: jest.fn().mockResolvedValue(mockData),
+ clone: jest.fn(),
} as unknown as Response;
const result = await handleStacResponse(mockResponse);
@@ -41,12 +45,13 @@ describe('handleStacResponse', () => {
statusText: 'Not Found',
url: 'https://api.example.com/collections/missing',
json: jest.fn().mockResolvedValue(errorDetail),
+ clone: jest.fn(),
} as unknown as Response;
await expect(handleStacResponse(mockResponse)).rejects.toThrow(ApiError);
await expect(handleStacResponse(mockResponse)).rejects.toMatchObject({
status: 404,
- statusText: 'Not Found',
+ message: 'Not Found',
detail: errorDetail,
url: 'https://api.example.com/collections/missing',
});
@@ -68,32 +73,11 @@ describe('handleStacResponse', () => {
await expect(handleStacResponse(mockResponse)).rejects.toThrow(ApiError);
await expect(handleStacResponse(mockResponse)).rejects.toMatchObject({
status: 500,
- statusText: 'Internal Server Error',
+ message: 'Internal Server Error',
detail: errorText,
url: 'https://api.example.com/search',
});
});
-
- it('should handle case where both JSON and text parsing fail', async () => {
- const mockResponse = {
- ok: false,
- status: 502,
- statusText: 'Bad Gateway',
- url: 'https://api.example.com/search',
- json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
- clone: jest.fn().mockReturnValue({
- text: jest.fn().mockRejectedValue(new Error('Cannot read text')),
- }),
- } as unknown as Response;
-
- await expect(handleStacResponse(mockResponse)).rejects.toThrow(ApiError);
- await expect(handleStacResponse(mockResponse)).rejects.toMatchObject({
- status: 502,
- statusText: 'Bad Gateway',
- detail: 'Unable to parse error response',
- url: 'https://api.example.com/search',
- });
- });
});
describe('invalid JSON responses', () => {
@@ -103,11 +87,14 @@ describe('handleStacResponse', () => {
status: 200,
url: 'https://api.example.com/collections',
json: jest.fn().mockRejectedValue(new SyntaxError('Unexpected token')),
+ clone: jest.fn().mockReturnValue({
+ text: jest.fn().mockResolvedValue('Invalid JSON'),
+ }),
} as unknown as Response;
await expect(handleStacResponse(mockResponse)).rejects.toThrow(ApiError);
await expect(handleStacResponse(mockResponse)).rejects.toMatchObject({
- statusText: 'Invalid JSON Response',
+ message: 'Invalid JSON: Unexpected token',
status: 200,
url: 'https://api.example.com/collections',
});
@@ -119,6 +106,9 @@ describe('handleStacResponse', () => {
status: 200,
url: 'https://api.example.com/collections',
json: jest.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON input')),
+ clone: jest.fn().mockReturnValue({
+ text: jest.fn().mockResolvedValue('Original non jsnon response'),
+ }),
} as unknown as Response;
try {
@@ -127,8 +117,8 @@ describe('handleStacResponse', () => {
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
- expect(apiError.detail).toContain('Response is not valid JSON');
- expect(apiError.detail).toContain('Unexpected end of JSON input');
+ expect(apiError.detail).toContain('Original non jsnon response');
+ expect(apiError.message).toContain('Invalid JSON: Unexpected end of JSON input');
}
});
});
@@ -142,6 +132,7 @@ describe('handleStacResponse', () => {
status: 200,
url: 'https://api.example.com/collections/col-1',
json: jest.fn().mockResolvedValue(mockData),
+ clone: jest.fn(),
} as unknown as Response;
const result = await handleStacResponse(mockResponse);
diff --git a/src/utils/handleStacResponse.ts b/src/utils/handleStacResponse.ts
index e548ca4..23041d1 100644
--- a/src/utils/handleStacResponse.ts
+++ b/src/utils/handleStacResponse.ts
@@ -24,29 +24,28 @@ import { ApiError } from './ApiError';
* ```
*/
export async function handleStacResponse(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 {
- const clone = response.clone();
- try {
- detail = await clone.text();
- } catch {
- detail = 'Unable to parse error response';
- }
+ detail = await clone.text();
}
-
throw new ApiError(response.statusText, response.status, detail, response.url);
}
try {
- return await response.json();
+ const result: T = await response.json();
+ return result;
} catch (error) {
throw new ApiError(
- 'Invalid JSON Response',
+ `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
response.status,
- `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
+ await clone.text(),
response.url
);
}
diff --git a/tsconfig.json b/tsconfig.json
index b70cad7..158657b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,6 +16,6 @@
"strictNullChecks": true,
"allowSyntheticDefaultImports": true
},
- "include": ["src", "jest.setup.ts", "vite.config.ts"],
+ "include": ["src", "jest.setup.ts", "vite.config.ts", "jest.utils.ts"],
"exclude": ["node_modules", "dist"]
}