@opendatasoft/api-client
Version:
Opendatasoft's API Client.
187 lines (161 loc) • 6.27 kB
text/typescript
/* eslint-disable no-restricted-globals */
/* eslint-disable no-nested-ternary */
import update from 'immutability-helper';
import { Query } from '../odsql';
import { AuthenticationError, NotFoundError, ServerError, UserError } from './error';
const API_KEY_AUTH_TYPE = 'ApiKey';
// Using the UMD bundle in ObservableHQ, the error "ReferenceError: global is not defined" is returned.
// ... I'm not sure why it behaves that way, but this fixes the issue:
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any
const _global: any =
typeof global !== 'undefined'
? global
: typeof self !== 'undefined'
? self
: typeof window !== 'undefined'
? window
: {};
export type RequestInterceptor = (request: Request) => Promise<Request>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ResponseInterceptor = (response: Response) => Promise<any>;
export interface ApiClientOptions {
domain?: string;
apiKey?: string;
fetch?: WindowOrWorkerGlobalScope['fetch'];
headers?: HeadersInit;
interceptRequest?: RequestInterceptor;
interceptResponse?: ResponseInterceptor;
hideDeprecatedWarning?: boolean;
}
export interface ApiClientConfiguration {
baseUrl: string;
apiKey?: string;
fetch: WindowOrWorkerGlobalScope['fetch'];
headers?: Headers;
interceptRequest?: RequestInterceptor;
interceptResponse?: ResponseInterceptor;
hideDeprecatedWarning?: boolean;
}
function computeBaseUrl(domain: string): string {
let baseUrl;
if (domain.startsWith('http://') || domain.startsWith('https://')) {
baseUrl = domain;
} else {
baseUrl = `https://${domain}.opendatasoft.com`;
}
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
baseUrl += 'api/explore/v2.1/';
return baseUrl;
}
function buildConfig(
apiClientOptions: ApiClientOptions | undefined,
defaultConfig: ApiClientConfiguration
): ApiClientConfiguration {
if (!apiClientOptions) {
return defaultConfig;
}
const {
domain,
fetch,
apiKey,
headers,
interceptRequest,
interceptResponse,
hideDeprecatedWarning,
} = apiClientOptions;
const newConfig: Partial<ApiClientConfiguration> = {};
if (domain) newConfig.baseUrl = computeBaseUrl(domain);
if (apiKey) newConfig.apiKey = apiKey;
if (fetch) newConfig.fetch = fetch;
if (headers) newConfig.headers = new Headers(headers);
if (interceptRequest) newConfig.interceptRequest = interceptRequest;
if (interceptResponse) newConfig.interceptResponse = interceptResponse;
newConfig.hideDeprecatedWarning = Boolean(hideDeprecatedWarning);
return update(defaultConfig, { $merge: newConfig });
}
export class ApiClient {
readonly defaultConfig: ApiClientConfiguration;
deprecatedWarningShown: string[] = [];
/**
* Constructs an instance of {@link ApiClient}
*/
constructor(options?: ApiClientOptions) {
const fetch = options?.fetch || _global?.fetch;
if (!fetch) {
throw new Error(
'fetch() was not found, try installing a polyfill in the browser or node-fetch in nodejs. You can pass fetch as an option to the api client.'
);
}
const domain = options?.domain || _global?.location?.origin;
if (!domain) {
throw new Error('Missing domain');
}
this.defaultConfig = buildConfig(options, {
baseUrl: computeBaseUrl(domain),
fetch,
});
}
async get<T>(query: string | Query, options?: ApiClientOptions): Promise<T> {
const config = buildConfig(options, this.defaultConfig);
// Build the URL
let path = typeof query === 'string' ? query : query.toString();
if (path.startsWith('/')) path = path.slice(1);
const url = new URL(path, config.baseUrl);
url.searchParams.sort(); // Url stability is HTTP cache friendly
// Build the Headers
const headers = config.headers || new Headers();
headers.append('Accept', 'application/json');
if (config.apiKey) {
headers.append('Authorization', `${API_KEY_AUTH_TYPE} ${config.apiKey}`);
}
// Build Request
let request = new Request(url.toString(), {
method: 'GET',
headers,
credentials: 'same-origin',
});
if (config.interceptRequest) request = await config.interceptRequest(request);
// Send request
const { fetch } = config;
const fetchResponse = await fetch(request);
if (!options?.hideDeprecatedWarning) {
const msg = fetchResponse.headers.get('Ods-Explore-Api-Deprecation');
if (msg !== null && !this.deprecatedWarningShown.includes(msg)) {
this.deprecatedWarningShown.push(msg);
// eslint-disable-next-line no-console
console.warn(`@opendatasoft/api-client : ${msg}`);
}
}
if (config.interceptResponse) return config.interceptResponse(fetchResponse);
if (fetchResponse.ok) {
const data = await fetchResponse.json();
return data;
}
let errorData;
const response = await fetchResponse.text();
if (response) {
try {
errorData = JSON.parse(response);
} catch (e) {
// Ignore
}
}
if (!errorData && (response || fetchResponse.statusText)) {
errorData = {
message: response || fetchResponse.statusText,
};
}
if (fetchResponse.status === 401) {
throw new AuthenticationError(fetchResponse, errorData || 'authentication-error');
}
if (fetchResponse.status === 404) {
throw new NotFoundError(fetchResponse, errorData || 'not-found');
}
if (fetchResponse.status < 500) {
throw new UserError(fetchResponse, errorData || 'user-error');
}
throw new ServerError(fetchResponse, errorData || 'server-error');
}
}