Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions __tests__/ipinfoPlusWrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as dotenv from "dotenv";
import { IPBogon, IPinfoPlus } from "../src/common";
import IPinfoPlusWrapper from "../src/ipinfoPlusWrapper";

const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip;

beforeAll(() => {
dotenv.config();
});

describe("IPinfoPlusWrapper", () => {
testIfTokenIsSet("lookupIp", async () => {
const ipinfoWrapper = new IPinfoPlusWrapper(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 IPinfoPlus;

// Basic fields
expect(data.ip).toEqual("8.8.8.8");
expect(data.hostname).toBeDefined();

// Check nested geo object with all fields
expect(data.geo).toBeDefined();
expect(typeof data.geo).toBe("object");
expect(data.geo.city).toBeDefined();
expect(data.geo.region).toBeDefined();
expect(data.geo.region_code).toBeDefined();
expect(data.geo.country).toBeDefined();
expect(data.geo.country_code).toBeDefined();
expect(data.geo.continent).toBeDefined();
expect(data.geo.continent_code).toBeDefined();
expect(data.geo.latitude).toBeDefined();
expect(data.geo.longitude).toBeDefined();
expect(data.geo.timezone).toBeDefined();
expect(data.geo.postal_code).toBeDefined();
expect(data.geo.dma_code).toBeDefined();
expect(data.geo.geoname_id).toBeDefined();
expect(data.geo.radius).toBeDefined();

// Check nested as object with all fields
expect(data.as).toBeDefined();
expect(typeof data.as).toBe("object");
expect(data.as.asn).toBeDefined();
expect(data.as.name).toBeDefined();
expect(data.as.domain).toBeDefined();
expect(data.as.type).toBeDefined();
expect(data.as.last_changed).toBeDefined();

// Check mobile and anonymous objects
expect(data.mobile).toBeDefined();
expect(typeof data.mobile).toBe("object");
expect(data.anonymous).toBeDefined();
expect(typeof data.anonymous).toBe("object");
expect(data.anonymous.is_proxy).toBeDefined();
expect(data.anonymous.is_relay).toBeDefined();
expect(data.anonymous.is_tor).toBeDefined();
expect(data.anonymous.is_vpn).toBeDefined();

// Check all network/type flags
expect(data.is_anonymous).toBeDefined();
expect(data.is_anycast).toBeDefined();
expect(data.is_hosting).toBeDefined();
expect(data.is_mobile).toBeDefined();
expect(data.is_satellite).toBeDefined();

// Check geo formatting was applied
expect(data.geo.country_name).toBeDefined();
expect(data.geo.isEU).toBeDefined();
expect(data.geo.country_flag_url).toBeDefined();
}
});

testIfTokenIsSet("isBogon", async () => {
const ipinfoWrapper = new IPinfoPlusWrapper(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 IPinfoPlusWrapper("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 IPinfoPlusWrapper(
"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");
});
});
52 changes: 52 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,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";

// cache version
export const CACHE_VSN: string = "1";
Expand Down Expand Up @@ -150,6 +151,57 @@ export interface IPinfoCore {
is_satellite: boolean;
}

export interface IPinfoPlus {
ip: string;
hostname: string;
geo: {
city: string;
region: string;
region_code: string;
country: string;
country_code: string;
continent: string;
continent_code: string;
latitude: number;
longitude: number;
timezone: string;
postal_code: string;
dma_code: string;
geoname_id: string;
radius: number;
last_changed?: string;
country_name?: string;
isEU?: boolean;
country_flag?: CountryFlag;
country_currency?: CountryCurrency;
country_flag_url?: string;
};
as: {
asn: string;
name: string;
domain: string;
type: string;
last_changed: string;
};
mobile: {
name?: string;
mcc?: string;
mnc?: string;
};
anonymous: {
is_proxy: boolean;
is_relay: boolean;
is_tor: boolean;
is_vpn: boolean;
name?: string;
};
is_anonymous: boolean;
is_anycast: boolean;
is_hosting: boolean;
is_mobile: boolean;
is_satellite: boolean;
}

export interface Prefix {
netblock: string;
id: string;
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import IPinfoWrapper from "./ipinfoWrapper";
import IPinfoLiteWrapper from "./ipinfoLiteWrapper";
import IPinfoCoreWrapper from "./ipinfoCoreWrapper";
import IPinfoPlusWrapper from "./ipinfoPlusWrapper";
import Cache from "./cache/cache";
import LruCache from "./cache/lruCache";
import ApiLimitError from "./errors/apiLimitError";
Expand All @@ -13,6 +14,7 @@ export {
IPinfoWrapper,
IPinfoLiteWrapper,
IPinfoCoreWrapper,
IPinfoPlusWrapper,
ApiLimitError
};
export {
Expand All @@ -24,6 +26,7 @@ export {
Domains,
IPinfo,
IPinfoCore,
IPinfoPlus,
Prefix,
Prefixes6,
AsnResponse,
Expand Down
193 changes: 193 additions & 0 deletions src/ipinfoPlusWrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import fetch from "node-fetch";
import type { RequestInit, Response } from "node-fetch";
import {
defaultContinents,
defaultCountriesCurrencies,
defaultCountriesFlags,
defaultCountries,
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_PLUS,
BOGON_NETWORKS,
IPinfoPlus,
IPBogon
} from "./common";
import VERSION from "./version";

const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`;
const countryFlagURL = "https://cdn.ipinfo.io/static/images/countries-flags/";

export default class IPinfoPlusWrapper {
private token: string;
private baseUrl: string;
private countries: any;
private countriesFlags: any;
private countriesCurrencies: any;
private continents: any;
private euCountries: Array<string>;
private cache: Cache;
private timeout: number;

/**
* Creates IPinfoPlusWrapper object to communicate with the IPinfo Plus 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?: {
countries?: any;
countriesFlags?: any;
countriesCurrencies?: any;
continents?: any;
euCountries?: Array<string>;
},
baseUrl?: string
) {
this.token = token;
this.countries = i18nData?.countries
? i18nData.countries
: defaultCountries;
this.countriesFlags = i18nData?.countriesFlags
? i18nData.countriesFlags
: defaultCountriesFlags;
this.countriesCurrencies = i18nData?.countriesCurrencies
? i18nData.countriesCurrencies
: defaultCountriesCurrencies;
this.continents = i18nData?.continents
? i18nData.continents
: defaultContinents;
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_PLUS}`;
}

public static cacheKey(k: string) {
return `${k}:${CACHE_VSN}`;
}

public async fetchApi(
path: string,
init: RequestInit = {}
): Promise<Response> {
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<IPinfoPlus | IPBogon> {
if (ip && this.isBogon(ip)) {
return {
ip,
bogon: true
};
}

if (!ip) {
ip = "me";
}

const data = await this.cache.get(IPinfoPlusWrapper.cacheKey(ip));

if (data) {
return data;
}

return this.fetchApi(ip).then(async (response) => {
const ipinfo = (await response.json()) as IPinfoPlus;

// Format geo object
const countryCode = ipinfo.geo.country_code;
ipinfo.geo.country_name = this.countries[countryCode];
ipinfo.geo.isEU = this.euCountries.includes(countryCode);
ipinfo.geo.country_flag = this.countriesFlags[countryCode];
ipinfo.geo.country_currency =
this.countriesCurrencies[countryCode];
ipinfo.geo.continent = this.continents[countryCode];
ipinfo.geo.country_flag_url =
countryFlagURL + countryCode + ".svg";

this.cache.set(IPinfoPlusWrapper.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;
}
}
Loading