Skip to content

Commit dafe115

Browse files
committed
Refactor H5GroveApi with Fetcher abstraction
1 parent dd3f582 commit dafe115

4 files changed

Lines changed: 146 additions & 93 deletions

File tree

packages/app/src/providers/h5grove/h5grove-api.ts

Lines changed: 72 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,11 @@ import {
1616
type ExportFormat,
1717
type ExportURL,
1818
} from '@h5web/shared/vis-models';
19-
import axios, {
20-
AxiosError,
21-
type AxiosInstance,
22-
type AxiosRequestConfig,
23-
} from 'axios';
19+
import axios, { type AxiosRequestConfig } from 'axios';
2420

2521
import { DataProviderApi } from '../api';
26-
import { type ValuesStoreParams } from '../models';
27-
import { AbortError, createAxiosProgressHandler } from '../utils';
22+
import { type Fetcher, type ValuesStoreParams } from '../models';
23+
import { createAxiosFetcher, FetcherError, toJSON } from '../utils';
2824
import {
2925
type H5GroveAttrValuesResponse,
3026
type H5GroveDataResponse,
@@ -33,29 +29,31 @@ import {
3329
} from './models';
3430
import {
3531
h5groveTypedArrayFromDType,
36-
isH5GroveError,
32+
isH5GroveErrorResponse,
3733
parseEntity,
3834
} from './utils';
3935

4036
const SUPPORTED_EXPORT_FORMATS = new Set<ExportFormat>(['npy', 'tiff']);
4137

4238
export class H5GroveApi extends DataProviderApi {
43-
private readonly client: AxiosInstance;
39+
private readonly fetcher: Fetcher;
4440

4541
/* API compatible with h5grove@2.3.0 */
4642
public constructor(
47-
url: string,
43+
private readonly baseURL: string,
4844
filepath: string,
4945
axiosConfig?: AxiosRequestConfig,
5046
private readonly _getExportURL?: DataProviderApi['getExportURL'],
5147
) {
5248
super(filepath);
5349

54-
this.client = axios.create({
55-
adapter: 'fetch',
56-
baseURL: url,
57-
...axiosConfig,
58-
});
50+
this.fetcher = createAxiosFetcher(
51+
axios.create({
52+
adapter: 'fetch',
53+
baseURL,
54+
...axiosConfig,
55+
}),
56+
);
5957
}
6058

6159
public override async getEntity(path: string): Promise<ProvidedEntity> {
@@ -70,33 +68,25 @@ export class H5GroveApi extends DataProviderApi {
7068
): Promise<H5GroveDataResponse> {
7169
const { dataset } = params;
7270

73-
try {
74-
if (dataset.type.class === DTypeClass.Opaque) {
75-
return new Uint8Array(
76-
await this.fetchBinaryData(params, abortSignal, onProgress),
77-
);
78-
}
79-
80-
const DTypedArray = h5groveTypedArrayFromDType(dataset.type);
81-
if (DTypedArray) {
82-
const buffer = await this.fetchBinaryData(
83-
params,
84-
abortSignal,
85-
onProgress,
86-
true,
87-
);
88-
const array = new DTypedArray(buffer);
89-
return hasScalarShape(dataset) ? array[0] : array;
90-
}
91-
92-
return await this.fetchData(params, abortSignal, onProgress);
93-
} catch (error) {
94-
if (error instanceof AxiosError && axios.isCancel(error)) {
95-
throw new AbortError(abortSignal, error);
96-
}
71+
if (dataset.type.class === DTypeClass.Opaque) {
72+
return new Uint8Array(
73+
await this.fetchBinaryData(params, abortSignal, onProgress),
74+
);
75+
}
9776

98-
throw error;
77+
const DTypedArray = h5groveTypedArrayFromDType(dataset.type);
78+
if (DTypedArray) {
79+
const buffer = await this.fetchBinaryData(
80+
params,
81+
abortSignal,
82+
onProgress,
83+
true,
84+
);
85+
const array = new DTypedArray(buffer);
86+
return hasScalarShape(dataset) ? array[0] : array;
9987
}
88+
89+
return await this.fetchData(params, abortSignal, onProgress);
10090
}
10191

10292
public override async getAttrValues(
@@ -135,42 +125,36 @@ export class H5GroveApi extends DataProviderApi {
135125
return undefined;
136126
}
137127

138-
const { baseURL, params } = this.client.defaults;
139-
140-
const searchParams = new URLSearchParams(params as Record<string, string>);
141-
searchParams.set('path', dataset.path);
142-
searchParams.set('format', format);
143-
144-
if (selection) {
145-
searchParams.set('selection', selection);
146-
}
128+
const searchParams = new URLSearchParams({
129+
file: this.filepath,
130+
path: dataset.path,
131+
format,
132+
...(selection && { selection }),
133+
});
147134

148-
return new URL(`${baseURL || ''}/data/?${searchParams.toString()}`);
135+
return new URL(`${this.baseURL || ''}/data/?${searchParams.toString()}`);
149136
}
150137

151138
public override async getSearchablePaths(path: string): Promise<string[]> {
152-
const { data } = await this.client.get<H5GrovePathsResponse>(`/paths/`, {
153-
params: { path },
154-
});
155-
156-
return data;
139+
const buffer = await this.fetcher(`${this.baseURL}/paths/`, { path });
140+
return toJSON(buffer) as H5GrovePathsResponse;
157141
}
158142

159143
private async fetchEntity(path: string): Promise<H5GroveEntityResponse> {
160144
try {
161-
const { data } = await this.client.get<H5GroveEntityResponse>(`/meta/`, {
162-
params: { path },
163-
});
164-
return data;
145+
const buffer = await this.fetcher(`${this.baseURL}/meta/`, { path });
146+
return toJSON(buffer) as H5GroveEntityResponse;
165147
} catch (error) {
166-
if (
167-
!(error instanceof AxiosError) ||
168-
!isH5GroveError(error.response?.data)
169-
) {
148+
if (!(error instanceof FetcherError)) {
149+
throw error;
150+
}
151+
152+
const payload = toJSON(error.buffer);
153+
if (!isH5GroveErrorResponse(payload)) {
170154
throw error;
171155
}
172156

173-
const { message } = error.response.data;
157+
const { message } = payload;
174158
if (message.includes('File not found')) {
175159
throw new Error(`File not found: '${this.filepath}'`, { cause: error });
176160
}
@@ -196,28 +180,28 @@ export class H5GroveApi extends DataProviderApi {
196180
private async fetchAttrValues(
197181
path: string,
198182
): Promise<H5GroveAttrValuesResponse> {
199-
const { data } = await this.client.get<H5GroveAttrValuesResponse>(
200-
`/attr/`,
201-
{ params: { path } },
202-
);
203-
return data;
183+
const buffer = await this.fetcher(`${this.baseURL}/attr/`, { path });
184+
return toJSON(buffer) as H5GroveAttrValuesResponse;
204185
}
205186

206187
private async fetchData(
207188
params: ValuesStoreParams,
208189
abortSignal: AbortSignal | undefined,
209190
onProgress: OnProgress | undefined,
210191
): Promise<H5GroveDataResponse> {
211-
const { data } = await this.client.get<H5GroveDataResponse>('/data/', {
212-
params: {
213-
path: params.dataset.path,
214-
selection: params.selection,
215-
flatten: true,
192+
const { dataset, selection } = params;
193+
194+
const buffer = await this.fetcher(
195+
`${this.baseURL}/data/`,
196+
{
197+
path: dataset.path,
198+
...(selection && { selection }),
199+
flatten: 'true',
216200
},
217-
signal: abortSignal,
218-
onDownloadProgress: createAxiosProgressHandler(onProgress),
219-
});
220-
return data;
201+
{ abortSignal, onProgress },
202+
);
203+
204+
return toJSON(buffer);
221205
}
222206

223207
private async fetchBinaryData(
@@ -226,17 +210,17 @@ export class H5GroveApi extends DataProviderApi {
226210
onProgress: OnProgress | undefined,
227211
safe = false,
228212
): Promise<ArrayBuffer> {
229-
const { data } = await this.client.get<ArrayBuffer>('/data/', {
230-
responseType: 'arraybuffer',
231-
params: {
232-
path: params.dataset.path,
233-
selection: params.selection,
213+
const { dataset, selection } = params;
214+
215+
return this.fetcher(
216+
`${this.baseURL}/data/`,
217+
{
218+
path: dataset.path,
219+
...(selection && { selection }),
234220
format: 'bin',
235-
dtype: safe ? 'safe' : undefined,
221+
...(safe && { dtype: 'safe' }),
236222
},
237-
signal: abortSignal,
238-
onDownloadProgress: createAxiosProgressHandler(onProgress),
239-
});
240-
return data;
223+
{ abortSignal, onProgress },
224+
);
241225
}
242226
}

packages/app/src/providers/h5grove/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ function parseAttributes(attrsMetadata: H5GroveAttribute[]): Attribute[] {
150150
}));
151151
}
152152

153-
export function isH5GroveError(
153+
export function isH5GroveErrorResponse(
154154
payload: unknown,
155155
): payload is H5GroveErrorResponse {
156156
return (

packages/app/src/providers/models.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
type ProvidedEntity,
77
type ScalarShape,
88
} from '@h5web/shared/hdf5-models';
9-
import { type FetchStore } from '@h5web/shared/react-suspense-fetch';
9+
import {
10+
type FetchStore,
11+
type OnProgress,
12+
} from '@h5web/shared/react-suspense-fetch';
1013

1114
import { type NxAttribute } from '../vis-packs/nexus/models';
1215

@@ -26,3 +29,14 @@ export type ImageAttribute = 'CLASS' | 'IMAGE_SUBCLASS';
2629
export type AttrName = NxAttribute | ImageAttribute | '_FillValue';
2730

2831
export type ProgressCallback = (prog: number[]) => void;
32+
33+
export type Fetcher = (
34+
url: string,
35+
params: Record<string, string>,
36+
opts?: FetcherOptions,
37+
) => Promise<ArrayBuffer>;
38+
39+
export interface FetcherOptions {
40+
abortSignal?: AbortSignal;
41+
onProgress?: OnProgress;
42+
}

packages/app/src/providers/utils.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-classes-per-file */
12
import {
23
isBoolType,
34
isEnumType,
@@ -15,9 +16,15 @@ import {
1516
type BigIntTypedArrayConstructor,
1617
type TypedArrayConstructor,
1718
} from '@h5web/shared/vis-models';
18-
import { type AxiosProgressEvent, isAxiosError } from 'axios';
19+
import {
20+
type AxiosInstance,
21+
type AxiosProgressEvent,
22+
isAxiosError,
23+
isCancel,
24+
} from 'axios';
1925

2026
import { type DataProviderApi } from './api';
27+
import { type Fetcher, type FetcherOptions } from './models';
2128

2229
export function typedArrayFromDType(
2330
dtype: DType,
@@ -88,6 +95,11 @@ export async function getValueOrError(
8895
}
8996
}
9097

98+
export function toJSON(buffer: ArrayBuffer): unknown {
99+
const str = new TextDecoder().decode(buffer);
100+
return str === '' ? {} : JSON.parse(str);
101+
}
102+
91103
export function createAxiosProgressHandler(
92104
onProgress: OnProgress | undefined,
93105
): ((evt: AxiosProgressEvent) => void) | undefined {
@@ -101,12 +113,55 @@ export function createAxiosProgressHandler(
101113
);
102114
}
103115

116+
export function createAxiosFetcher(axiosInstance: AxiosInstance): Fetcher {
117+
return async (
118+
url: string,
119+
params: Record<string, string>,
120+
opts: FetcherOptions = {},
121+
): Promise<ArrayBuffer> => {
122+
const { abortSignal, onProgress } = opts;
123+
124+
try {
125+
const { data } = await axiosInstance.get<ArrayBuffer>(url, {
126+
responseType: 'arraybuffer',
127+
params: { ...axiosInstance.defaults.params, ...params },
128+
signal: abortSignal,
129+
onDownloadProgress: createAxiosProgressHandler(onProgress),
130+
});
131+
return data;
132+
} catch (error) {
133+
if (isCancel(error)) {
134+
throw new AbortError(abortSignal, error);
135+
}
136+
137+
if (isAxiosError<ArrayBuffer>(error) && error.response) {
138+
const { status, statusText, data } = error.response;
139+
throw new FetcherError(status, statusText, data, error);
140+
}
141+
142+
throw error;
143+
}
144+
};
145+
}
146+
104147
export class AbortError extends Error {
105148
public constructor(abortSignal?: AbortSignal, cause?: unknown) {
106-
const message =
149+
const reason =
107150
typeof abortSignal?.reason === 'string' ? abortSignal.reason : undefined;
108151

109-
super(message, { cause });
152+
super(`Request aborted: ${reason}`, { cause });
110153
this.name = 'AbortError';
111154
}
112155
}
156+
157+
export class FetcherError extends Error {
158+
public constructor(
159+
public readonly status: number,
160+
public readonly statusText: string,
161+
public readonly buffer: ArrayBuffer,
162+
cause?: unknown,
163+
) {
164+
super(`Request failed: ${status} ${statusText}`, { cause });
165+
this.name = 'FetcherError';
166+
}
167+
}

0 commit comments

Comments
 (0)