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"] }