diff --git a/CHANGELOG.md b/CHANGELOG.md index fcdc233..22fa4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.0] +### Added +- Options-object API: construct the client with `new IpregistryClient({ apiKey, baseUrl, timeout, maxRetries, cache, ... })` and pass per-call options as a plain object, e.g. `client.lookupIp(ip, { fields: 'location', hostname: true })`. The `baseUrl` option accepts the shorthand `'eu'` for the European Union endpoint. +- Request cancellation: every lookup accepts an `AbortSignal` via `{ signal }`. Aborting cancels the in-flight request, pending retries and their backoff waits, and pending batch chunks. +- `parseUserAgents` accepts an array (`client.parseUserAgents([ua1, ua2])`) in addition to the deprecated variadic form. +- Generic cache typing: `IpregistryCache` and the new `IpregistryCacheValue` type. + +### Changed +- The library is now compiled with full TypeScript strict mode (`noImplicitAny` enabled). + +### Deprecated +- `IpregistryConfigBuilder`: pass an `IpregistryClientOptions` object to the constructor instead. +- `IpregistryOption`, `FilterOption`, `HostnameOption`, `IpregistryOptions` and the variadic lookup signatures: pass a `LookupOptions` object instead. +- The variadic `parseUserAgents(...userAgents)` form: pass an array instead. + +All deprecated forms keep working in 7.x and behave identically (including cache +key compatibility between legacy options and their `LookupOptions` equivalents); +they will be removed in a future major version. + +### Migration +```typescript +// Before (6.x) // After (7.x) +new IpregistryClient( new IpregistryClient({ + new IpregistryConfigBuilder('KEY') apiKey: 'KEY', + .withEuBaseUrl() baseUrl: 'eu', + .withTimeout(10000) cache: new InMemoryCache(), + .build(), timeout: 10000, + new InMemoryCache()) }) + +client.lookupIp(ip, client.lookupIp(ip, { + IpregistryOptions.filter('location'), fields: 'location', + IpregistryOptions.hostname(true)) hostname: true, + }) +``` + ## [6.2.0] - 2026-07-05 ### Added - Automatic splitting of large batch lookups, aligned with the Go client: inputs beyond the API limit (1024 values) are chunked and dispatched with bounded concurrency, preserving input order. Configurable via `withMaxBatchSize` and `withBatchConcurrency` (default 4, set 1 for sequential dispatch). diff --git a/README.md b/README.md index 6d698ee..1be5aa0 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ runtime: - **Bun** — verified continuously. - **Cloudflare Workers** — verified continuously against the real Workers runtime (workerd). Note that retry backoff waits count toward a Worker's - wall-clock duration; on latency-sensitive Workers consider - `withMaxRetries(1)` or a lower `withRetryInterval`. + wall-clock duration; on latency-sensitive Workers consider a lower + `maxRetries` or `retryInterval`. - **Deno** and other web-standard runtimes are expected to work through the same APIs. - **Browsers** — through a bundler (the ESM build is tree-shakable) or a @@ -146,7 +146,7 @@ in subsequent invocations. Caching up to 16384 entries: ```typescript -const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384)); +const client = new IpregistryClient({apiKey: 'YOUR_API_KEY', cache: new InMemoryCache(16384)}); ``` ### Configuring cache max age @@ -154,7 +154,7 @@ const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384)); Caching up to 16384 entries for at most 6 hours: ```typescript -const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384, 3600 * 6 * 1000)); +const client = new IpregistryClient({apiKey: 'YOUR_API_KEY', cache: new InMemoryCache(16384, 3600 * 6 * 1000)}); ``` If your purpose is to re-use a same Ipregistry client instance (and thus share the same cache) for different API keys, @@ -167,7 +167,7 @@ client.config.apiKey = 'YOUR_NEW_API_KEY'; ### Disabling caching ```typescript -const client = new IpregistryClient('YOUR_API_KEY', new NoCache()); +const client = new IpregistryClient('YOUR_API_KEY'); ``` ## Timeouts and retries @@ -180,21 +180,34 @@ retried unless you opt in; when enabled, a `Retry-After` response header takes precedence over the computed backoff. All of it is configurable: ```typescript -const client = new IpregistryClient( - new IpregistryConfigBuilder('YOUR_API_KEY') - .withTimeout(10000) // per-attempt timeout, in milliseconds - .withMaxRetries(2) // 0 disables retries - .withRetryInterval(500) // backoff base, in milliseconds - .withRetryOnServerError(true) // retry 5xx responses (default: true) - .withRetryOnTooManyRequests(true) // retry 429 responses (default: false) - .build() -); +const client = new IpregistryClient({ + apiKey: 'YOUR_API_KEY', + maxRetries: 2, // 0 disables retries + retryInterval: 500, // backoff base, in milliseconds + retryOnServerError: true, // retry 5xx responses (default: true) + retryOnTooManyRequests: true, // retry 429 responses (default: false) + timeout: 10000, // per-attempt timeout, in milliseconds +}); ``` Rate limiting is opt-in per API key on Ipregistry, which is why `retryOnTooManyRequests` defaults to false. Enable it if your key is rate limited. +### Cancelling requests + +Every lookup accepts an `AbortSignal`. Aborting cancels the in-flight request, +any pending retries and their backoff waits, and pending batch chunks: + +```typescript +const controller = new AbortController(); + +const promise = client.lookupIp('73.2.2.2', {signal: controller.signal}); + +// Later, e.g. when the user navigates away: +controller.abort(); +``` + ## Batch lookups `batchLookupIps` and `batchLookupAsns` look up many values in a single call: @@ -210,12 +223,11 @@ Cached entries are served locally; only the remainder is requested. Both knobs are configurable: ```typescript -const client = new IpregistryClient( - new IpregistryConfigBuilder('YOUR_API_KEY') - .withMaxBatchSize(256) // values per request, capped at 1024 - .withBatchConcurrency(1) // sequential dispatch, gentler on rate-limited keys - .build() -); +const client = new IpregistryClient({ + apiKey: 'YOUR_API_KEY', + batchConcurrency: 1, // sequential dispatch, gentler on rate-limited keys + maxBatchSize: 256, // values per request, capped at 1024 +}); ``` ## Enabling hostname lookup @@ -224,7 +236,7 @@ By default, the Ipregistry API does not return information about the hostname a In order to include the hostname value in your API result, you need to enable the feature explicitly: ```typescript -const ipInfo = await client.lookupIp('73.2.2.2', IpregistryOptions.hostname(true)); +const ipInfo = await client.lookupIp('73.2.2.2', {hostname: true}); ``` ## Errors @@ -254,7 +266,7 @@ the cache and no request is made to the Ipregistry API. To save bandwidth and speed up response times, the API allows selecting fields to return: ```typescript -const response = await client.lookupIp('73.2.2.2', IpregistryOptions.filter('hostname,location.country.name')); +const response = await client.lookupIp('73.2.2.2', {fields: 'hostname,location.country.name'}); ``` ## Usage data @@ -278,11 +290,10 @@ console.log(response.throttling.reset); ## European Union base URL For clients operating within the European Union or for those who prefer to route their requests through our EU -servers, the Ipregistry client library provides an easy way to configure this preference using the `withEuBaseURL` option. This ensures that your requests are handled by our EU-based infrastructure, potentially reducing latency and aligning with local data handling regulations: +servers, the Ipregistry client library provides an easy way to configure this preference using the `baseUrl` option. This ensures that your requests are handled by our EU-based infrastructure, potentially reducing latency and aligning with local data handling regulations: ```typescript -const config = new IpregistryConfigBuilder('tryout').withEuBaseUrl().build() -const client = new IpregistryClient(config) +const client = new IpregistryClient({apiKey: 'tryout', baseUrl: 'eu'}) ``` # Other Libraries diff --git a/package.json b/package.json index 44ff2c3..3f04571 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ipregistry/client", "description": "Official Ipregistry Javascript Library.", - "version": "6.2.0", + "version": "7.0.0", "browser": "./dist/index.global.js", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -70,6 +70,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@swc/core": "^1.15.43", + "@types/chai": "^5.2.3", "@types/node": "^26.1.0", "chai": "^6.2.2", "eslint": "^10.6.0", diff --git a/src/cache.ts b/src/cache.ts index 8cc32bc..010af48 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -14,21 +14,26 @@ * limitations under the License. */ -import { IpInfo } from './model.js' +import { AutonomousSystem, IpInfo } from './model.js' -export interface IpregistryCache { - get(key: string): any | undefined +/** + * The union of value types the Ipregistry client stores in its cache. + */ +export type IpregistryCacheValue = IpInfo | AutonomousSystem + +export interface IpregistryCache { + get(key: string): V | undefined - put(key: string, data: any): void + put(key: string, data: V): void invalidate(key: string): void invalidateAll(): void } -interface CacheEntry { +interface CacheEntry { expiresAt: number - value: IpInfo + value: V } /** @@ -37,14 +42,16 @@ interface CacheEntry { * after insertion; reading an entry refreshes its recency for eviction * purposes but does not extend its lifetime. */ -export class InMemoryCache implements IpregistryCache { +export class InMemoryCache< + V = IpregistryCacheValue, +> implements IpregistryCache { private readonly maximumSize: number private readonly expireAfter: number // Iteration order of a Map is insertion order; the first key is therefore // the least recently used, since reads re-insert their entry. - private readonly cache: Map = new Map() + private readonly cache: Map> = new Map() constructor( maximumSize: number = typeof window !== 'undefined' ? 16 : 2048, @@ -54,7 +61,7 @@ export class InMemoryCache implements IpregistryCache { this.expireAfter = expireAfter } - get(key: string): IpInfo | undefined { + get(key: string): V | undefined { const entry = this.cache.get(key) if (!entry) { @@ -80,7 +87,7 @@ export class InMemoryCache implements IpregistryCache { this.cache.clear() } - put(key: string, data: IpInfo): void { + put(key: string, data: V): void { this.cache.delete(key) this.cache.set(key, { expiresAt: Date.now() + this.expireAfter, @@ -96,9 +103,9 @@ export class InMemoryCache implements IpregistryCache { } } -export class NoCache implements IpregistryCache { +export class NoCache implements IpregistryCache { // eslint-disable-next-line @typescript-eslint/no-unused-vars - get(key: string): IpInfo | undefined { + get(key: string): V | undefined { return undefined } @@ -112,7 +119,7 @@ export class NoCache implements IpregistryCache { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - put(key: string, data: IpInfo): void { + put(key: string, data: V): void { // do nothing } } diff --git a/src/fetch.ts b/src/fetch.ts index 5b3c116..d5757ee 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -52,7 +52,13 @@ export async function customFetch( ), } as Required> & Options + const callerSignal = providedOptions.signal ?? undefined + for (let attempt = 0; ; attempt++) { + if (callerSignal?.aborted) { + throw new ClientError('Request cancelled') + } + const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), options.timeout) @@ -60,13 +66,16 @@ export async function customFetch( try { response = await fetch(url, { ...options, - signal: controller.signal, + signal: combineSignals(controller.signal, callerSignal), }) } catch { + if (callerSignal?.aborted) { + throw new ClientError('Request cancelled') + } // Transport errors are retried up to maxRetries regardless of the // retry-on-status flags, matching the other Ipregistry clients. if (attempt < options.maxRetries) { - await backoff(options.retryInterval, attempt, 0) + await backoff(options.retryInterval, attempt, 0, callerSignal) continue } throw new ClientError( @@ -87,7 +96,12 @@ export async function customFetch( const retryAfter = parseRetryAfter( response.headers.get('retry-after'), ) - await backoff(options.retryInterval, attempt, retryAfter) + await backoff( + options.retryInterval, + attempt, + retryAfter, + callerSignal, + ) continue } @@ -139,15 +153,47 @@ function parseRetryAfter(value: string | null): number { return parseInt(value) * 1000 } +/** + * Combines the internal timeout signal with an optional caller-supplied + * signal so that either can abort the request. + */ +function combineSignals( + timeoutSignal: AbortSignal, + callerSignal?: AbortSignal, +): AbortSignal { + if (!callerSignal) { + return timeoutSignal + } + + if (typeof AbortSignal.any === 'function') { + return AbortSignal.any([timeoutSignal, callerSignal]) + } + + // Fallback for runtimes without AbortSignal.any (Node.js < 20.3). + const controller = new AbortController() + const abort = () => controller.abort() + + if (timeoutSignal.aborted || callerSignal.aborted) { + controller.abort() + } else { + timeoutSignal.addEventListener('abort', abort, { once: true }) + callerSignal.addEventListener('abort', abort, { once: true }) + } + + return controller.signal +} + /** * Waits before the next retry attempt, honoring an explicit Retry-After delay * when positive and otherwise using exponential backoff - * (retryInterval * 2^attempt). + * (retryInterval * 2^attempt). Rejects with a `ClientError` if the caller + * signal aborts while waiting. */ async function backoff( retryInterval: number, attempt: number, retryAfter: number, + callerSignal?: AbortSignal, ): Promise { let delay = retryAfter @@ -155,5 +201,15 @@ async function backoff( delay = retryInterval * 2 ** Math.min(attempt, 30) } - await new Promise(resolve => setTimeout(resolve, delay)) + await new Promise((resolve, reject) => { + const cancel = () => { + clearTimeout(timer) + reject(new ClientError('Request cancelled during retry backoff')) + } + const timer = setTimeout(() => { + callerSignal?.removeEventListener('abort', cancel) + resolve() + }, delay) + callerSignal?.addEventListener('abort', cancel, { once: true }) + }) } diff --git a/src/index.ts b/src/index.ts index 60c5914..5176b19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,12 @@ import { UserAgent, } from './model.js' import { IpregistryCache, NoCache } from './cache.js' -import { IpregistryOption } from './options.js' +import { + FilterOption, + HostnameOption, + IpregistryOption, + LookupOptions, +} from './options.js' import { isApiError, LookupError } from './errors.js' @@ -169,6 +174,10 @@ export class IpregistryConfig { * Provides a builder pattern for constructing `IpregistryConfig` instances. * This class allows for setting the `apiKey`, `baseUrl`, and `timeout` before * building the final `IpregistryConfig` object. + * + * @deprecated Pass an `IpregistryClientOptions` object to the + * `IpregistryClient` constructor instead, e.g. + * `new IpregistryClient({ apiKey: 'KEY', timeout: 10000 })`. */ export class IpregistryConfigBuilder { private apiKey: string @@ -306,6 +315,72 @@ export class IpregistryConfigBuilder { } } +/** + * Configuration options for constructing an `IpregistryClient`. + */ +export interface IpregistryClientOptions { + /** + * The API key used for authenticating requests to Ipregistry. + */ + apiKey: string + + /** + * The base URL of the Ipregistry API, or the shorthand 'eu' for the + * European Union endpoint. Defaults to 'https://api.ipregistry.co'. + */ + baseUrl?: string + + /** + * The timeout (in milliseconds) for API requests. Defaults to 5000. + */ + timeout?: number + + /** + * The maximum number of automatic retries performed in addition to the + * initial attempt. Defaults to 3. Use 0 to disable retries. + */ + maxRetries?: number + + /** + * The base backoff (in milliseconds) between retries. Defaults to 1000. + */ + retryInterval?: number + + /** + * Whether 5xx responses (and transient network errors) are retried. + * Defaults to true. + */ + retryOnServerError?: boolean + + /** + * Whether 429 Too Many Requests responses are retried, honoring the + * Retry-After header when present. Defaults to false. + */ + retryOnTooManyRequests?: boolean + + /** + * The maximum number of values sent in a single batch request. Capped at + * `DEFAULT_MAX_BATCH_SIZE` (the API limit). + */ + maxBatchSize?: number + + /** + * How many batch sub-requests are dispatched concurrently when a batch is + * split into chunks. Defaults to 4. + */ + batchConcurrency?: number + + /** + * The cache used to memoize lookups. Defaults to `NoCache`. + */ + cache?: IpregistryCache + + /** + * A custom handler for API requests. + */ + requestHandler?: IpregistryRequestHandler +} + /** * The main client for interacting with the Ipregistry API. * This class provides methods for looking up IP information, ASN details, parsing user agents, and more. @@ -317,23 +392,67 @@ export class IpregistryClient { private requestHandler: IpregistryRequestHandler + /** + * Constructs an IpregistryClient instance for API operations. + * @param options The client configuration, including the API key. + */ + constructor(options: IpregistryClientOptions) /** * Constructs an IpregistryClient instance for API operations. * @param keyOrConfig The API key as a string or an IpregistryConfig instance for custom configurations. * @param cache Optional. An instance implementing the IpregistryCache interface for caching responses. * @param requestHandler Optional. A custom handler for API requests. + * @deprecated Pass an `IpregistryClientOptions` object instead, e.g. + * `new IpregistryClient({ apiKey: 'KEY', cache: new InMemoryCache() })`. + * The API-key-string form remains supported. */ constructor( keyOrConfig: string | IpregistryConfig, cache?: IpregistryCache, requestHandler?: IpregistryRequestHandler, + ) + constructor( + keyOrConfigOrOptions: + string | IpregistryConfig | IpregistryClientOptions, + cache?: IpregistryCache, + requestHandler?: IpregistryRequestHandler, ) { - if (typeof keyOrConfig === 'string') { - this.config = new IpregistryConfigBuilder(keyOrConfig).build() - } else if (!keyOrConfig) { + let options: IpregistryClientOptions | undefined + + if ( + keyOrConfigOrOptions && + typeof keyOrConfigOrOptions === 'object' && + !(keyOrConfigOrOptions instanceof IpregistryConfig) + ) { + options = keyOrConfigOrOptions + } + + if (options) { + this.config = new IpregistryConfig( + options.apiKey, + options.baseUrl === 'eu' + ? 'https://eu.api.ipregistry.co' + : (options.baseUrl ?? ''), + options.timeout ?? 0, + options.maxRetries, + options.retryInterval, + options.retryOnServerError, + options.retryOnTooManyRequests, + options.maxBatchSize, + options.batchConcurrency, + ) + cache = options.cache ?? cache + requestHandler = options.requestHandler ?? requestHandler + } else if (typeof keyOrConfigOrOptions === 'string') { + this.config = new IpregistryConfigBuilder( + keyOrConfigOrOptions, + ).build() + } else if (!keyOrConfigOrOptions) { this.config = new IpregistryConfigBuilder('tryout').build() } else { - this.config = keyOrConfig + // The plain-options case was handled above, so an object here is + // necessarily an IpregistryConfig instance. + this.config = keyOrConfigOrOptions as IpregistryConfig } if (cache) { @@ -356,10 +475,23 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing an array of AutonomousSystem or LookupError objects. */ + async batchLookupAsns( + asns: number[], + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.batchLookupAsns(asns, { fields: 'name' })`. + */ async batchLookupAsns( asns: number[], ...options: IpregistryOption[] + ): Promise> + async batchLookupAsns( + asns: number[], + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { + const { params, signal } = IpregistryClient.normalizeOptions(options) const sparseCache: Array = new Array(asns.length) const cacheMisses: Array = [] @@ -368,9 +500,10 @@ export class IpregistryClient { const asn = asns[i] const cacheKey = IpregistryClient.buildCacheKey( asn.toString(), - options, + params, ) - const cacheValue = this.cache.get(cacheKey) + const cacheValue = this.cache.get(cacheKey) as + AutonomousSystem | undefined if (cacheValue) { sparseCache[i] = cacheValue @@ -384,7 +517,7 @@ export class IpregistryClient { >(asns.length) const apiResponse = await this.dispatchBatchChunks(cacheMisses, chunk => - this.requestHandler.batchLookupAsns(chunk, options), + this.requestHandler.batchLookupAsns(chunk, params, signal), ) const freshAutonomousSystem = apiResponse ? apiResponse.data.results @@ -406,7 +539,7 @@ export class IpregistryClient { this.cache.put( IpregistryClient.buildCacheKey( cacheMisses[k].toString(), - options, + params, ), freshAutonomousSystem[k] as AutonomousSystem, ) @@ -440,10 +573,23 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing an array of IpInfo or LookupError objects. */ + async batchLookupIps( + ips: string[], + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.batchLookupIps(ips, { fields: 'location' })`. + */ async batchLookupIps( ips: string[], ...options: IpregistryOption[] + ): Promise> + async batchLookupIps( + ips: string[], + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { + const { params, signal } = IpregistryClient.normalizeOptions(options) const sparseCache: Array = new Array( ips.length, ) @@ -451,8 +597,8 @@ export class IpregistryClient { for (let i = 0; i < ips.length; i++) { const ip = ips[i] - const cacheKey = IpregistryClient.buildCacheKey(ip, options) - const cacheValue = this.cache.get(cacheKey) + const cacheKey = IpregistryClient.buildCacheKey(ip, params) + const cacheValue = this.cache.get(cacheKey) as IpInfo | undefined if (cacheValue) { sparseCache[i] = cacheValue @@ -466,7 +612,7 @@ export class IpregistryClient { >(ips.length) const apiResponse = await this.dispatchBatchChunks(cacheMisses, chunk => - this.requestHandler.batchLookupIps(chunk, options), + this.requestHandler.batchLookupIps(chunk, params, signal), ) const freshIpInfo = apiResponse ? apiResponse.data.results : [] @@ -484,7 +630,7 @@ export class IpregistryClient { ) } else { this.cache.put( - IpregistryClient.buildCacheKey(cacheMisses[k], options), + IpregistryClient.buildCacheKey(cacheMisses[k], params), freshIpInfo[k] as IpInfo, ) result[j] = freshIpInfo[k] @@ -516,17 +662,31 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing the AutonomousSystem information. */ + async lookupAsn( + asn: number, + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.lookupAsn(asn, { fields: 'name' })`. + */ async lookupAsn( asn: number, ...options: IpregistryOption[] + ): Promise> + async lookupAsn( + asn: number, + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { - const cacheKey = IpregistryClient.buildCacheKey(asn.toString(), options) - const cacheValue = this.cache.get(cacheKey) as AutonomousSystem + const { params, signal } = IpregistryClient.normalizeOptions(options) + const cacheKey = IpregistryClient.buildCacheKey(asn.toString(), params) + const cacheValue = this.cache.get(cacheKey) as + AutonomousSystem | undefined let result: ApiResponse if (!cacheValue) { - result = await this.requestHandler.lookupAsn(asn, options) + result = await this.requestHandler.lookupAsn(asn, params, signal) this.cache.put(cacheKey, result.data) } else { result = { @@ -548,17 +708,30 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing the IpInfo. */ + async lookupIp( + ip: string, + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.lookupIp(ip, { fields: 'location', hostname: true })`. + */ async lookupIp( ip: string, ...options: IpregistryOption[] + ): Promise> + async lookupIp( + ip: string, + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { - const cacheKey = IpregistryClient.buildCacheKey(ip, options) - const cacheValue = this.cache.get(cacheKey) as IpInfo + const { params, signal } = IpregistryClient.normalizeOptions(options) + const cacheKey = IpregistryClient.buildCacheKey(ip, params) + const cacheValue = this.cache.get(cacheKey) as IpInfo | undefined let result: ApiResponse if (!cacheValue) { - result = await this.requestHandler.lookupIp(ip, options) + result = await this.requestHandler.lookupIp(ip, params, signal) this.cache.put(cacheKey, result.data) } else { result = { @@ -582,10 +755,21 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing the RequesterAutonomousSystem information. */ + async originLookupAsn( + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.originLookupAsn({ fields: 'name' })`. + */ async originLookupAsn( ...options: IpregistryOption[] + ): Promise> + async originLookupAsn( + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { - return await this.requestHandler.originLookupAsn(options) + const { params, signal } = IpregistryClient.normalizeOptions(options) + return await this.requestHandler.originLookupAsn(params, signal) } /** @@ -597,10 +781,21 @@ export class IpregistryClient { * @param options Optional. Additional options for the lookup operation. * @returns A Promise resolving to an ApiResponse containing the RequesterIpInfo. */ + async originLookupIp( + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass a `LookupOptions` object instead, e.g. + * `client.originLookupIp({ fields: 'location' })`. + */ async originLookupIp( ...options: IpregistryOption[] + ): Promise> + async originLookupIp( + ...options: (IpregistryOption | LookupOptions | undefined)[] ): Promise> { - return await this.requestHandler.originLookupIp(options) + const { params, signal } = IpregistryClient.normalizeOptions(options) + return await this.requestHandler.originLookupIp(params, signal) } /** @@ -608,10 +803,37 @@ export class IpregistryClient { * @param userAgents An array of user agent strings to parse. * @returns A Promise resolving to an ApiResponse containing an array of UserAgent information. */ + async parseUserAgents( + userAgents: string[], + options?: LookupOptions, + ): Promise> + /** + * @deprecated Pass the user agents as an array instead, e.g. + * `client.parseUserAgents([userAgent1, userAgent2])`. + */ async parseUserAgents( ...userAgents: string[] + ): Promise> + async parseUserAgents( + ...args: (string | string[] | LookupOptions | undefined)[] ): Promise> { - const response = await this.requestHandler.parseUserAgents(userAgents) + let userAgents: string[] + let signal: AbortSignal | undefined + + if (Array.isArray(args[0])) { + userAgents = args[0] + const options = args[1] as LookupOptions | undefined + signal = options?.signal + } else { + userAgents = args.filter( + (value): value is string => typeof value === 'string', + ) + } + + const response = await this.requestHandler.parseUserAgents( + userAgents, + signal, + ) return { credits: response.credits, data: response.data.results, @@ -734,6 +956,52 @@ export class IpregistryClient { return result } + /** + * Normalizes the supported option shapes (a single `LookupOptions` object + * or legacy variadic `IpregistryOption` instances) into query parameters + * and an optional abort signal. Param entries from `LookupOptions#params` + * are sorted by name so equivalent options produce identical cache keys. + */ + private static normalizeOptions( + options: (IpregistryOption | LookupOptions | undefined)[], + ): { params: IpregistryOption[]; signal?: AbortSignal } { + const provided = options.filter( + (option): option is IpregistryOption | LookupOptions => + option !== undefined && option !== null, + ) + + if ( + provided.length === 1 && + !(provided[0] instanceof IpregistryOption) + ) { + const lookupOptions = provided[0] + const params: IpregistryOption[] = [] + + if (lookupOptions.fields !== undefined) { + params.push(new FilterOption(lookupOptions.fields)) + } + + if (lookupOptions.hostname !== undefined) { + params.push(new HostnameOption(lookupOptions.hostname)) + } + + if (lookupOptions.params) { + for (const name of Object.keys(lookupOptions.params).sort()) { + params.push( + new IpregistryOption( + name, + String(lookupOptions.params[name]), + ), + ) + } + } + + return { params, signal: lookupOptions.signal } + } + + return { params: provided as IpregistryOption[] } + } + private static buildCacheKey( primaryKey: string, options: IpregistryOption[], diff --git a/src/options.ts b/src/options.ts index d1f9800..a6a5cad 100644 --- a/src/options.ts +++ b/src/options.ts @@ -14,6 +14,37 @@ * limitations under the License. */ +/** + * Options accepted by lookup methods. + */ +export interface LookupOptions { + /** + * Selects the fields to include in the response, as a comma-separated + * list of field paths (e.g. 'location.country,security'). + */ + fields?: string + + /** + * Whether to resolve and include the hostname the IP address points to. + */ + hostname?: boolean + + /** + * Additional query parameters to send with the request. + */ + params?: Record + + /** + * Cancels the request (including retries and, for batch lookups, pending + * chunks) when aborted. + */ + signal?: AbortSignal +} + +/** + * @deprecated Use `LookupOptions` instead, e.g. + * `client.lookupIp(ip, { fields: 'location', hostname: true })`. + */ export class IpregistryOption { public readonly name: string @@ -25,18 +56,28 @@ export class IpregistryOption { } } +/** + * @deprecated Use `LookupOptions#fields` instead. + */ export class FilterOption extends IpregistryOption { constructor(expression: string) { super('fields', expression) } } +/** + * @deprecated Use `LookupOptions#hostname` instead. + */ export class HostnameOption extends IpregistryOption { constructor(hostname: boolean) { super('hostname', String(hostname)) } } +/** + * @deprecated Use `LookupOptions` instead, e.g. + * `client.lookupIp(ip, { fields: 'location', hostname: true })`. + */ export class IpregistryOptions { public static filter(fields: string): FilterOption { return new FilterOption(fields) diff --git a/src/request.ts b/src/request.ts index 2bdc342..a6353ee 100644 --- a/src/request.ts +++ b/src/request.ts @@ -69,33 +69,40 @@ export interface IpregistryRequestHandler { batchLookupAsns( asns: number[], options: IpregistryOption[], + signal?: AbortSignal, ): Promise>> batchLookupIps( ipAddresses: string[], options: IpregistryOption[], + signal?: AbortSignal, ): Promise>> lookupAsn( asn: number, options: IpregistryOption[], + signal?: AbortSignal, ): Promise> lookupIp( ipAddress: string, options: IpregistryOption[], + signal?: AbortSignal, ): Promise> originLookupAsn( options: IpregistryOption[], + signal?: AbortSignal, ): Promise> originLookupIp( options: IpregistryOption[], + signal?: AbortSignal, ): Promise> parseUserAgents( userAgents: string[], + signal?: AbortSignal, ): Promise>> } @@ -110,6 +117,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async batchLookupAsns( asns: number[], options: IpregistryOption[], + signal?: AbortSignal, ): Promise>> { try { const response = await customFetch(this.buildApiUrl('', options), { @@ -117,6 +125,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { headers: this.getHeaders(), body: JSON.stringify(asns.map(asn => `AS${asn}`)), ...this.getFetchOptions(), + signal, }) return this.buildApiResponse(response) @@ -128,6 +137,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async batchLookupIps( ips: string[], options: IpregistryOption[], + signal?: AbortSignal, ): Promise>> { try { const response = await customFetch(this.buildApiUrl('', options), { @@ -135,6 +145,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { headers: this.getHeaders(), body: JSON.stringify(ips), ...this.getFetchOptions(), + signal, }) return this.buildApiResponse(response) @@ -146,6 +157,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async lookupAsn( asn: number, options: IpregistryOption[], + signal?: AbortSignal, ): Promise> { try { const response = await customFetch( @@ -154,6 +166,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { method: 'GET', headers: this.getHeaders(), ...this.getFetchOptions(), + signal, }, ) return this.buildApiResponse(response) @@ -165,12 +178,14 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async lookupIp( ip: string, options: IpregistryOption[], + signal?: AbortSignal, ): Promise> { try { const response = await customFetch(this.buildApiUrl(ip, options), { method: 'GET', headers: this.getHeaders(), ...this.getFetchOptions(), + signal, }) return this.buildApiResponse(response) } catch (error) { @@ -180,6 +195,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async originLookupAsn( options: IpregistryOption[], + signal?: AbortSignal, ): Promise> { try { const response = await customFetch( @@ -188,6 +204,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { method: 'GET', headers: this.getHeaders(), ...this.getFetchOptions(), + signal, }, ) return this.buildApiResponse(response) @@ -198,12 +215,14 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async originLookupIp( options: IpregistryOption[], + signal?: AbortSignal, ): Promise> { try { const response = await customFetch(this.buildApiUrl('', options), { method: 'GET', headers: this.getHeaders(), ...this.getFetchOptions(), + signal, }) return this.buildApiResponse(response) } catch (error: unknown) { @@ -213,6 +232,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { async parseUserAgents( userAgents: string[], + signal?: AbortSignal, ): Promise>> { try { const response = await customFetch(this.buildApiUrl('user_agent'), { @@ -220,6 +240,7 @@ export class DefaultRequestHandler implements IpregistryRequestHandler { headers: this.getHeaders(), body: JSON.stringify(userAgents), ...this.getFetchOptions(), + signal, }) return this.buildApiResponse(response) } catch (error) { diff --git a/src/version.ts b/src/version.ts index 9bede3b..130ba59 100644 --- a/src/version.ts +++ b/src/version.ts @@ -18,4 +18,4 @@ * The version of this library. Must be kept in sync with the version field of * package.json; a unit test enforces this. */ -export const LIBRARY_VERSION = '6.2.0' +export const LIBRARY_VERSION = '7.0.0' diff --git a/test/v7.test.ts b/test/v7.test.ts new file mode 100644 index 0000000..be2503e --- /dev/null +++ b/test/v7.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright 2019 Ipregistry (https://ipregistry.co). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Tests for the v7 API surface: options-object construction, per-call +// LookupOptions, and request cancellation with AbortSignal. + +import { + ClientError, + InMemoryCache, + IpregistryClient, + IpregistryOptions, +} from '../dist/index.mjs' + +import { afterEach, describe, it } from 'node:test' +import { expect } from 'chai' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function capturingFetch(): { urls: string[]; headers: Record } { + const captured: { urls: string[]; headers: Record } = { + urls: [], + headers: {}, + } + globalThis.fetch = (async (url: string, init: RequestInit) => { + captured.urls.push(String(url)) + captured.headers = init.headers as Record + return jsonResponse({ ip: '8.8.8.8' }) + }) as typeof fetch + return captured +} + +describe('options-object construction', () => { + it('builds a working client from a plain options object', async () => { + const captured = capturingFetch() + const client = new IpregistryClient({ apiKey: 'my-key' }) + + const response = await client.lookupIp('8.8.8.8') + + expect(response.data).to.deep.equal({ ip: '8.8.8.8' }) + expect(captured.urls[0]).to.equal('https://api.ipregistry.co/8.8.8.8?') + expect(captured.headers['authorization']).to.equal('ApiKey my-key') + }) + + it("maps the 'eu' base URL shorthand to the EU endpoint", async () => { + const captured = capturingFetch() + const client = new IpregistryClient({ apiKey: 'k', baseUrl: 'eu' }) + + await client.lookupIp('8.8.8.8') + + expect(captured.urls[0]).to.match( + /^https:\/\/eu\.api\.ipregistry\.co\//, + ) + }) + + it('accepts a cache through the options object', async () => { + capturingFetch() + const cache = new InMemoryCache() + const client = new IpregistryClient({ apiKey: 'k', cache }) + + expect(client.getCache()).to.equal(cache) + + await client.lookupIp('8.8.8.8') + const captured = capturingFetch() + await client.lookupIp('8.8.8.8') + + expect(captured.urls).to.have.length(0) + }) +}) + +describe('per-call lookup options', () => { + it('serializes fields, hostname and extra params into the query', async () => { + const captured = capturingFetch() + const client = new IpregistryClient({ apiKey: 'k' }) + + await client.lookupIp('8.8.8.8', { + fields: 'location.country', + hostname: true, + params: { zed: 'z', alpha: 'a' }, + }) + + expect(captured.urls[0]).to.equal( + 'https://api.ipregistry.co/8.8.8.8?fields=location.country&hostname=true&alpha=a&zed=z', + ) + }) + + it('produces the same cache key as the equivalent legacy options', async () => { + capturingFetch() + const client = new IpregistryClient({ + apiKey: 'k', + cache: new InMemoryCache(), + }) + + await client.lookupIp('8.8.8.8', { + fields: 'location', + hostname: true, + }) + + const captured = capturingFetch() + await client.lookupIp( + '8.8.8.8', + IpregistryOptions.filter('location'), + IpregistryOptions.hostname(true), + ) + + expect(captured.urls).to.have.length(0) + }) + + it('supports the array form of parseUserAgents', async () => { + globalThis.fetch = (async () => + jsonResponse({ results: [{ name: 'Chrome' }] })) as typeof fetch + const client = new IpregistryClient({ apiKey: 'k' }) + + const response = await client.parseUserAgents(['Mozilla/5.0']) + + expect(response.data).to.deep.equal([{ name: 'Chrome' }]) + }) +}) + +describe('request cancellation', () => { + it('rejects immediately when the signal is already aborted', async () => { + const captured = capturingFetch() + const client = new IpregistryClient({ apiKey: 'k' }) + const controller = new AbortController() + controller.abort() + + try { + await client.lookupIp('8.8.8.8', { signal: controller.signal }) + expect.fail('expected lookupIp to throw') + } catch (error) { + expect(error).instanceOf(ClientError) + expect((error as ClientError).message).to.equal('Request cancelled') + } + + expect(captured.urls).to.have.length(0) + }) + + it('cancels retry backoff waits', async () => { + globalThis.fetch = ((_url: unknown, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject( + Object.assign(new Error('aborted'), { + name: 'AbortError', + }), + ), + ) + })) as typeof fetch + + // A 5ms timeout forces a quick transport failure, and the long retry + // interval means the client would then sleep for minutes; aborting + // must interrupt that wait right away. + const client = new IpregistryClient({ + apiKey: 'k', + timeout: 5, + retryInterval: 600_000, + }) + const controller = new AbortController() + setTimeout(() => controller.abort(), 50) + + const startedAt = Date.now() + try { + await client.lookupIp('8.8.8.8', { signal: controller.signal }) + expect.fail('expected lookupIp to throw') + } catch (error) { + expect(error).instanceOf(ClientError) + expect((error as ClientError).message).to.equal( + 'Request cancelled during retry backoff', + ) + } + + expect(Date.now() - startedAt).to.be.below(5000) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e6e6883..1889c05 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,6 @@ "lib": ["es7", "dom"], "module": "NodeNext", "moduleResolution": "NodeNext", - "noImplicitAny": false, "noImplicitThis": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true,