Skip to content

Commit 815d89e

Browse files
committed
Consolidate response parsing logic into handleStacResponse utility
- Add handleStacResponse<T>() utility function to remove duplication - Refactor useCollection, useCollections, useItem, useStacSearch to use utility - Add test suite for handleStacResponse - Add test suite for StacApi class methods
1 parent 32272eb commit 815d89e

File tree

7 files changed

+408
-84
lines changed

7 files changed

+408
-84
lines changed

src/hooks/useCollection.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useQuery, type QueryObserverResult } from '@tanstack/react-query';
22
import type { ApiErrorType } from '../types';
33
import type { Collection } from '../types/stac';
4-
import { ApiError } from '../utils/ApiError';
4+
import { handleStacResponse } from '../utils/handleStacResponse';
55
import { generateCollectionQueryKey } from '../utils/queryKeys';
66
import { useStacApiContext } from '../context/useStacApiContext';
77

@@ -19,26 +19,7 @@ function useCollection(collectionId: string): StacCollectionHook {
1919
const fetchCollection = async (): Promise<Collection> => {
2020
if (!stacApi) throw new Error('No STAC API configured');
2121
const response: Response = await stacApi.getCollection(collectionId);
22-
if (!response.ok) {
23-
let detail;
24-
try {
25-
detail = await response.json();
26-
} catch {
27-
detail = await response.text();
28-
}
29-
30-
throw new ApiError(response.statusText, response.status, detail, response.url);
31-
}
32-
try {
33-
return await response.json();
34-
} catch (error) {
35-
throw new ApiError(
36-
'Invalid JSON Response',
37-
response.status,
38-
`Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
39-
response.url
40-
);
41-
}
22+
return handleStacResponse<Collection>(response);
4223
};
4324

4425
const {

src/hooks/useCollections.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useQuery, type QueryObserverResult } from '@tanstack/react-query';
22
import { type ApiErrorType } from '../types';
33
import type { CollectionsResponse } from '../types/stac';
4-
import { ApiError } from '../utils/ApiError';
4+
import { handleStacResponse } from '../utils/handleStacResponse';
55
import { generateCollectionsQueryKey } from '../utils/queryKeys';
66
import { useStacApiContext } from '../context/useStacApiContext';
77

@@ -19,26 +19,7 @@ function useCollections(): StacCollectionsHook {
1919
const fetchCollections = async (): Promise<CollectionsResponse> => {
2020
if (!stacApi) throw new Error('No STAC API configured');
2121
const response: Response = await stacApi.getCollections();
22-
if (!response.ok) {
23-
let detail;
24-
try {
25-
detail = await response.json();
26-
} catch {
27-
detail = await response.text();
28-
}
29-
30-
throw new ApiError(response.statusText, response.status, detail, response.url);
31-
}
32-
try {
33-
return await response.json();
34-
} catch (error) {
35-
throw new ApiError(
36-
'Invalid JSON Response',
37-
response.status,
38-
`Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
39-
response.url
40-
);
41-
}
22+
return handleStacResponse<CollectionsResponse>(response);
4223
};
4324

4425
const {

src/hooks/useItem.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useQuery, type QueryObserverResult } from '@tanstack/react-query';
22
import { Item } from '../types/stac';
33
import { type ApiErrorType } from '../types';
44
import { useStacApiContext } from '../context/useStacApiContext';
5-
import { ApiError } from '../utils/ApiError';
5+
import { handleStacResponse } from '../utils/handleStacResponse';
66
import { generateItemQueryKey } from '../utils/queryKeys';
77

88
type ItemHook = {
@@ -19,26 +19,7 @@ function useItem(url: string): ItemHook {
1919
const fetchItem = async (): Promise<Item> => {
2020
if (!stacApi) throw new Error('No STAC API configured');
2121
const response: Response = await stacApi.get(url);
22-
if (!response.ok) {
23-
let detail;
24-
try {
25-
detail = await response.json();
26-
} catch {
27-
detail = await response.text();
28-
}
29-
30-
throw new ApiError(response.statusText, response.status, detail, response.url);
31-
}
32-
try {
33-
return await response.json();
34-
} catch (error) {
35-
throw new ApiError(
36-
'Invalid JSON Response',
37-
response.status,
38-
`Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
39-
response.url
40-
);
41-
}
22+
return handleStacResponse<Item>(response);
4223
};
4324

4425
const {

src/hooks/useStacSearch.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
33
import debounce from '../utils/debounce';
44
import { generateStacSearchQueryKey } from '../utils/queryKeys';
55
import { type ApiErrorType } from '../types';
6-
import { ApiError } from '../utils/ApiError';
6+
import { handleStacResponse } from '../utils/handleStacResponse';
77
import type {
88
Link,
99
Bbox,
@@ -117,26 +117,7 @@ function useStacSearch(): StacSearchHook {
117117
? await stacApi.search(request.payload, request.headers)
118118
: await stacApi.get(request.url);
119119

120-
if (!response.ok) {
121-
let detail;
122-
try {
123-
detail = await response.json();
124-
} catch {
125-
detail = await response.text();
126-
}
127-
128-
throw new ApiError(response.statusText, response.status, detail, response.url);
129-
}
130-
try {
131-
return await response.json();
132-
} catch (error) {
133-
throw new ApiError(
134-
'Invalid JSON Response',
135-
response.status,
136-
`Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
137-
response.url
138-
);
139-
}
120+
return handleStacResponse<SearchResponse>(response);
140121
};
141122

142123
/**

src/stac-api/StacApi.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import StacApi, { SearchMode } from './index';
2+
import type { SearchRequestPayload } from '../types/stac';
3+
4+
// Mock fetch globally
5+
global.fetch = jest.fn();
6+
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
7+
8+
describe('StacApi', () => {
9+
let stacApi: StacApi;
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
stacApi = new StacApi('https://api.example.com', SearchMode.POST);
14+
});
15+
16+
describe('makeDatetimePayload', () => {
17+
it('should return undefined for undefined dateRange', () => {
18+
const result = stacApi.makeDatetimePayload(undefined);
19+
expect(result).toBeUndefined();
20+
});
21+
22+
it('should return undefined for empty dateRange', () => {
23+
const result = stacApi.makeDatetimePayload({});
24+
expect(result).toBeUndefined();
25+
});
26+
27+
it('should format date range with from and to', () => {
28+
const dateRange = { from: '2025-12-01', to: '2025-12-31' };
29+
const result = stacApi.makeDatetimePayload(dateRange);
30+
// Simple date format for STAC API compatibility
31+
expect(result).toBe('2025-12-01/2025-12-31');
32+
});
33+
34+
it('should format date range with only from', () => {
35+
const dateRange = { from: '2025-12-01' };
36+
const result = stacApi.makeDatetimePayload(dateRange);
37+
expect(result).toBe('2025-12-01/..');
38+
});
39+
40+
it('should format date range with only to', () => {
41+
const dateRange = { to: '2025-12-31' };
42+
const result = stacApi.makeDatetimePayload(dateRange);
43+
expect(result).toBe('../2025-12-31');
44+
});
45+
46+
it('should handle full datetime strings', () => {
47+
const dateRange = {
48+
from: '2025-12-01T00:00:00Z',
49+
to: '2025-12-31T23:59:59Z',
50+
};
51+
const result = stacApi.makeDatetimePayload(dateRange);
52+
expect(result).toBe('2025-12-01T00:00:00Z/2025-12-31T23:59:59Z');
53+
});
54+
});
55+
56+
describe('search payload transformation', () => {
57+
beforeEach(() => {
58+
// Mock successful response
59+
mockFetch.mockResolvedValue({
60+
ok: true,
61+
json: jest.fn().mockResolvedValue({ features: [] }),
62+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
63+
} as any);
64+
});
65+
66+
it('should transform dateRange to datetime string in POST mode', async () => {
67+
const searchPayload: SearchRequestPayload = {
68+
collections: ['sentinel-2-l2a'],
69+
dateRange: { from: '2025-12-01', to: '2025-12-31' },
70+
};
71+
72+
await stacApi.search(searchPayload);
73+
74+
expect(mockFetch).toHaveBeenCalledWith(
75+
'https://api.example.com/search',
76+
expect.objectContaining({
77+
method: 'POST',
78+
headers: expect.objectContaining({
79+
'Content-Type': 'application/json',
80+
}),
81+
body: JSON.stringify({
82+
collections: ['sentinel-2-l2a'],
83+
datetime: '2025-12-01/2025-12-31',
84+
ids: undefined,
85+
bbox: undefined,
86+
}),
87+
})
88+
);
89+
});
90+
91+
it('should transform dateRange to datetime string in GET mode', async () => {
92+
const getStacApi = new StacApi('https://api.example.com', SearchMode.GET);
93+
const searchPayload: SearchRequestPayload = {
94+
collections: ['sentinel-2-l2a'],
95+
dateRange: { from: '2025-12-01', to: '2025-12-31' },
96+
};
97+
98+
await getStacApi.search(searchPayload);
99+
100+
expect(mockFetch).toHaveBeenCalledWith(
101+
'https://api.example.com/search?collections=sentinel-2-l2a&datetime=2025-12-01%2F2025-12-31',
102+
expect.objectContaining({
103+
method: 'GET',
104+
headers: expect.objectContaining({
105+
'Content-Type': 'application/json',
106+
}),
107+
})
108+
);
109+
});
110+
111+
it('should not include undefined values in POST payload', async () => {
112+
const searchPayload: SearchRequestPayload = {
113+
collections: ['sentinel-2-l2a'],
114+
};
115+
116+
await stacApi.search(searchPayload);
117+
118+
expect(mockFetch).toHaveBeenCalledWith(
119+
'https://api.example.com/search',
120+
expect.objectContaining({
121+
body: JSON.stringify({
122+
collections: ['sentinel-2-l2a'],
123+
ids: undefined,
124+
bbox: undefined,
125+
datetime: undefined,
126+
}),
127+
})
128+
);
129+
});
130+
131+
it('should not include undefined values in GET query string', async () => {
132+
const getStacApi = new StacApi('https://api.example.com', SearchMode.GET);
133+
const searchPayload: SearchRequestPayload = {
134+
collections: ['sentinel-2-l2a'],
135+
};
136+
137+
await getStacApi.search(searchPayload);
138+
139+
const expectedUrl = 'https://api.example.com/search?collections=sentinel-2-l2a';
140+
expect(mockFetch).toHaveBeenCalledWith(
141+
expectedUrl,
142+
expect.objectContaining({
143+
method: 'GET',
144+
})
145+
);
146+
});
147+
});
148+
149+
describe('payloadToQuery', () => {
150+
it('should convert arrays to comma-separated strings', () => {
151+
const payload = {
152+
collections: ['col1', 'col2'],
153+
ids: ['id1', 'id2', 'id3'],
154+
};
155+
const result = stacApi.payloadToQuery(payload);
156+
expect(result).toBe('collections=col1%2Ccol2&ids=id1%2Cid2%2Cid3');
157+
});
158+
159+
it('should convert primitive values to strings', () => {
160+
const payload = {
161+
collections: ['col1'],
162+
datetime: '2025-12-01T00:00:00Z/2025-12-31T23:59:59Z',
163+
};
164+
const result = stacApi.payloadToQuery(payload);
165+
expect(result).toBe(
166+
'collections=col1&datetime=2025-12-01T00%3A00%3A00Z%2F2025-12-31T23%3A59%3A59Z'
167+
);
168+
});
169+
170+
it('should skip undefined values', () => {
171+
const payload = {
172+
collections: ['col1'],
173+
ids: undefined,
174+
datetime: '2025-12-01T00:00:00Z/2025-12-31T23:59:59Z',
175+
};
176+
const result = stacApi.payloadToQuery(payload);
177+
expect(result).toBe(
178+
'collections=col1&datetime=2025-12-01T00%3A00%3A00Z%2F2025-12-31T23%3A59%3A59Z'
179+
);
180+
});
181+
182+
it('should handle sortby parameter', () => {
183+
const payload = {
184+
collections: ['col1'],
185+
sortby: [
186+
{ field: 'datetime', direction: 'desc' as const },
187+
{ field: 'id', direction: 'asc' as const },
188+
],
189+
};
190+
const result = stacApi.payloadToQuery(payload);
191+
expect(result).toBe('collections=col1&sortby=-datetime%2C%2Bid');
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)