diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c72d1..b6054d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 4.3.0 + +- Add support for IPinfo Residential Proxy API + ## 4.2.0 - Add support for IPinfo Core API diff --git a/__tests__/ipinfoResProxyWrapper.test.ts b/__tests__/ipinfoResProxyWrapper.test.ts new file mode 100644 index 0000000..2580e5c --- /dev/null +++ b/__tests__/ipinfoResProxyWrapper.test.ts @@ -0,0 +1,61 @@ +import * as dotenv from "dotenv"; +import { IPBogon, IPinfoResProxy } from "../src/common"; +import IPinfoResProxyWrapper from "../src/ipinfoResProxyWrapper"; + +const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip; + +beforeAll(() => { + dotenv.config(); +}); + +describe("IPinfoResProxyWrapper", () => { + testIfTokenIsSet("lookupIp", async () => { + const wrapper = new IPinfoResProxyWrapper(process.env.IPINFO_TOKEN!); + + for (let i = 0; i < 5; i++) { + const data = (await wrapper.lookupIp( + "139.5.0.122" + )) as IPinfoResProxy; + + expect(data.ip).toEqual("139.5.0.122"); + expect(data.service).toBeDefined(); + expect(typeof data.service).toBe("string"); + expect(data.last_seen).toBeDefined(); + expect(typeof data.last_seen).toBe("string"); + expect(data.percent_days_seen).toBeDefined(); + expect(typeof data.percent_days_seen).toBe("number"); + } + }); + + testIfTokenIsSet("isBogon", async () => { + const wrapper = new IPinfoResProxyWrapper(process.env.IPINFO_TOKEN!); + + const data = (await wrapper.lookupIp("198.51.100.1")) as IPBogon; + expect(data.ip).toEqual("198.51.100.1"); + expect(data.bogon).toEqual(true); + }); + + test("Error is thrown for invalid token", async () => { + const wrapper = new IPinfoResProxyWrapper("invalid-token"); + await expect(wrapper.lookupIp("1.2.3.4")).rejects.toThrow(); + }); + + test("Error is thrown when response cannot be parsed as JSON", async () => { + const baseUrlWithUnparseableResponse = "https://ipinfo.io/developers#"; + const wrapper = new IPinfoResProxyWrapper( + "token", + undefined, + undefined, + baseUrlWithUnparseableResponse + ); + + await expect(wrapper.lookupIp("1.2.3.4")).rejects.toThrow(); + + const result = await wrapper + .lookupIp("1.2.3.4") + .then((_) => "parseable") + .catch((_) => "unparseable"); + + expect(result).toEqual("unparseable"); + }); +}); diff --git a/package.json b/package.json index 6dea183..e47fe62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-ipinfo", - "version": "4.2.0", + "version": "4.3.0", "description": "Official Node client library for IPinfo", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/src/common.ts b/src/common.ts index bade933..164d09c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,6 +2,7 @@ export const HOST: string = "ipinfo.io"; export const HOST_LITE: string = "api.ipinfo.io/lite"; export const HOST_CORE: string = "api.ipinfo.io/lookup"; export const HOST_PLUS: string = "api.ipinfo.io/lookup"; +export const HOST_RES_PROXY: string = "ipinfo.io/resproxy"; // cache version export const CACHE_VSN: string = "1"; @@ -202,6 +203,13 @@ export interface IPinfoPlus { is_satellite: boolean; } +export interface IPinfoResProxy { + ip: string; + service: string; + last_seen: string; + percent_days_seen: number; +} + export interface Prefix { netblock: string; id: string; diff --git a/src/index.ts b/src/index.ts index dd9a167..ee57f67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import IPinfoWrapper from "./ipinfoWrapper"; import IPinfoLiteWrapper from "./ipinfoLiteWrapper"; import IPinfoCoreWrapper from "./ipinfoCoreWrapper"; import IPinfoPlusWrapper from "./ipinfoPlusWrapper"; +import IPinfoResProxyWrapper from "./ipinfoResProxyWrapper"; import Cache from "./cache/cache"; import LruCache from "./cache/lruCache"; import ApiLimitError from "./errors/apiLimitError"; @@ -15,6 +16,7 @@ export { IPinfoLiteWrapper, IPinfoCoreWrapper, IPinfoPlusWrapper, + IPinfoResProxyWrapper, ApiLimitError }; export { @@ -27,6 +29,7 @@ export { IPinfo, IPinfoCore, IPinfoPlus, + IPinfoResProxy, Prefix, Prefixes6, AsnResponse, diff --git a/src/ipinfoResProxyWrapper.ts b/src/ipinfoResProxyWrapper.ts new file mode 100644 index 0000000..892e2b7 --- /dev/null +++ b/src/ipinfoResProxyWrapper.ts @@ -0,0 +1,128 @@ +import type { RequestInit, Response } from "node-fetch"; +import Cache from "./cache/cache"; +import LruCache from "./cache/lruCache"; +import ApiLimitError from "./errors/apiLimitError"; +import { isInSubnet } from "subnet-check"; +import { + REQUEST_TIMEOUT_DEFAULT, + CACHE_VSN, + HOST_RES_PROXY, + BOGON_NETWORKS, + IPinfoResProxy, + IPBogon +} from "./common"; +import VERSION from "./version"; + +const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`; + +export default class IPinfoResProxyWrapper { + private token: string; + private baseUrl: string; + private cache: Cache; + private timeout: number; + + /** + * Creates IPinfoResProxyWrapper object to communicate with the IPinfo Res Proxy API. + * + * @param token Token string provided by IPinfo for the registered user. + * @param cache An implementation of IPCache interface, or LruCache if not specified. + * @param timeout Request timeout in milliseconds, or 5000ms if not specified. 0 disables the timeout. + * @param baseUrl The base url to use for api requests, or "ipinfo.io" if not specified. + */ + constructor( + token: string, + cache?: Cache, + timeout?: number, + baseUrl?: string + ) { + this.token = token; + this.cache = cache || new LruCache(); + this.timeout = + timeout === null || timeout === undefined + ? REQUEST_TIMEOUT_DEFAULT + : timeout; + this.baseUrl = baseUrl || `https://${HOST_RES_PROXY}`; + } + + public static cacheKey(k: string): string { + return `${k}:${CACHE_VSN}`; + } + + public async fetchApi( + path: string, + init: RequestInit = {} + ): Promise { + const headers = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "User-Agent": clientUserAgent + }; + + const request = Object.assign( + { + timeout: this.timeout, + method: "GET", + compress: false + }, + init, + { headers: Object.assign(headers, init.headers) } + ); + + const url = [this.baseUrl, path].join( + !this.baseUrl.endsWith("/") && !path.startsWith("/") ? "/" : "" + ); + + return fetch(url, request).then((response: Response) => { + if (response.status === 429) { + throw new ApiLimitError(); + } + + if (response.status >= 400) { + throw new Error( + `Received an error from the IPinfo API ` + + `(using authorization ${headers["Authorization"]}) ` + + `${response.status} ${response.statusText} ${response.url}` + ); + } + + return response; + }); + } + + public async lookupIp( + ip: string | undefined = undefined + ): Promise { + if (ip && this.isBogon(ip)) { + return { ip, bogon: true }; + } + + if (!ip) { + ip = "me"; + } + + const data = await this.cache.get(IPinfoResProxyWrapper.cacheKey(ip)); + + if (data) { + return data; + } + + return this.fetchApi(ip).then(async (response) => { + const ipinfo = (await response.json()) as IPinfoResProxy; + this.cache.set(IPinfoResProxyWrapper.cacheKey(ip), ipinfo); + + return ipinfo; + }); + } + + private isBogon(ip: string): boolean { + if (ip != "") { + for (let network of BOGON_NETWORKS) { + if (isInSubnet(ip, network)) { + return true; + } + } + } + return false; + } +} diff --git a/src/version.ts b/src/version.ts index 5065de7..2d339d1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ -const VERSION = "4.2.0"; +const VERSION = "4.3.0"; export default VERSION;