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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 3.0.2

- Hide API keys when printing client settings and request errors, including errors in typed bulk results.
- Stop redirects from sending lookup data or custom headers to another server.
- Keep the HTTP status when an error response is too large or interrupted. Check that typed bulk results match the number of IPs requested.
- Add Arabic (`ar`) for single and bulk lookups.
- Include CDN, proxy, bot and corporate gateway details in typed results.
- Report the installed package version and preserve special property names in headers and JSON output.

## 3.0.0

- Initial JavaScript SDK release for `/v3/ipgeo` and `/v3/ipgeo-bulk`
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on

You can also pass a custom `HttpTransport` as the second constructor argument.

If you set `baseUrl`, use the final API address. In Node.js, the built-in client follows redirects only within the same protocol, host and port. Browsers hide the redirect address, so the client stops at a redirect.

## Available Methods

| Method | Returns | Notes |
Expand All @@ -210,7 +212,7 @@ You can also pass a custom `HttpTransport` as the second constructor argument.
|--------|------------|-------|
| `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. Domain lookup requires a paid plan. |
| `ips` | Bulk lookup | Iterable of 1 to 50,000 IPs or domains. |
| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. |
| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). |
| `include` | Single and bulk | Iterable of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. |
| `fields` | Single and bulk | Iterable of field paths to keep, for example `["location.country_name", "security.threat_score"]`. |
| `excludes` | Single and bulk | Iterable of field paths to remove from the response. |
Expand Down Expand Up @@ -401,7 +403,7 @@ for (const result of response.data) {

### Mixed Success and Error Results

Each bulk result is either a success with `data` or an error with `error.message`.
Each bulk result is either a success with `data` or an error with `error.message`. If the API sends a different number of results than you requested, the typed lookup throws `SerializationError`. The raw lookup returns the response as received.

```js
const response = await client.bulkLookupIpGeolocation({
Expand Down Expand Up @@ -453,7 +455,7 @@ console.log(response.metadata.firstHeaderValue("x-credits-charged")); // the sam

## JSON Helpers

Use `toJson()` or `toPrettyJson()` when you want a stable JSON view of SDK objects.
Use `toJson()` or `toPrettyJson()` to print SDK objects as JSON. When you print a client or its settings as JSON, the SDK shows `[REDACTED]` in place of your API key. `config.apiKey` still gives you the key.

```js
import { toPrettyJson } from "ip-geolocation-api-javascript-sdk";
Expand All @@ -467,7 +469,7 @@ console.log(toPrettyJson(response.data));

## Error Handling

All SDK errors extend `IpGeolocationError`.
All SDK errors extend `IpGeolocationError`. If an API error includes your key, the SDK hides it. This also applies to error messages in typed bulk results. If the SDK receives an HTTP status but can’t read the error body, you still get the status code.

Runtime and validation errors:

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"author": "IPGeolocation.io",
"name": "ip-geolocation-api-javascript-sdk",
"version": "3.0.1",
"version": "3.0.2",
"description": "Official JavaScript SDK for the IPGeolocation IP Location API with single and bulk lookup, typed JSON, and raw JSON/XML support.",
"license": "MIT",
"type": "module",
Expand Down
7 changes: 6 additions & 1 deletion scripts/check-package.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ async function main() {
const esm = await import(pkg.name);
const cjs = require(pkg.name);
assert.deepEqual(Object.keys(esm).sort(), Object.keys(cjs).sort());
assert.equal(esm.getVersion(), cjs.getVersion());
await access(path.resolve(__dirname, '..', pkg.types));
await access(path.resolve(__dirname, '..', 'dist/index.d.cts'));

for (const sdk of [esm, cjs]) {
assert.equal(sdk.getVersion(), pkg.version);
assert.equal(sdk.SDK_VERSION, pkg.version);
assert.equal(sdk.IpGeolocationClient.defaultUserAgent(), `ipgeolocation-javascript-sdk/${pkg.version}`);
const config = new sdk.IpGeolocationClientConfig({ apiKey: 'package-test' });
assert.equal(config.baseUrl, 'https://api.ipgeolocation.io');
assert.equal(Object.isFrozen(config), true);
for (const formatter of [esm, cjs]) {
assert.equal(JSON.parse(formatter.toJson(config)).apiKey, '[REDACTED]');
}
const request = new sdk.LookupIpGeolocationRequest({ ip: '2001:db8::1' });
assert.equal(request.ip, '2001:db8::1');
assert.equal(request.output, sdk.ResponseFormat.JSON);
Expand Down
37 changes: 15 additions & 22 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildQuery,
mergeHeaders,
resolveUserAgentHeader,
safeTransportError,
toApiError,
toMetadata,
validateJsonOutput,
Expand All @@ -31,7 +32,7 @@ import { getVersion } from "./version";

export class IpGeolocationClient {
private readonly config: IpGeolocationClientConfig;
private readonly transport: HttpTransport;
readonly #transport: HttpTransport;
private readonly ownsTransport: boolean;
private closed = false;

Expand All @@ -47,7 +48,7 @@ export class IpGeolocationClient {
config instanceof IpGeolocationClientConfig
? config
: new IpGeolocationClientConfig(config);
this.transport = transport ?? new FetchHttpTransport();
this.#transport = transport ?? new FetchHttpTransport();
this.ownsTransport = transport == null;
}

Expand All @@ -66,12 +67,8 @@ export class IpGeolocationClient {
const httpRequest = this.buildLookupHttpRequest(normalizedRequest);
const { response, durationMs } = await this.executeWithMetrics(httpRequest);

if (Math.floor(response.statusCode / 100) !== 2) {
throw toApiError(response.statusCode, response.body);
}

return {
data: parseSingleLookup(response.body),
data: parseSingleLookup(response.body, this.config.apiKey),
metadata: toMetadata(response.statusCode, durationMs, response.headers),
};
}
Expand All @@ -86,10 +83,6 @@ export class IpGeolocationClient {
const httpRequest = this.buildLookupHttpRequest(normalizedRequest);
const { response, durationMs } = await this.executeWithMetrics(httpRequest);

if (Math.floor(response.statusCode / 100) !== 2) {
throw toApiError(response.statusCode, response.body);
}

return {
data: response.body,
metadata: toMetadata(response.statusCode, durationMs, response.headers),
Expand All @@ -109,12 +102,8 @@ export class IpGeolocationClient {
const httpRequest = this.buildBulkHttpRequest(normalizedRequest);
const { response, durationMs } = await this.executeWithMetrics(httpRequest);

if (Math.floor(response.statusCode / 100) !== 2) {
throw toApiError(response.statusCode, response.body);
}

return {
data: parseBulkLookup(response.body),
data: parseBulkLookup(response.body, normalizedRequest.ips.length, this.config.apiKey),
metadata: toMetadata(response.statusCode, durationMs, response.headers),
};
}
Expand All @@ -131,10 +120,6 @@ export class IpGeolocationClient {
const httpRequest = this.buildBulkHttpRequest(normalizedRequest);
const { response, durationMs } = await this.executeWithMetrics(httpRequest);

if (Math.floor(response.statusCode / 100) !== 2) {
throw toApiError(response.statusCode, response.body);
}

return {
data: response.body,
metadata: toMetadata(response.statusCode, durationMs, response.headers),
Expand All @@ -148,7 +133,7 @@ export class IpGeolocationClient {

this.closed = true;
if (this.ownsTransport) {
await this.transport.close();
await this.#transport.close();
}
}

Expand Down Expand Up @@ -269,7 +254,15 @@ export class IpGeolocationClient {
request: HttpRequestData,
): Promise<{ response: HttpResponseData; durationMs: number }> {
const started = Date.now();
const response = await this.transport.send(request);
let response: HttpResponseData;
try {
response = await this.#transport.send(request);
} catch (error) {
throw safeTransportError(error, this.config.apiKey);
}
if (Math.floor(response.statusCode / 100) !== 2) {
throw toApiError(response.statusCode, response.body, this.config.apiKey);
}
return {
response,
durationMs: Math.max(0, Date.now() - started),
Expand Down
11 changes: 9 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ValidationError } from "./errors";
import { configForOutput, configJson } from "./internal/redaction";
import { containsCrOrLf, isPlainObjectRecord } from "./internal/shared";

const DEFAULT_BASE_URL = "https://api.ipgeolocation.io";
Expand All @@ -15,7 +16,7 @@ export interface IpGeolocationClientConfigInit {
}

export class IpGeolocationClientConfig {
public readonly apiKey: string | undefined;
declare public readonly apiKey: string | undefined;
public readonly requestOrigin: string | undefined;
public readonly baseUrl: string;
public readonly connectTimeoutMs: number;
Expand All @@ -41,7 +42,13 @@ export class IpGeolocationClientConfig {
DEFAULT_READ_TIMEOUT_MS,
);

this.apiKey = apiKey;
const forOutput = () => configForOutput(this);
Object.defineProperties(this, {
apiKey: { enumerable: true, get: () => apiKey },
toJSON: { value: forOutput },
[configJson]: { value: forOutput },
[Symbol.for("nodejs.util.inspect.custom")]: { value: forOutput },
});
this.requestOrigin = requestOrigin;
this.baseUrl = baseUrl;
this.connectTimeoutMs = connectTimeoutMs;
Expand Down
1 change: 1 addition & 0 deletions src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const Language = Object.freeze({
KO: "ko",
FA: "fa",
PT: "pt",
AR: "ar",
} as const);

export type Language = (typeof Language)[keyof typeof Language];
Expand Down
125 changes: 125 additions & 0 deletions src/internal/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { IpGeolocationClientConfig } from "../config";

const MASK = "[REDACTED]";
export const configJson = Symbol.for("ipgeolocation.clientConfig.toJSON");

export function redactApiKey(text: string, key?: string): string {
if (!key) return text;
if (/^[A-Za-z0-9._-]+$/.test(key)) return text.split(key).join(MASK);

const form = new URLSearchParams({ k: key });
const encoded = encodeURIComponent(form.get("k")!);
let result = text;
for (const token of new Set([
encoded.replace(/'/g, "%27"),
encoded,
form.toString().slice(2),
])) {
if (token === key || token.length > result.length) continue;
const upperEscapes = (s: string) =>
s.replace(/%[a-f\d]{2}/gi, (escape) => escape.toUpperCase());
const haystack = upperEscapes(result);
const needle = upperEscapes(token);
let start = 0;
const parts: string[] = [];
for (
let at = haystack.indexOf(needle);
at >= 0;
at = haystack.indexOf(needle, start)
) {
parts.push(result.slice(start, at), MASK);
start = at + token.length;
}
if (parts.length) result = parts.join("") + result.slice(start);
}
const escaped = JSON.stringify(key).slice(1, -1);
if (escaped !== key) result = result.split(escaped).join(MASK);
return result.split(key).join(MASK);
}

const nativeStackGetter = Object.getOwnPropertyDescriptor(
new Error(),
"stack",
)?.get;
const inspectSymbol = Symbol.for("nodejs.util.inspect.custom");

export function hasSafeErrorDetails(
value: unknown,
key: string,
seen = new Set<object>(),
): boolean {
if (typeof value === "string") return redactApiKey(value, key) === value;
if (value === null || typeof value !== "object") {
return typeof value !== "function" && typeof value !== "symbol";
}
if (seen.has(value)) return true;
const proto = Object.getPrototypeOf(value);
if (
seen.size >= 32 ||
"toJSON" in value ||
Symbol.toPrimitive in value ||
inspectSymbol in value ||
(!(value instanceof Error) &&
!Array.isArray(value) &&
proto !== null &&
proto !== Object.prototype)
) {
return false;
}
seen.add(value);
if (value instanceof Error) {
for (
let p = proto;
p && p !== Error.prototype;
p = Object.getPrototypeOf(p)
) {
for (const name of Reflect.ownKeys(p)) {
if (name === "constructor") continue;
const field = Object.getOwnPropertyDescriptor(p, name)!;
if (
typeof name !== "string" ||
name === "toString" ||
name === "valueOf" ||
!("value" in field) ||
redactApiKey(name, key) !== name
)
return false;
if (
typeof field.value !== "function" &&
!hasSafeErrorDetails(field.value, key, seen)
)
return false;
}
}
}
for (const name of Reflect.ownKeys(value)) {
if (typeof name !== "string" || redactApiKey(name, key) !== name)
return false;
const property = Object.getOwnPropertyDescriptor(value, name)!;
let detail = property.value;
if (!("value" in property)) {
if (
name !== "stack" ||
!(value instanceof Error) ||
!nativeStackGetter ||
property.get !== nativeStackGetter
)
return false;
detail = nativeStackGetter.call(value);
}
if (!hasSafeErrorDetails(detail, key, seen)) return false;
}
return true;
}

export function configForOutput(config: IpGeolocationClientConfig) {
const key = config.apiKey;
return {
apiKey: key === undefined ? undefined : MASK,
requestOrigin:
config.requestOrigin && redactApiKey(config.requestOrigin, key),
baseUrl: redactApiKey(config.baseUrl, key),
connectTimeoutMs: config.connectTimeoutMs,
readTimeoutMs: config.readTimeoutMs,
};
}
Loading