Skip to content

Commit e67309b

Browse files
committed
Add support for Plus bundle
1 parent adb345b commit e67309b

File tree

4 files changed

+357
-0
lines changed

4 files changed

+357
-0
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as dotenv from "dotenv";
2+
import { IPBogon, IPinfoPlus } from "../src/common";
3+
import IPinfoPlusWrapper from "../src/ipinfoPlusWrapper";
4+
5+
const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip;
6+
7+
beforeAll(() => {
8+
dotenv.config();
9+
});
10+
11+
describe("IPinfoPlusWrapper", () => {
12+
testIfTokenIsSet("lookupIp", async () => {
13+
const ipinfoWrapper = new IPinfoPlusWrapper(process.env.IPINFO_TOKEN!);
14+
15+
// test multiple times for cache.
16+
for (let i = 0; i < 5; i++) {
17+
const data = (await ipinfoWrapper.lookupIp(
18+
"8.8.8.8"
19+
)) as IPinfoPlus;
20+
21+
// Basic fields
22+
expect(data.ip).toEqual("8.8.8.8");
23+
expect(data.hostname).toBeDefined();
24+
25+
// Check nested geo object with all fields
26+
expect(data.geo).toBeDefined();
27+
expect(typeof data.geo).toBe("object");
28+
expect(data.geo.city).toBeDefined();
29+
expect(data.geo.region).toBeDefined();
30+
expect(data.geo.region_code).toBeDefined();
31+
expect(data.geo.country).toBeDefined();
32+
expect(data.geo.country_code).toBeDefined();
33+
expect(data.geo.continent).toBeDefined();
34+
expect(data.geo.continent_code).toBeDefined();
35+
expect(data.geo.latitude).toBeDefined();
36+
expect(data.geo.longitude).toBeDefined();
37+
expect(data.geo.timezone).toBeDefined();
38+
expect(data.geo.postal_code).toBeDefined();
39+
expect(data.geo.dma_code).toBeDefined();
40+
expect(data.geo.geoname_id).toBeDefined();
41+
expect(data.geo.radius).toBeDefined();
42+
43+
// Check nested as object with all fields
44+
expect(data.as).toBeDefined();
45+
expect(typeof data.as).toBe("object");
46+
expect(data.as.asn).toBeDefined();
47+
expect(data.as.name).toBeDefined();
48+
expect(data.as.domain).toBeDefined();
49+
expect(data.as.type).toBeDefined();
50+
expect(data.as.last_changed).toBeDefined();
51+
52+
// Check mobile and anonymous objects
53+
expect(data.mobile).toBeDefined();
54+
expect(typeof data.mobile).toBe("object");
55+
expect(data.anonymous).toBeDefined();
56+
expect(typeof data.anonymous).toBe("object");
57+
expect(data.anonymous.is_proxy).toBeDefined();
58+
expect(data.anonymous.is_relay).toBeDefined();
59+
expect(data.anonymous.is_tor).toBeDefined();
60+
expect(data.anonymous.is_vpn).toBeDefined();
61+
62+
// Check all network/type flags
63+
expect(data.is_anonymous).toBeDefined();
64+
expect(data.is_anycast).toBeDefined();
65+
expect(data.is_hosting).toBeDefined();
66+
expect(data.is_mobile).toBeDefined();
67+
expect(data.is_satellite).toBeDefined();
68+
69+
// Check geo formatting was applied
70+
expect(data.geo.country_name).toBeDefined();
71+
expect(data.geo.isEU).toBeDefined();
72+
expect(data.geo.country_flag_url).toBeDefined();
73+
}
74+
});
75+
76+
testIfTokenIsSet("isBogon", async () => {
77+
const ipinfoWrapper = new IPinfoPlusWrapper(process.env.IPINFO_TOKEN!);
78+
79+
const data = (await ipinfoWrapper.lookupIp("198.51.100.1")) as IPBogon;
80+
expect(data.ip).toEqual("198.51.100.1");
81+
expect(data.bogon).toEqual(true);
82+
});
83+
84+
test("Error is thrown for invalid token", async () => {
85+
const ipinfo = new IPinfoPlusWrapper("invalid-token");
86+
await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow();
87+
});
88+
89+
test("Error is thrown when response cannot be parsed as JSON", async () => {
90+
const baseUrlWithUnparseableResponse = "https://ipinfo.io/developers#";
91+
92+
const ipinfo = new IPinfoPlusWrapper(
93+
"token",
94+
undefined,
95+
undefined,
96+
undefined,
97+
baseUrlWithUnparseableResponse
98+
);
99+
100+
await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow();
101+
102+
const result = await ipinfo
103+
.lookupIp("1.2.3.4")
104+
.then((_) => "parseable")
105+
.catch((_) => "unparseable");
106+
107+
expect(result).toEqual("unparseable");
108+
});
109+
});

src/common.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const HOST: string = "ipinfo.io";
22
export const HOST_LITE: string = "api.ipinfo.io/lite";
33
export const HOST_CORE: string = "api.ipinfo.io/lookup";
4+
export const HOST_PLUS: string = "api.ipinfo.io/lookup";
45

56
// cache version
67
export const CACHE_VSN: string = "1";
@@ -150,6 +151,57 @@ export interface IPinfoCore {
150151
is_satellite: boolean;
151152
}
152153

154+
export interface IPinfoPlus {
155+
ip: string;
156+
hostname: string;
157+
geo: {
158+
city: string;
159+
region: string;
160+
region_code: string;
161+
country: string;
162+
country_code: string;
163+
continent: string;
164+
continent_code: string;
165+
latitude: number;
166+
longitude: number;
167+
timezone: string;
168+
postal_code: string;
169+
dma_code: string;
170+
geoname_id: string;
171+
radius: number;
172+
last_changed?: string;
173+
country_name?: string;
174+
isEU?: boolean;
175+
country_flag?: CountryFlag;
176+
country_currency?: CountryCurrency;
177+
country_flag_url?: string;
178+
};
179+
as: {
180+
asn: string;
181+
name: string;
182+
domain: string;
183+
type: string;
184+
last_changed: string;
185+
};
186+
mobile: {
187+
name?: string;
188+
mcc?: string;
189+
mnc?: string;
190+
};
191+
anonymous: {
192+
is_proxy: boolean;
193+
is_relay: boolean;
194+
is_tor: boolean;
195+
is_vpn: boolean;
196+
name?: string;
197+
};
198+
is_anonymous: boolean;
199+
is_anycast: boolean;
200+
is_hosting: boolean;
201+
is_mobile: boolean;
202+
is_satellite: boolean;
203+
}
204+
153205
export interface Prefix {
154206
netblock: string;
155207
id: string;

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import IPinfoWrapper from "./ipinfoWrapper";
22
import IPinfoLiteWrapper from "./ipinfoLiteWrapper";
33
import IPinfoCoreWrapper from "./ipinfoCoreWrapper";
4+
import IPinfoPlusWrapper from "./ipinfoPlusWrapper";
45
import Cache from "./cache/cache";
56
import LruCache from "./cache/lruCache";
67
import ApiLimitError from "./errors/apiLimitError";
@@ -13,6 +14,7 @@ export {
1314
IPinfoWrapper,
1415
IPinfoLiteWrapper,
1516
IPinfoCoreWrapper,
17+
IPinfoPlusWrapper,
1618
ApiLimitError
1719
};
1820
export {
@@ -24,6 +26,7 @@ export {
2426
Domains,
2527
IPinfo,
2628
IPinfoCore,
29+
IPinfoPlus,
2730
Prefix,
2831
Prefixes6,
2932
AsnResponse,

src/ipinfoPlusWrapper.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import fetch from "node-fetch";
2+
import type { RequestInit, Response } from "node-fetch";
3+
import {
4+
defaultContinents,
5+
defaultCountriesCurrencies,
6+
defaultCountriesFlags,
7+
defaultCountries,
8+
defaultEuCountries
9+
} from "../config/utils";
10+
import Cache from "./cache/cache";
11+
import LruCache from "./cache/lruCache";
12+
import ApiLimitError from "./errors/apiLimitError";
13+
import { isInSubnet } from "subnet-check";
14+
import {
15+
REQUEST_TIMEOUT_DEFAULT,
16+
CACHE_VSN,
17+
HOST_PLUS,
18+
BOGON_NETWORKS,
19+
IPinfoPlus,
20+
IPBogon
21+
} from "./common";
22+
import VERSION from "./version";
23+
24+
const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`;
25+
const countryFlagURL = "https://cdn.ipinfo.io/static/images/countries-flags/";
26+
27+
export default class IPinfoPlusWrapper {
28+
private token: string;
29+
private baseUrl: string;
30+
private countries: any;
31+
private countriesFlags: any;
32+
private countriesCurrencies: any;
33+
private continents: any;
34+
private euCountries: Array<string>;
35+
private cache: Cache;
36+
private timeout: number;
37+
38+
/**
39+
* Creates IPinfoPlusWrapper object to communicate with the IPinfo Plus API.
40+
*
41+
* @param token Token string provided by IPinfo for registered user.
42+
* @param cache An implementation of IPCache interface. If it is not provided
43+
* then LruCache is used as default.
44+
* @param timeout Timeout in milliseconds that controls the timeout of requests.
45+
* It defaults to 5000 i.e. 5 seconds. A timeout of 0 disables the timeout feature.
46+
* @param i18nData Internationalization data for customizing countries-related information.
47+
* @param i18nData.countries Custom countries data. If not provided, default countries data will be used.
48+
* @param i18nData.countriesFlags Custom countries flags data. If not provided, default countries flags data will be used.
49+
* @param i18nData.countriesCurrencies Custom countries currencies data. If not provided, default countries currencies data will be used.
50+
* @param i18nData.continents Custom continents data. If not provided, default continents data will be used.
51+
* @param i18nData.euCountries Custom EU countries data. If not provided or an empty array, default EU countries data will be used.
52+
*/
53+
constructor(
54+
token: string,
55+
cache?: Cache,
56+
timeout?: number,
57+
i18nData?: {
58+
countries?: any;
59+
countriesFlags?: any;
60+
countriesCurrencies?: any;
61+
continents?: any;
62+
euCountries?: Array<string>;
63+
},
64+
baseUrl?: string
65+
) {
66+
this.token = token;
67+
this.countries = i18nData?.countries
68+
? i18nData.countries
69+
: defaultCountries;
70+
this.countriesFlags = i18nData?.countriesFlags
71+
? i18nData.countriesFlags
72+
: defaultCountriesFlags;
73+
this.countriesCurrencies = i18nData?.countriesCurrencies
74+
? i18nData.countriesCurrencies
75+
: defaultCountriesCurrencies;
76+
this.continents = i18nData?.continents
77+
? i18nData.continents
78+
: defaultContinents;
79+
this.euCountries =
80+
i18nData?.euCountries && i18nData?.euCountries.length !== 0
81+
? i18nData.euCountries
82+
: defaultEuCountries;
83+
this.cache = cache ? cache : new LruCache();
84+
this.timeout =
85+
timeout === null || timeout === undefined
86+
? REQUEST_TIMEOUT_DEFAULT
87+
: timeout;
88+
this.baseUrl = baseUrl || `https://${HOST_PLUS}`;
89+
}
90+
91+
public static cacheKey(k: string) {
92+
return `${k}:${CACHE_VSN}`;
93+
}
94+
95+
public async fetchApi(
96+
path: string,
97+
init: RequestInit = {}
98+
): Promise<Response> {
99+
const headers = {
100+
Accept: "application/json",
101+
Authorization: `Bearer ${this.token}`,
102+
"Content-Type": "application/json",
103+
"User-Agent": clientUserAgent
104+
};
105+
106+
const request = Object.assign(
107+
{
108+
timeout: this.timeout,
109+
method: "GET",
110+
compress: false
111+
},
112+
init,
113+
{ headers: Object.assign(headers, init.headers) }
114+
);
115+
116+
const url = [this.baseUrl, path].join(
117+
!this.baseUrl.endsWith("/") && !path.startsWith("/") ? "/" : ""
118+
);
119+
120+
return fetch(url, request).then((response: Response) => {
121+
if (response.status === 429) {
122+
throw new ApiLimitError();
123+
}
124+
125+
if (response.status >= 400) {
126+
throw new Error(
127+
`Received an error from the IPinfo API ` +
128+
`(using authorization ${headers["Authorization"]}) ` +
129+
`${response.status} ${response.statusText} ${response.url}`
130+
);
131+
}
132+
133+
return response;
134+
});
135+
}
136+
137+
/**
138+
* Lookup IP information using the IP.
139+
*
140+
* @param ip IP address against which the location information is required.
141+
* @return Response containing location information.
142+
*/
143+
public async lookupIp(
144+
ip: string | undefined = undefined
145+
): Promise<IPinfoPlus | IPBogon> {
146+
if (ip && this.isBogon(ip)) {
147+
return {
148+
ip,
149+
bogon: true
150+
};
151+
}
152+
153+
if (!ip) {
154+
ip = "me";
155+
}
156+
157+
const data = await this.cache.get(IPinfoPlusWrapper.cacheKey(ip));
158+
159+
if (data) {
160+
return data;
161+
}
162+
163+
return this.fetchApi(ip).then(async (response) => {
164+
const ipinfo = (await response.json()) as IPinfoPlus;
165+
166+
// Format geo object
167+
const countryCode = ipinfo.geo.country_code;
168+
ipinfo.geo.country_name = this.countries[countryCode];
169+
ipinfo.geo.isEU = this.euCountries.includes(countryCode);
170+
ipinfo.geo.country_flag = this.countriesFlags[countryCode];
171+
ipinfo.geo.country_currency =
172+
this.countriesCurrencies[countryCode];
173+
ipinfo.geo.continent = this.continents[countryCode];
174+
ipinfo.geo.country_flag_url =
175+
countryFlagURL + countryCode + ".svg";
176+
177+
this.cache.set(IPinfoPlusWrapper.cacheKey(ip), ipinfo);
178+
179+
return ipinfo;
180+
});
181+
}
182+
183+
private isBogon(ip: string): boolean {
184+
if (ip != "") {
185+
for (var network of BOGON_NETWORKS) {
186+
if (isInSubnet(ip, network)) {
187+
return true;
188+
}
189+
}
190+
}
191+
return false;
192+
}
193+
}

0 commit comments

Comments
 (0)