diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1991ee4..a7755ce 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,6 +17,8 @@ jobs: node-version: ${{ matrix.version }} - run: npm ci - run: npm test + env: + IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }} - run: npm run build - run: cd test-app && npm install && node index.js env: diff --git a/README.md b/README.md index c3d093a..287d29d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ You'll need an IPinfo API access token, which you can get by signing up for a fr The free plan is limited to 50,000 requests per month, and doesn't include some of the data fields such as IP type and company data. To enable all the data fields and additional request volumes see [https://ipinfo.io/pricing](https://ipinfo.io/pricing). -⚠️ Note: This library does not currently support our newest free API https://ipinfo.io/lite. If you’d like to use IPinfo Lite, you can call the [endpoint directly](https://ipinfo.io/developers/lite-api) using your preferred HTTP client. Developers are also welcome to contribute support for Lite by submitting a pull request. +The library also supports the Lite API, see the [Lite API section](#lite-api) for more info. ### Installation @@ -57,7 +57,7 @@ const ipinfo = await ipinfoWrapper.lookupIp("1.1.1.1");
Standalone example -1. Create `ipinfo.js` with the following code, then replace `MY_TOKEN` with +1. Create `ipinfo.js` with the following code, then replace `MY_TOKEN` with [your token](https://ipinfo.io/account/token). ```typescript @@ -79,7 +79,7 @@ node ipinfo.js // ... ``` -3. Run `ipinfo.js` with an IP to lookup, like `2.2.2.2` `8.8.8.8` or +3. Run `ipinfo.js` with an IP to lookup, like `2.2.2.2` `8.8.8.8` or [your IP](https://ipinfo.io/what-is-my-ip). ```shell @@ -95,7 +95,7 @@ node ipinfo.js 2.2.2.2 Each `lookup` method will throw an error when the lookup does not complete successfully. A program that performs a lookup should catch errors unless it is -desirable for the error to bubble up. For example, if your program is performing +desirable for the error to bubble up. For example, if your program is performing a lookup to find the country code of an IP you can return "N/A" when catching an error. @@ -106,6 +106,23 @@ const countryCode = ipinfoWrapper .catch((error) => "N/A"); ``` +### Lite API + +The library gives the possibility to use the [Lite API](https://ipinfo.io/developers/lite-api) too, authentication with your token is still required. + +The returned details are slightly different from the Core API. + +```typescript +import IPinfoLiteWrapper from "node-ipinfo"; + +const ipinfoWrapper = new IPinfoLiteWrapper("MY_TOKEN"); +const ipinfo = await ipinfoWrapper.lookupIp("8.8.8.8"); +console.log(ipinfo.countryCode) +// US +console.log(ipinfo.country) +// United States +``` + ### Caching This library uses an LRU cache (deletes the least-recently-used items). @@ -224,7 +241,7 @@ const { IPinfoWrapper } = require("node-ipinfo"); const ipinfoWrapper = new IPinfoWrapper("MY_TOKEN"); -const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4"]; +const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4"]; ipinfoWrapper.getMap(ips).then(response => { console.log(response); }); @@ -239,7 +256,7 @@ const { IPinfoWrapper } = require("node-ipinfo"); const ipinfoWrapper = new IPinfoWrapper("MY_TOKEN"); -const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4/country"]; +const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4/country"]; ipinfoWrapper .getBatch(ips) diff --git a/__tests__/ipinfoLiteWrapper.test.ts b/__tests__/ipinfoLiteWrapper.test.ts new file mode 100644 index 0000000..e16746b --- /dev/null +++ b/__tests__/ipinfoLiteWrapper.test.ts @@ -0,0 +1,65 @@ +import * as dotenv from "dotenv"; +import { IPBogon, IPinfoLite } from "../src/common"; +import IPinfoLiteWrapper from "../src/ipinfoLiteWrapper"; + +const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip; + +beforeAll(() => { + dotenv.config(); +}); + +describe("IPinfoLiteWrapper", () => { + testIfTokenIsSet("lookupIp", async () => { + const ipinfoWrapper = new IPinfoLiteWrapper(process.env.IPINFO_TOKEN!); + + // test multiple times for cache. + for (let i = 0; i < 5; i++) { + const data = (await ipinfoWrapper.lookupIp( + "8.8.8.8" + )) as IPinfoLite; + expect(data.ip).toEqual("8.8.8.8"); + expect(data.asn).toEqual("AS15169"); + expect(data.asName).toEqual("Google LLC"); + expect(data.asDomain).toEqual("google.com"); + expect(data.countryCode).toEqual("US"); + expect(data.country).toEqual("United States"); + expect(data.continentCode).toEqual("NA"); + expect(data.continent).toEqual("North America"); + expect(data.isEU).toEqual(false); + } + }); + + testIfTokenIsSet("isBogon", async () => { + const ipinfoWrapper = new IPinfoLiteWrapper(process.env.IPINFO_TOKEN!); + + const data = (await ipinfoWrapper.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 ipinfo = new IPinfoLiteWrapper("invalid-token"); + await expect(ipinfo.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 ipinfo = new IPinfoLiteWrapper( + "token", + undefined, + undefined, + undefined, + baseUrlWithUnparseableResponse + ); + + await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow(); + + const result = await ipinfo + .lookupIp("1.2.3.4") + .then((_) => "parseable") + .catch((_) => "unparseable"); + + expect(result).toEqual("unparseable"); + }); +}); diff --git a/src/common.ts b/src/common.ts index 9ba944c..0634c1e 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,4 +1,5 @@ export const HOST: string = "ipinfo.io"; +export const HOST_LITE: string = "api.ipinfo.io/lite"; // cache version export const CACHE_VSN: string = "1"; @@ -98,6 +99,23 @@ export interface IPinfo { domains: Domains; } +export interface IPBogon { + ip: string; + bogon: boolean; +} + +export interface IPinfoLite { + ip: string; + asn: string; + asName: string; + asDomain: string; + countryCode: string; + country: string; + continentCode: string; + continent: string; + isEU: boolean; +} + export interface Prefix { netblock: string; id: string; diff --git a/src/index.ts b/src/index.ts index 6f13e4a..a92a4ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import IPinfoWrapper from "./ipinfoWrapper"; +import IPinfoLiteWrapper from "./ipinfoLiteWrapper"; import Cache from "./cache/cache"; import LruCache from "./cache/lruCache"; import ApiLimitError from "./errors/apiLimitError"; export { Options } from "lru-cache"; -export { Cache, LruCache, IPinfoWrapper, ApiLimitError }; +export { Cache, LruCache, IPinfoWrapper, IPinfoLiteWrapper, ApiLimitError }; export { Asn, Company, diff --git a/src/ipinfoLiteWrapper.ts b/src/ipinfoLiteWrapper.ts new file mode 100644 index 0000000..98ba8d3 --- /dev/null +++ b/src/ipinfoLiteWrapper.ts @@ -0,0 +1,167 @@ +import fetch from "node-fetch"; +import type { RequestInit, Response } from "node-fetch"; +import { defaultEuCountries } from "../config/utils"; +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_LITE, + BOGON_NETWORKS, + IPinfoLite, + IPBogon +} from "./common"; +import VERSION from "./version"; + +const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`; + +export default class IPinfoLiteWrapper { + private token: string; + private baseUrl: string; + private euCountries: Array; + private cache: Cache; + private timeout: number; + + /** + * Creates IPinfoWrapper object to communicate with the [IPinfo](https://ipinfo.io/) API. + * + * @param token Token string provided by IPinfo for registered user. + * @param cache An implementation of IPCache interface. If it is not provided + * then LruCache is used as default. + * @param timeout Timeout in milliseconds that controls the timeout of requests. + * It defaults to 5000 i.e. 5 seconds. A timeout of 0 disables the timeout feature. + * @param i18nData Internationalization data for customizing countries-related information. + * @param i18nData.countries Custom countries data. If not provided, default countries data will be used. + * @param i18nData.countriesFlags Custom countries flags data. If not provided, default countries flags data will be used. + * @param i18nData.countriesCurrencies Custom countries currencies data. If not provided, default countries currencies data will be used. + * @param i18nData.continents Custom continents data. If not provided, default continents data will be used. + * @param i18nData.euCountries Custom EU countries data. If not provided or an empty array, default EU countries data will be used. + */ + constructor( + token: string, + cache?: Cache, + timeout?: number, + i18nData?: { + euCountries?: Array; + }, + baseUrl?: string + ) { + this.token = token; + this.euCountries = + i18nData?.euCountries && i18nData?.euCountries.length !== 0 + ? i18nData.euCountries + : defaultEuCountries; + this.cache = cache ? cache : new LruCache(); + this.timeout = + timeout === null || timeout === undefined + ? REQUEST_TIMEOUT_DEFAULT + : timeout; + this.baseUrl = baseUrl || `https://${HOST_LITE}`; + } + + public static cacheKey(k: 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; + }); + } + + /** + * Lookup IP information using the IP. + * + * @param ip IP address against which the location information is required. + * @return Response containing location information. + */ + 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(IPinfoLiteWrapper.cacheKey(ip)); + + if (data) { + return data; + } + + return this.fetchApi(ip).then(async (response) => { + const data = await response.json(); + + const ipinfo = { + ip: data.ip, + asn: data.asn, + asName: data.as_name, + asDomain: data.as_domain, + countryCode: data.country_code, + country: data.country, + continentCode: data.continent_code, + continent: data.continent, + isEU: this.euCountries.includes(data.country_code) + }; + + this.cache.set(IPinfoLiteWrapper.cacheKey(ip), ipinfo); + + return ipinfo; + }); + } + + private isBogon(ip: string): boolean { + if (ip != "") { + for (var network of BOGON_NETWORKS) { + if (isInSubnet(ip, network)) { + return true; + } + } + } + return false; + } +}