UNPKG

observation-js

Version:

A fully-typed TypeScript client for the waarneming.nl API.

434 lines (433 loc) 16.4 kB
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); }; }