observation-js
Version:
A fully-typed TypeScript client for the waarneming.nl API.
434 lines (433 loc) • 16.4 kB
JavaScript
import { Badges } from '../lib/badges';
import { Challenges } from '../lib/challenges';
import { Countries } from '../lib/countries';
import { Exports } from '../lib/exports';
import { Groups } from '../lib/groups';
import { Languages } from '../lib/languages';
import { Locations } from '../lib/locations';
import { Lookups } from '../lib/lookups';
import { Media } from '../lib/media';
import { Nia } from '../lib/nia';
import { Observations } from '../lib/observations';
import { Regions } from '../lib/regions';
import { RegionSpeciesLists } from '../lib/regionSpeciesLists';
import { Sessions } from '../lib/sessions';
import { Species } from '../lib/species';
import { Users } from '../lib/users';
import { ApiError, AuthenticationError, RateLimitError } from './errors';
import { InMemoryCache } from './cache';
import { InterceptorManager } from './interceptors';
import { RefreshTokenInterceptor } from './refresh-interceptor';
const API_BASE_URL = '/api/v1';
const platformBaseUrls = {
nl: {
production: 'https://waarneming.nl',
test: 'https://waarneming-test.nl',
},
be: {
production: 'https://waarnemingen.be',
test: 'https://waarnemingen-test.be',
},
org: {
production: 'https://observation.org',
test: 'https://observation-test.org',
},
};
export class ObservationClient {
#accessToken = null;
#refreshToken = null;
#options;
#language = 'en'; // Default to English
#baseUrl;
#cache;
observations;
species;
regions;
locations;
regionSpeciesLists;
users;
countries;
badges;
groups;
exports;
languages;
lookups;
nia;
media;
sessions;
challenges;
interceptors;
/**
* The main client for interacting with the Waarneming.nl API.
*
* @param options - Configuration options for the client.
*/
constructor(options) {
this.#options = options;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager(),
};
this.#cache = options?.cache?.store ?? new InMemoryCache();
if (options?.baseUrl) {
this.#baseUrl = options.baseUrl;
}
else if (options?.platform) {
this.#baseUrl =
platformBaseUrls[options.platform][options.test === false ? 'production' : 'test'];
}
else {
// Default to the test environment for the default platform 'nl'
this.#baseUrl = platformBaseUrls['nl']['test'];
}
this.observations = new Observations(this);
this.species = new Species(this);
this.regions = new Regions(this);
this.locations = new Locations(this);
this.regionSpeciesLists = new RegionSpeciesLists(this);
this.users = new Users(this);
this.countries = new Countries(this);
this.badges = new Badges(this);
this.groups = new Groups(this);
this.exports = new Exports(this);
this.languages = new Languages(this);
this.lookups = new Lookups(this);
this.nia = new Nia(this);
this.media = new Media(this);
this.sessions = new Sessions(this);
this.challenges = new Challenges(this);
// Set up automatic token refresh if enabled (default: true)
if (options?.autoRefreshToken !== false) {
const refreshInterceptor = new RefreshTokenInterceptor(this);
this.interceptors.response.use((response) => response, refreshInterceptor.createResponseErrorHandler());
}
}
/**
* Sets the language for the `Accept-Language` header in all subsequent API requests.
* The default language is 'en'.
*
* @param language - The two-letter language code (e.g., 'nl', 'en', 'de').
*/
setLanguage(language) {
this.#language = language;
}
/**
* Gets the base URL for the API.
* @returns The base URL.
* @internal
*/
getApiBaseUrl() {
return `${this.#baseUrl}${API_BASE_URL}`;
}
/**
* Generates the authorization URL for the OAuth2 Authorization Code Grant flow.
* The user should be redirected to this URL to authorize the application.
*
* @param state - A random string to protect against CSRF attacks.
* @param scope - An array of scopes the application is requesting.
* @returns The full authorization URL to redirect the user to.
* @throws {Error} If the client options (clientId, redirectUri) are not configured.
*/
getAuthorizationUrl(state, scope) {
if (!this.#options) {
throw new Error('Client options are not set.');
}
const urlParams = new URLSearchParams();
urlParams.set('response_type', 'code');
urlParams.set('client_id', this.#options.clientId || '');
urlParams.set('redirect_uri', this.#options.redirectUri || '');
urlParams.set('scope', scope.join(' '));
urlParams.set('state', state);
return `${this.#baseUrl}/api/v1/oauth2/authorize/?${urlParams.toString()}`;
}
/**
* Exchanges an authorization code for an access token using the Authorization Code Grant flow.
*
* @param code - The authorization code received from the callback URL after user authorization.
* @returns A promise that resolves to the token response from the API.
* @throws {AuthenticationError} If the token request fails.
* @throws {Error} If the client options are not configured.
*/
async getAccessToken(code) {
if (!this.#options) {
throw new Error('Client options are not set.');
}
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('redirect_uri', this.#options.redirectUri || '');
body.set('client_id', this.#options.clientId || '');
body.set('client_secret', this.#options.clientSecret || '');
const response = await fetch(`${this.#baseUrl}/api/v1/oauth2/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
});
if (!response.ok) {
const errorBody = await response.json();
throw new AuthenticationError(response, errorBody);
}
const tokenData = (await response.json());
this.#accessToken = tokenData.access_token;
this.#refreshToken = tokenData.refresh_token;
return tokenData;
}
/**
* Fetches an access token using the Resource Owner Password Credentials Grant.
* Use this grant type only for trusted applications.
*
* @param options - The credentials for the password grant.
* @returns A promise that resolves to the token response.
* @throws {AuthenticationError} If the token request fails.
*/
async getAccessTokenWithPassword(options) {
const body = new URLSearchParams();
body.set('grant_type', 'password');
body.set('client_id', options.clientId);
body.set('username', options.email);
body.set('password', options.password);
if (options.clientSecret) {
body.set('client_secret', options.clientSecret);
}
const response = await fetch(`${this.#baseUrl}/api/v1/oauth2/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
});
if (!response.ok) {
const errorText = await response.text();
let errorBody = null;
try {
errorBody = errorText ? JSON.parse(errorText) : null;
}
catch {
errorBody = errorText;
}
throw new AuthenticationError(response, errorBody);
}
const tokenData = (await response.json());
this.#accessToken = tokenData.access_token;
this.#refreshToken = tokenData.refresh_token;
return tokenData;
}
/**
* Refreshes an expired access token using a refresh token.
*
* @returns A promise that resolves to the new token response.
* @throws {AuthenticationError} If the refresh token request fails.
* @throws {Error} If the refresh token or client options are not available.
*/
async refreshAccessToken() {
if (!this.#refreshToken) {
throw new Error('No refresh token available.');
}
if (!this.#options) {
throw new Error('Client options are not set.');
}
const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', this.#refreshToken);
body.set('client_id', this.#options.clientId || '');
body.set('client_secret', this.#options.clientSecret || '');
const response = await fetch(`${this.#baseUrl}/api/v1/oauth2/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
});
if (!response.ok) {
const errorBody = await response.json();
throw new AuthenticationError(response, errorBody);
}
const tokenData = (await response.json());
this.#accessToken = tokenData.access_token;
this.#refreshToken = tokenData.refresh_token;
return tokenData;
}
/**
* Manually sets the access token for the client to use in subsequent authenticated requests.
*
* @param token - The access token.
*/
setAccessToken(token) {
this.#accessToken = token;
}
/**
* Manually sets the refresh token for the client.
*
* @param token - The refresh token.
* @internal
*/
setRefreshToken(token) {
this.#refreshToken = token;
}
/**
* Checks if an access token is currently set on the client.
*
* @returns `true` if an access token is set, `false` otherwise.
*/
hasAccessToken() {
return this.#accessToken !== null;
}
/**
* Checks if a refresh token is currently set on the client.
*
* @returns `true` if a refresh token is set, `false` otherwise.
*/
hasRefreshToken() {
return this.#refreshToken !== null;
}
/**
* Gets the current access token.
*
* @returns The access token or null if not set.
* @internal
*/
getCurrentAccessToken() {
return this.#accessToken;
}
async _fetch(endpoint, authenticate, options = {}) {
// --- Caching Layer: GET ---
const url = this.buildUrl(endpoint, options.params);
const cacheKey = url.toString();
const isCacheable = (options.method === 'GET' || !options.method) &&
this.#options?.cache?.enabled !== false;
if (isCacheable && this.#cache.has(cacheKey)) {
const cachedData = this.#cache.get(cacheKey);
if (cachedData)
return cachedData;
}
// --- Interceptor Chain ---
const chain = [
this._coreRequestAndHandle.bind(this, url, authenticate),
undefined,
];
this.interceptors.request.forEach((interceptor) => {
chain.unshift(interceptor.onFulfilled, interceptor.onRejected);
});
this.interceptors.response.forEach((interceptor) => {
chain.push(interceptor.onFulfilled, interceptor.onRejected);
});
let promise = Promise.resolve(options);
while (chain.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
promise = promise.then(chain.shift(), chain.shift());
}
// --- Final Response Handling & Caching Layer: SET ---
return promise
.then((data) => {
const typedData = data;
if (isCacheable && options.clientCache) {
const cacheOptions = this.#options?.cache;
const defaultTTL = cacheOptions?.defaultTTL ?? 3600;
let ttl;
if (typeof options.clientCache === 'object' &&
options.clientCache.ttl) {
ttl = options.clientCache.ttl;
}
else if (options.clientCache === true) {
ttl = defaultTTL;
}
if (ttl) {
this.#cache.set(cacheKey, typedData, ttl);
}
}
return typedData;
});
}
buildUrl(endpoint, params) {
let url;
if (endpoint.startsWith('http') || endpoint.startsWith('/api/')) {
url = new URL(endpoint.startsWith('/') ? `${this.#baseUrl}${endpoint}` : endpoint);
}
else {
url = new URL(`${this.getApiBaseUrl()}/${endpoint}`);
}
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.append(key, String(value));
}
}
return url;
}
async _coreRequestAndHandle(url, authenticate, options) {
const response = await this._coreRequest(url, authenticate, options);
return this._handleResponse(response);
}
async _coreRequest(url, authenticate, options) {
if (authenticate && !this.#accessToken) {
throw new Error('Access token is not set. Please authenticate first.');
}
// params are already in the URL, so we can delete them from options
delete options.params;
// The `clientCache` property is for the internal cache, not for the fetch API
delete options.clientCache;
const headers = new Headers(options.headers);
if (authenticate) {
headers.set('Authorization', `Bearer ${this.#accessToken}`);
}
headers.set('Accept-Language', this.#language);
headers.set('Accept', 'application/json');
const fetchOptions = {
...options,
headers,
};
const response = await fetch(url.toString(), fetchOptions);
// Attach the original config to the response for the interceptor
response.config = {
...fetchOptions,
url: url.toString(),
};
return response;
}
async _handleResponse(response) {
if (!response.ok) {
const body = await response.text();
let errorBody = null;
try {
errorBody = body ? JSON.parse(body) : null;
}
catch {
errorBody = body;
}
if (response.status === 401 || response.status === 403) {
throw new AuthenticationError(response, errorBody);
}
if (response.status === 429) {
throw new RateLimitError(response, errorBody);
}
throw new ApiError(`API request failed with status ${response.status}`, response, errorBody);
}
const text = await response.text();
return text ? JSON.parse(text) : {};
}
/**
* Makes an authenticated request to the API.
* An access token must be set via `setAccessToken` or by using one of the authentication flows.
*
* @param endpoint - The API endpoint to request.
* @param options - Optional request options, including URL parameters.
* @returns A promise that resolves to the JSON response.
* @throws {AuthenticationError} If the access token is not set or the request is unauthorized.
* @throws {ApiError} If the API request fails for other reasons.
*/
request = async (endpoint, options = {}) => {
return this._fetch(endpoint, true, options);
};
/**
* Makes a public (unauthenticated) request to the API.
*
* @param endpoint - The API endpoint to request.
* @param options - Optional request options, including URL parameters.
* @returns A promise that resolves to the JSON response.
* @throws {ApiError} If the API request fails.
*/
publicRequest = async (endpoint, options = {}) => {
return this._fetch(endpoint, false, options);
};
}