diff --git a/CLAUDE.md b/CLAUDE.md index 1e2a764..42572ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ npx prettier --write . # Format all files (respects .prettierignore) ``` Makefile targets (used by CI): + ```bash make test # fmt + test (installs deps if needed) make build # Rollup build @@ -26,7 +27,7 @@ To run a single test file: npx mocha --require tsx/cjs tests/test_RetrySender.ts ``` -Build outputs dual formats via Rollup: `dist/cjs/` (CommonJS), `dist/esm/` (ESM), and `dist/types/` (declarations). Rollup preserves module structure (`preserveModules: true`). Axios and axios-retry are external dependencies (not bundled). +Build outputs dual formats via Rollup: `dist/cjs/` (CommonJS), `dist/esm/` (ESM), and `dist/types/` (declarations). Rollup preserves module structure (`preserveModules: true`). `undici` is the only external dependency (not bundled). ## Architecture @@ -41,7 +42,7 @@ CustomQuerySender → LicenseSender → BaseUrlSender → CustomHeaderSender → AgentSender → RetrySender → SigningSender → StatusCodeSender → HttpSender ``` -Each sender adds specific functionality (authentication, retries, headers, etc.). The `HttpSender` at the end uses Axios for actual HTTP transport. +Each sender adds specific functionality (authentication, retries, headers, etc.). The `HttpSender` at the end uses the Fetch API for HTTP transport (with optional `undici` `ProxyAgent` for proxy support in Node.js). All senders implement the `Sender` interface from `src/types.ts`, which also defines the core `Request` and `Response` contracts that flow through the chain. diff --git a/UPGRADING.md b/UPGRADING.md index 1d28c54..e121b3d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -88,11 +88,11 @@ console.log(response.lookups[0].result); ## Import Patterns -| Style | Syntax | -| --- | --- | -| ESM default import | `import SmartySDK from "smartystreets-javascript-sdk"` | -| ESM named imports | `import { core, usStreet } from "smartystreets-javascript-sdk"` | -| CommonJS | `const SmartySDK = require("smartystreets-javascript-sdk")` | +| Style | Syntax | +| ---------------------- | ------------------------------------------------------------------- | +| ESM default import | `import SmartySDK from "smartystreets-javascript-sdk"` | +| ESM named imports | `import { core, usStreet } from "smartystreets-javascript-sdk"` | +| CommonJS | `const SmartySDK = require("smartystreets-javascript-sdk")` | | TypeScript type import | `import type { MatchStrategy } from "smartystreets-javascript-sdk"` | ## FAQ diff --git a/examples/international_address_autocomplete.ts b/examples/international_address_autocomplete.ts index 11a79ff..9fb80c9 100644 --- a/examples/international_address_autocomplete.ts +++ b/examples/international_address_autocomplete.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalAddressAutocomplete } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalAddressAutocomplete, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; @@ -41,7 +45,10 @@ function logSuggestions(response: LookupInternationalAddressAutocomplete, messag console.log("\n"); } -async function handleRequest(lookup: LookupInternationalAddressAutocomplete, lookupType: string): Promise { +async function handleRequest( + lookup: LookupInternationalAddressAutocomplete, + lookupType: string, +): Promise { try { const results = await client.send(lookup); logSuggestions(results, lookupType); diff --git a/examples/international_postal_code.ts b/examples/international_postal_code.ts index 7f31e7b..b672506 100644 --- a/examples/international_postal_code.ts +++ b/examples/international_postal_code.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalPostalCode } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalPostalCode, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; @@ -43,7 +47,10 @@ function displayResult(lookup: LookupInternationalPostalCode, message: string): console.log("\n"); } -async function handleResponse(lookup: LookupInternationalPostalCode, lookupType: string): Promise { +async function handleResponse( + lookup: LookupInternationalPostalCode, + lookupType: string, +): Promise { try { const result = await client.send(lookup); displayResult(result, lookupType); @@ -59,7 +66,13 @@ async function main(): Promise { // lookup1.addCustomParameter("input_id", 1234); // Lookup by locality, administrative area, and country - const lookup2 = new LookupInternationalPostalCode("Brazil", undefined, "SP", "Sao Paulo", "ID-8675309"); + const lookup2 = new LookupInternationalPostalCode( + "Brazil", + undefined, + "SP", + "Sao Paulo", + "ID-8675309", + ); await handleResponse(lookup1, "Postal code lookup"); await handleResponse(lookup2, "Locality and administrative area lookup"); diff --git a/examples/international_street.ts b/examples/international_street.ts index 6b78e41..3129238 100644 --- a/examples/international_street.ts +++ b/examples/international_street.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalStreet } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalStreet, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_autocomplete_pro.ts b/examples/us_autocomplete_pro.ts index 6e6842b..af3a018 100644 --- a/examples/us_autocomplete_pro.ts +++ b/examples/us_autocomplete_pro.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSAutocompletePro } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSAutocompletePro, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_enrichment.ts b/examples/us_enrichment.ts index 593274a..be7c53a 100644 --- a/examples/us_enrichment.ts +++ b/examples/us_enrichment.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSEnrichment } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSEnrichment, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_reverse_geo.ts b/examples/us_reverse_geo.ts index 72b49ba..f9042d1 100644 --- a/examples/us_reverse_geo.ts +++ b/examples/us_reverse_geo.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSReverseGeo } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSReverseGeo, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_street.ts b/examples/us_street.ts index 12203ae..cc11022 100644 --- a/examples/us_street.ts +++ b/examples/us_street.ts @@ -1,4 +1,9 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSStreet, Batch } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSStreet, + Batch, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_zipcode.ts b/examples/us_zipcode.ts index ac887d6..1f555d7 100644 --- a/examples/us_zipcode.ts +++ b/examples/us_zipcode.ts @@ -1,4 +1,9 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSZipcode, Batch } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSZipcode, + Batch, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/package-lock.json b/package-lock.json index fd36778..d9cf363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,8 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "axios": "^1.13.2", - "axios-retry": "^4.5.0", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "undici": "^7.0.0" }, "devDependencies": { "@babel/preset-env": "^7.28.5", @@ -2736,35 +2735,6 @@ "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -2901,19 +2871,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3102,18 +3059,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3241,15 +3186,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -3260,20 +3196,6 @@ "node": ">=0.3.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3295,51 +3217,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", @@ -3507,26 +3384,6 @@ "flat": "cli.js" } }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3544,22 +3401,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3579,6 +3420,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3605,43 +3447,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -3710,18 +3515,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3732,37 +3525,11 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3900,18 +3667,6 @@ "@types/estree": "*" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4055,15 +3810,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4101,27 +3847,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", @@ -4364,12 +4089,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4915,6 +4634,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 12ee9ea..a934e3e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "test": "mocha --require tsx/cjs 'tests/**/*.ts'" }, "author": "Smarty SDK Team (https://www.smarty.com)", + "engines": { + "node": ">=20.0.0" + }, "license": "Apache-2.0", "repository": { "type": "git", @@ -72,8 +75,9 @@ "serialize-javascript": "^7.0.3" }, "dependencies": { - "axios": "^1.13.2", - "axios-retry": "^4.5.0", "tslib": "^2.8.1" + }, + "optionalDependencies": { + "undici": "^7.0.0" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index afd4b2d..cac9b8e 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -7,7 +7,7 @@ import typescript from "@rollup/plugin-typescript"; export default { input: "index.ts", - external: ["axios", "axios-retry"], + external: ["undici"], output: [ { dir: "dist", diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index aecfecd..0e9e121 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -23,7 +23,6 @@ import InternationalAddressAutocompleteClient from "./international_address_auto import UsEnrichmentClient from "./us_enrichment/Client.js"; import InternationalPostalCodeClient from "./international_postal_code/Client.js"; import { Sender } from "./types.js"; -import { AxiosProxyConfig } from "axios"; const INTERNATIONAL_STREET_API_URI = "https://international-street.api.smarty.com/verify"; const US_AUTOCOMPLETE_PRO_API_URL = "https://us-autocomplete-pro.api.smarty.com/lookup"; @@ -44,10 +43,10 @@ export default class ClientBuilder { private maxRetries: number; private maxTimeout: number; private baseUrl: string | undefined; - private proxy: AxiosProxyConfig | undefined; + private proxy: { url: string } | undefined; private customHeaders: Record; private appendHeaders: Record; - private debug: boolean | undefined; + private debug: boolean; private licenses: string[]; private customQueries: Map; @@ -69,7 +68,7 @@ export default class ClientBuilder { this.proxy = undefined; this.customHeaders = {}; this.appendHeaders = {}; - this.debug = undefined; + this.debug = false; this.licenses = []; this.customQueries = new Map(); } @@ -94,24 +93,34 @@ export default class ClientBuilder { return this; } + withProxy(url: string): ClientBuilder; withProxy( host: string, port: number, protocol: string, username?: string, password?: string, + ): ClientBuilder; + withProxy( + hostOrUrl: string, + port?: number, + protocol?: string, + username?: string, + password?: string, ): ClientBuilder { - this.proxy = { - host: host, - port: port, - protocol: protocol, - }; - - if (username && password) { - this.proxy.auth = { - username: username, - password: password, - }; + if (port === undefined) { + this.proxy = { url: hostOrUrl }; + } else { + if (!protocol) { + throw new Error( + 'withProxy() requires a protocol (e.g. "http" or "https") when using host/port arguments.', + ); + } + let auth = ""; + if (username && password) { + auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + } + this.proxy = { url: `${protocol}://${auth}${hostOrUrl}:${port}` }; } return this; @@ -183,9 +192,11 @@ export default class ClientBuilder { const conflicts: string[] = []; if (this.maxTimeout !== 10000) conflicts.push("withMaxTimeout()"); if (this.proxy !== undefined) conflicts.push("withProxy()"); - if (this.debug !== undefined) conflicts.push("withDebug()"); + if (this.debug) conflicts.push("withDebug()"); if (conflicts.length > 0) - throw new Error(`withSender() cannot be combined with: ${conflicts.join(", ")}. These options only apply to the built-in HTTP transport.`); + throw new Error( + `withSender() cannot be combined with: ${conflicts.join(", ")}. These options only apply to the built-in HTTP transport.`, + ); } const httpSender = this.httpSender ?? new HttpSender(this.maxTimeout, this.proxy, this.debug); const statusCodeSender = new StatusCodeSender(httpSender); diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 901e8dd..a52a5d2 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -1,69 +1,145 @@ -import Axios, { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig } from "axios"; import { buildSmartyResponse } from "./util/buildSmartyResponse.js"; import { Request as SmartyRequest, Response as SmartyResponse } from "./types.js"; +type FetchFunction = typeof fetch; + export default class HttpSender { - private axiosInstance: AxiosInstance; private timeout: number; - private proxyConfig: AxiosProxyConfig | undefined; + private debug: boolean; + private fetchFn: FetchFunction | undefined; + private dispatcher: import("undici").Dispatcher | undefined; + private proxyReady: Promise | undefined; - constructor(timeout: number = 10000, proxyConfig?: AxiosProxyConfig, debug: boolean = false) { - this.axiosInstance = Axios.create(); + constructor( + timeout: number = 10000, + proxyConfig?: { url: string }, + debug = false, + fetchFn?: FetchFunction, + ) { this.timeout = timeout; - this.proxyConfig = proxyConfig; - if (debug) this.enableDebug(); + this.debug = debug; + this.fetchFn = fetchFn; + + if (proxyConfig) { + this.proxyReady = this.initProxy(proxyConfig); + } + } + + private resolveFetch(): FetchFunction { + if (this.fetchFn) return this.fetchFn; + if (typeof globalThis.fetch === "function") { + this.fetchFn = globalThis.fetch.bind(globalThis); + return this.fetchFn; + } + throw new Error( + "No fetch implementation available. Provide one via the fetchFn constructor parameter.", + ); + } + + private async initProxy(config: { url: string }): Promise { + let ProxyAgent: typeof import("undici").ProxyAgent; + try { + ({ ProxyAgent } = await import("undici")); + } catch { + throw new Error( + "The 'undici' package is required for proxy support. Install it with: npm install undici", + ); + } + try { + this.dispatcher = new ProxyAgent(config.url); + } catch (error) { + const err = new Error( + `Invalid proxy URL: "${config.url}". The proxy URL must be an origin (scheme + host + port) with no path, query, or fragment.`, + ); + (err as Error & { cause: unknown }).cause = error; + throw err; + } } - buildRequestConfig(request: SmartyRequest): AxiosRequestConfig { - const config: AxiosRequestConfig = { - method: "GET", - timeout: this.timeout, - params: request.parameters, + buildFetchArgs(request: SmartyRequest): { url: string; init: RequestInit } { + const url = this.buildUrl(request); + + const init: RequestInit = { + method: request.payload ? "POST" : "GET", headers: request.headers, - baseURL: request.baseUrl, - validateStatus: function (status: number) { - return status < 500; - }, + signal: AbortSignal.timeout(this.timeout), }; if (request.payload) { - config.method = "POST"; - config.data = request.payload; + init.body = + typeof request.payload === "string" ? request.payload : JSON.stringify(request.payload); } - if (this.proxyConfig) config.proxy = this.proxyConfig; - return config; + if (this.dispatcher) { + (init as Record)["dispatcher"] = this.dispatcher; + } + + return { url, init }; } - send(request: SmartyRequest): Promise { - return new Promise((resolve, reject) => { - const requestConfig = this.buildRequestConfig(request); + async send(request: SmartyRequest): Promise { + if (this.proxyReady) await this.proxyReady; - this.axiosInstance(requestConfig) - .then((response) => { - const smartyResponse = buildSmartyResponse(response); + const fetchFn = this.resolveFetch(); + const { url, init } = this.buildFetchArgs(request); - if (smartyResponse.statusCode >= 400) return reject(smartyResponse); + if (this.debug) { + console.log("Request:\r\n", { url, ...init }); + console.log("\r\n*******************************************\r\n"); + } + + let response: globalThis.Response | undefined; + try { + response = await fetchFn(url, init); + const data = await this.parseResponseBody(response); + const headers = Object.fromEntries(response.headers.entries()); + + if (this.debug) { + console.log("Response:\r\n"); + console.log("Status:", response.status, response.statusText); + console.log("Headers:", headers); + console.log("Data:", data); + } + + const smartyResponse = buildSmartyResponse({ status: response.status, data, headers }); - resolve(smartyResponse); - }) - .catch((error) => reject(buildSmartyResponse(undefined, error))); - }); + if (smartyResponse.statusCode >= 400) throw smartyResponse; + + return smartyResponse; + } catch (error) { + if (error && typeof error === "object" && "statusCode" in error) throw error; + + const wrappedError = error instanceof Error ? error : new Error(String(error)); + if (response) { + throw buildSmartyResponse({ + status: response.status, + error: wrappedError, + headers: Object.fromEntries(response.headers.entries()), + }); + } + throw buildSmartyResponse(undefined, wrappedError); + } } - private enableDebug(): void { - this.axiosInstance.interceptors.request.use((request) => { - console.log("Request:\r\n", request); - console.log("\r\n*******************************************\r\n"); - return request; - }); - - this.axiosInstance.interceptors.response.use((response) => { - console.log("Response:\r\n"); - console.log("Status:", response.status, response.statusText); - console.log("Headers:", response.headers); - console.log("Data:", response.data); - return response; - }); + private buildUrl(request: SmartyRequest): string { + if (!request.baseUrl || !/^https?:\/\//i.test(request.baseUrl)) { + throw new Error(`Invalid baseUrl: "${request.baseUrl}". Expected an absolute HTTP(S) URL.`); + } + const url = new URL(request.baseUrl); + for (const [key, value] of Object.entries(request.parameters)) { + url.searchParams.append(key, String(value)); + } + return url.toString(); + } + + private async parseResponseBody( + response: globalThis.Response, + ): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return await response.json(); + } + const text = await response.text(); + return text || null; } } diff --git a/src/RetrySender.ts b/src/RetrySender.ts index 988874b..a608b32 100644 --- a/src/RetrySender.ts +++ b/src/RetrySender.ts @@ -18,7 +18,7 @@ export default class RetrySender { } async send(request: Request): Promise { - let response = await this.inner.send(request); + let response = await this.trySend(request); for (let i = 0; i < this.maxRetries; i++) { const statusCode = response.statusCode; @@ -29,7 +29,7 @@ export default class RetrySender { if (statusCode === this.statusTooManyRequests) { let secondsToBackoff = 10; if (response.headers) { - const retryAfterHeader = response.headers["Retry-After"]; + const retryAfterHeader = response.headers["retry-after"]; if (Number.isInteger(Number(retryAfterHeader))) { secondsToBackoff = Number(retryAfterHeader); } @@ -38,12 +38,25 @@ export default class RetrySender { } else { await this.backoff(i); } - response = await this.inner.send(request); + response = await this.trySend(request); } + if (!response.statusCode || response.statusCode >= 400) throw response; + return response; } + private async trySend(request: Request): Promise { + try { + return await this.inner.send(request); + } catch (error) { + if (error && typeof error === "object" && "statusCode" in error) { + return error as Response; + } + throw error; + } + } + private async backoff(attempt: number): Promise { const backoffDuration = Math.min(attempt, this.maxBackoffDuration); console.log( diff --git a/src/StatusCodeSender.ts b/src/StatusCodeSender.ts index f32b301..43a6d2d 100644 --- a/src/StatusCodeSender.ts +++ b/src/StatusCodeSender.ts @@ -20,6 +20,10 @@ export default class StatusCodeSender { .then(resolve) .catch((error: Response) => { switch (error.statusCode) { + case 0: + error.error = error.error ?? new DefaultError("Network error: unable to connect."); + break; + case 500: error.error = new InternalServerError(); break; @@ -36,7 +40,8 @@ export default class StatusCodeSender { const payload = error.payload as { errors?: { message?: string }[]; } | null; - error.error = new DefaultError(payload?.errors?.[0]?.message); + const message = payload?.errors?.[0]?.message; + error.error = new DefaultError(message ?? error.error?.message); } } reject(error); diff --git a/src/types.ts b/src/types.ts index a0accf0..553c5af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,14 +30,3 @@ export interface BaseLookup { customParameters: Record; result: { inputIndex: number }[]; } - -export interface MockSenderInstance extends Sender { - statusCodes: number[]; - headers?: Record | undefined; - error?: string | undefined; - currentStatusCodeIndex: number; -} - -export interface MockSleeperInstance extends Sleeper { - sleepDurations: number[]; -} diff --git a/src/util/buildSmartyResponse.ts b/src/util/buildSmartyResponse.ts index b2f1e12..04a0501 100644 --- a/src/util/buildSmartyResponse.ts +++ b/src/util/buildSmartyResponse.ts @@ -1,14 +1,24 @@ import Response from "../Response.js"; -interface AxiosLikeResponse { +interface HttpResponse { status: number; data?: object[] | object | string | null | undefined; - error?: any; - headers?: Record | undefined; + error?: string | Error | undefined; + headers?: Record | undefined; } -export function buildSmartyResponse(response?: AxiosLikeResponse, error?: Error): Response { +function normalizeError(error: string | Error | undefined | null): Error | null { + if (error == null) return null; + return error instanceof Error ? error : new Error(error); +} + +export function buildSmartyResponse(response?: HttpResponse, error?: Error): Response { if (response) - return new Response(response.status, response.data, response.error, response.headers); + return new Response( + response.status, + response.data, + normalizeError(response.error), + response.headers, + ); return new Response(0, null, error); } diff --git a/tests/fixtures/mock_senders.ts b/tests/fixtures/mock_senders.ts index 4ab6ed2..56a3837 100644 --- a/tests/fixtures/mock_senders.ts +++ b/tests/fixtures/mock_senders.ts @@ -43,18 +43,18 @@ export class MockSenderWithResponse { export class MockSenderWithStatusCodesAndHeaders { statusCodes: number[]; - headers: Record | undefined; + headers: Record | undefined; error: string | undefined; currentStatusCodeIndex: number; - constructor(statusCodes: number[], headers?: Record, error?: string) { + constructor(statusCodes: number[], headers?: Record, error?: string) { this.statusCodes = statusCodes; this.headers = headers; this.error = error; this.currentStatusCodeIndex = 0; } - send(_request: IRequest) { + async send(_request: IRequest): Promise { const mockResponse = { status: this.statusCodes[this.currentStatusCodeIndex], headers: this.headers, @@ -62,6 +62,7 @@ export class MockSenderWithStatusCodesAndHeaders { }; const response = buildSmartyResponse(mockResponse); this.currentStatusCodeIndex += 1; + if (response.statusCode >= 400) throw response; return response; } } diff --git a/tests/international_street/test_Client.ts b/tests/international_street/test_Client.ts index 28a84e0..7e987a6 100644 --- a/tests/international_street/test_Client.ts +++ b/tests/international_street/test_Client.ts @@ -17,14 +17,20 @@ describe("An International Street client", function () { let mockSender = new MockSender(); let client = new Client(mockSender); - expect(() => client.send(new Lookup())).to.throw(errors.UnprocessableEntityError, "Country field is required."); + expect(() => client.send(new Lookup())).to.throw( + errors.UnprocessableEntityError, + "Country field is required.", + ); }); it("throws an error if sending a lookup with country but missing freeform and address1.", function () { let mockSender = new MockSender(); let client = new Client(mockSender); - expect(() => client.send(new Lookup("CA"))).to.throw(errors.UnprocessableEntityError, "Either freeform or address1 is required."); + expect(() => client.send(new Lookup("CA"))).to.throw( + errors.UnprocessableEntityError, + "Either freeform or address1 is required.", + ); }); it("correctly assigns request parameters based on lookup input.", function () { diff --git a/tests/test_ClientBuilder.ts b/tests/test_ClientBuilder.ts index a6b7dbb..c05c941 100644 --- a/tests/test_ClientBuilder.ts +++ b/tests/test_ClientBuilder.ts @@ -26,13 +26,23 @@ describe("ClientBuilder", function () { it("throws when withSender() is combined with withMaxTimeout().", function () { expect(() => - new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withMaxTimeout(5000).buildUsStreetApiClient() + new ClientBuilder(credentials) + .withSender({ + send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }), + }) + .withMaxTimeout(5000) + .buildUsStreetApiClient(), ).to.throw("withSender() cannot be combined with: withMaxTimeout()"); }); it("throws when withSender() is combined with withProxy().", function () { expect(() => - new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withProxy({ host: "localhost", port: 8080 }).buildUsStreetApiClient() + new ClientBuilder(credentials) + .withSender({ + send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }), + }) + .withProxy("localhost", 8080, "http") + .buildUsStreetApiClient(), ).to.throw("withSender() cannot be combined with: withProxy()"); }); @@ -45,7 +55,9 @@ describe("ClientBuilder", function () { }, }; - const client = new ClientBuilder(credentials).withSender(capturingSender).buildUsStreetApiClient(); + const client = new ClientBuilder(credentials) + .withSender(capturingSender) + .buildUsStreetApiClient(); const lookup = new Lookup(); lookup.street = "1 Rosedale"; diff --git a/tests/test_HttpSender.ts b/tests/test_HttpSender.ts index e69cf3a..121d33c 100644 --- a/tests/test_HttpSender.ts +++ b/tests/test_HttpSender.ts @@ -3,87 +3,211 @@ import Request from "../src/Request.js"; import HttpSender from "../src/HttpSender.js"; import { buildSmartyResponse } from "../src/util/buildSmartyResponse.js"; -describe("An Axios implementation of a HTTP sender", function () { - it("adds a data payload to the HTTP request config.", function () { - let expectedPayload = "test payload"; - let request = new Request(expectedPayload); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); - - expect(requestConfig.hasOwnProperty("data")).to.equal(true); - expect(requestConfig.data).to.equal(expectedPayload); +function mockFetchResponse( + status: number, + body: unknown = null, + headers: Record = { "content-type": "application/json" }, +): typeof fetch { + return async () => + new Response(body === null ? null : JSON.stringify(body), { + status, + headers, + }); +} + +function mockFetchError(error: Error): typeof fetch { + return async () => { + throw error; + }; +} + +describe("A fetch-based HTTP sender", function () { + it("builds a GET request when there is no payload.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { url, init } = sender.buildFetchArgs(request); + + expect(init.method).to.equal("GET"); + expect(init.body).to.be.undefined; + expect(url).to.equal("https://example.com/api"); }); - it("adds a POST method to the HTTP request config when appropriate.", function () { - let request = new Request("test payload"); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("builds a POST request when a payload is provided.", function () { + const payload = { key: "value" }; + const request = new Request(payload); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); - expect(requestConfig.hasOwnProperty("method")).to.equal(true); - expect(requestConfig.method).to.equal("POST"); + expect(init.method).to.equal("POST"); + expect(init.body).to.equal(JSON.stringify(payload)); }); - it("adds a GET method to the HTTP request config when appropriate.", function () { - let request = new Request(); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("sends a string payload as-is without JSON.stringify.", function () { + const payload = "raw string body"; + const request = new Request(payload); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); - expect(requestConfig.hasOwnProperty("method")).to.equal(true); - expect(requestConfig.method).to.equal("GET"); + expect(init.body).to.equal("raw string body"); }); - it("add a timeout to the HTTP request config.", function () { - let request = new Request("test payload"); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("appends query parameters to the URL.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + request.parameters["auth-id"] = "123"; + request.parameters["zipcode"] = "20500"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { url } = sender.buildFetchArgs(request); + + const parsed = new URL(url); + expect(parsed.searchParams.get("auth-id")).to.equal("123"); + expect(parsed.searchParams.get("zipcode")).to.equal("20500"); + }); + + it("includes headers in the fetch init.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.headers).to.deep.equal(request.headers); + expect((init.headers as Record)["Content-Type"]).to.equal( + "application/json; charset=utf-8", + ); + }); + + it("includes an abort signal for timeout.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(5000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.signal).to.be.an.instanceOf(AbortSignal); + }); + + it("uses the default timeout of 10000ms.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(undefined, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.signal).to.be.an.instanceOf(AbortSignal); + }); + + it("includes a dispatcher when proxy config is provided.", async function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + let capturedInit: RequestInit | undefined; + const capturingFetch: typeof fetch = async (_url, init) => { + capturedInit = init; + return new Response(JSON.stringify(null), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const sender = new HttpSender(10000, { url: "http://proxy:8080" }, false, capturingFetch); + + await sender.send(request); + + expect((capturedInit as Record)["dispatcher"]).to.not.be.undefined; + }); - let customTimeoutSender = new HttpSender(5); - let customTimeoutRequestConfig = customTimeoutSender.buildRequestConfig(request); + it("resolves with a SmartyResponse on a 2xx response.", async function () { + const mockData = [{ street: "123 Main St" }]; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200, mockData)); + const request = new Request(); + request.baseUrl = "https://example.com/api"; - expect(requestConfig.hasOwnProperty("timeout")).to.equal(true); - expect(requestConfig.timeout).to.equal(10000); + const response = await sender.send(request); - expect(customTimeoutRequestConfig.timeout).to.equal(5); + expect(response.statusCode).to.equal(200); + expect(response.payload).to.deep.equal(mockData); }); - it("adds parameters to the HTTP request config.", function () { - let request = new Request(""); - let sender = new HttpSender(); - request.parameters["test"] = "1"; - let requestConfig = sender.buildRequestConfig(request); + it("rejects with a SmartyResponse on a 4xx response.", async function () { + const sender = new HttpSender( + 10000, + undefined, + false, + mockFetchResponse(401, { error: "unauthorized" }), + ); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + } + }); - expect(requestConfig.hasOwnProperty("params")).to.equal(true); - expect(requestConfig.params).to.deep.equal(request.parameters); + it("rejects with a SmartyResponse on a network error.", async function () { + const networkError = new Error("fetch failed"); + const sender = new HttpSender(10000, undefined, false, mockFetchError(networkError)); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.equal(networkError); + } }); - it("adds headers to the HTTP request config.", function () { - let request = new Request(""); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("parses text response when content-type is not JSON.", async function () { + const textFetch: typeof fetch = async () => + new Response("plain text body", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + const sender = new HttpSender(10000, undefined, false, textFetch); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + const response = await sender.send(request); - expect(requestConfig.hasOwnProperty("headers")).to.equal(true); - expect(requestConfig.headers!["Content-Type"]).to.equal("application/json; charset=utf-8"); + expect(response.statusCode).to.equal(200); + expect(response.payload).to.equal("plain text body"); }); + it("captures response headers.", async function () { + const headerFetch: typeof fetch = async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { + "content-type": "application/json", + "x-custom": "value", + }, + }); + const sender = new HttpSender(10000, undefined, false, headerFetch); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + const response = await sender.send(request); + + expect(response.headers["x-custom"]).to.equal("value"); + }); +}); + +describe("buildSmartyResponse", function () { it("has a response with the right status code.", function () { - let mockResponse = { - status: 200, - }; - let smartyResponse = buildSmartyResponse(mockResponse); + const mockResponse = { status: 200 }; + const smartyResponse = buildSmartyResponse(mockResponse); - expect(smartyResponse.hasOwnProperty("statusCode")).to.equal(true); expect(smartyResponse.statusCode).to.equal(200); }); it("has a response with a payload.", function () { - let mockData = [1, 2, 3]; - let mockResponse = { - status: 200, - data: mockData, - }; - let smartyResponse = buildSmartyResponse(mockResponse); + const mockData = [1, 2, 3]; + const mockResponse = { status: 200, data: mockData }; + const smartyResponse = buildSmartyResponse(mockResponse); - expect(smartyResponse.hasOwnProperty("payload")).to.equal(true); expect(smartyResponse.payload).to.equal(mockData); }); }); diff --git a/tests/test_HttpSender_integration.ts b/tests/test_HttpSender_integration.ts new file mode 100644 index 0000000..b15cac2 --- /dev/null +++ b/tests/test_HttpSender_integration.ts @@ -0,0 +1,182 @@ +import { expect } from "chai"; +import * as http from "node:http"; +import Request from "../src/Request.js"; +import HttpSender from "../src/HttpSender.js"; + +let server: http.Server; +let baseUrl: string; + +function startServer(handler: http.RequestListener): Promise { + return new Promise((resolve) => { + server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); +} + +function stopServer(): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe("HttpSender integration (real fetch against local server)", function () { + afterEach(async function () { + if (server) await stopServer(); + }); + + it("sends a GET request and receives a JSON response.", async function () { + await startServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ method: req.method, url: req.url })); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + request.parameters["auth-id"] = "123"; + request.parameters["zipcode"] = "20500"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + const payload = response.payload as { method: string; url: string }; + expect(payload.method).to.equal("GET"); + expect(payload.url).to.include("auth-id=123"); + expect(payload.url).to.include("zipcode=20500"); + }); + + it("sends a POST request with a JSON body.", async function () { + let receivedBody = ""; + let receivedContentType = ""; + + await startServer((req, res) => { + receivedContentType = req.headers["content-type"] ?? ""; + req.on("data", (chunk: Buffer) => (receivedBody += chunk.toString())); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ method: req.method, body: JSON.parse(receivedBody) })); + }); + }); + + const payload = [{ street: "123 Main St" }, { street: "456 Oak Ave" }]; + const request = new Request(payload); + request.baseUrl = baseUrl + "/street-address"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + expect(receivedContentType).to.include("application/json"); + const result = response.payload as { method: string; body: object[] }; + expect(result.method).to.equal("POST"); + expect(result.body).to.deep.equal(payload); + }); + + it("rejects with the correct status on a 401 response.", async function () { + await startServer((_req, res) => { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + } + }); + + it("rejects with status 0 on a connection error.", async function () { + const request = new Request(); + request.baseUrl = "http://127.0.0.1:1"; // nothing listening + + const sender = new HttpSender(1000); + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.be.an("error"); + } + }); + + it("preserves custom headers through the request.", async function () { + let receivedHeaders: http.IncomingHttpHeaders = {}; + + await startServer((req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({})); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + request.headers["X-Custom"] = "test-value"; + + const sender = new HttpSender(); + await sender.send(request); + + expect(receivedHeaders["x-custom"]).to.equal("test-value"); + }); + + it("returns response headers to the caller.", async function () { + await startServer((_req, res) => { + res.writeHead(200, { + "Content-Type": "application/json", + "X-RateLimit-Remaining": "99", + }); + res.end(JSON.stringify({})); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.headers["x-ratelimit-remaining"]).to.equal("99"); + }); + + it("handles a plain text response.", async function () { + await startServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("plain text body"); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + expect(response.payload).to.equal("plain text body"); + }); + + it("times out when the server is too slow.", async function () { + await startServer((_req, _res) => { + // never respond + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(100); // 100ms timeout + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.not.be.null; + } + }); +}); diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index af93322..67429c8 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -2,45 +2,13 @@ import { expect } from "chai"; import RetrySender from "../src/RetrySender.js"; import { MockSenderWithStatusCodesAndHeaders } from "./fixtures/mock_senders.js"; import Request from "../src/Request.js"; -import Response from "../src/Response.js"; -import type { Sender, Sleeper, MockSenderInstance, MockSleeperInstance } from "../src/types"; - -class CompatibleMockSender implements MockSenderInstance { - statusCodes: number[]; - headers?: Record | undefined; - error?: string | undefined; - currentStatusCodeIndex: number; - private mockSender: { - send(request: Request): Response; - currentStatusCodeIndex: number; - }; - - constructor(statusCodes: number[], headers?: Record, error?: string) { - this.statusCodes = statusCodes; - this.headers = headers; - this.error = error; - this.currentStatusCodeIndex = 0; - this.mockSender = new (MockSenderWithStatusCodesAndHeaders as new ( - ...args: [number[], Record?, string?] - ) => { - send(request: Request): Response; - currentStatusCodeIndex: number; - })(statusCodes, headers, error); - } - - async send(request: Request): Promise { - const result = this.mockSender.send(request); - this.currentStatusCodeIndex = this.mockSender.currentStatusCodeIndex; - return Promise.resolve(result); - } -} +import type { Sender, Sleeper } from "../src/types"; -class CompatibleMockSleeper implements MockSleeperInstance { +class MockSleeper implements Sleeper { sleepDurations: number[] = []; async sleep(ms: number): Promise { this.sleepDurations.push(ms); - return Promise.resolve(); } } @@ -52,42 +20,52 @@ async function sendWithRetry(retries: number, inner: Sender, sleeper: Sleeper) { describe("Retry Sender tests", function () { it("test success does not retry", async function () { - let inner = new CompatibleMockSender([200]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); + const inner = new MockSenderWithStatusCodesAndHeaders([200]); + await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test client error does not retry", async function () { - let inner = new CompatibleMockSender([422]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); - - expect(inner.currentStatusCodeIndex).to.equal(1); + const inner = new MockSenderWithStatusCodesAndHeaders([422]); + + try { + await sendWithRetry(5, inner, new MockSleeper()); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number }; + expect(response.statusCode).to.equal(422); + expect(inner.currentStatusCodeIndex).to.equal(1); + } }); it("test will retry until success", async function () { - let inner = new CompatibleMockSender([500, 500, 500, 200, 500]); - await sendWithRetry(10, inner, new CompatibleMockSleeper()); + const inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 200, 500]); + await sendWithRetry(10, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(4); }); it("test return response if retry limit exceeded", async function () { - let inner = new CompatibleMockSender([500, 500, 500, 500, 500]); - const sleeper = new CompatibleMockSleeper(); - const response = await sendWithRetry(4, inner, sleeper); - - expect(response); - expect(inner.currentStatusCodeIndex).to.equal(5); - expect(response.statusCode).to.equal(500); - expect(sleeper.sleepDurations).to.deep.equal([0, 1, 2, 3]); + const inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 500, 500]); + const sleeper = new MockSleeper(); + + try { + await sendWithRetry(4, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number; error: Error }; + expect(inner.currentStatusCodeIndex).to.equal(5); + expect(response.statusCode).to.equal(500); + expect(sleeper.sleepDurations).to.deep.equal([0, 1, 2, 3]); + } }); it("test backoff does not exceed max", async function () { - let inner = new CompatibleMockSender([ + const inner = new MockSenderWithStatusCodesAndHeaders([ 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 200, ]); - const sleeper = new CompatibleMockSleeper(); + const sleeper = new MockSleeper(); await sendWithRetry(20, inner, sleeper); @@ -95,15 +73,19 @@ describe("Retry Sender tests", function () { }); it("test empty status does not retry", async function () { - let inner = new CompatibleMockSender([]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); - - expect(inner.currentStatusCodeIndex).to.equal(1); + const inner = new MockSenderWithStatusCodesAndHeaders([]); + + try { + await sendWithRetry(5, inner, new MockSleeper()); + expect.fail("Expected an error to be thrown"); + } catch { + expect(inner.currentStatusCodeIndex).to.equal(1); + } }); it("test sleep on rate limit", async function () { - let inner = new CompatibleMockSender([429, 200]); - const sleeper = new CompatibleMockSleeper(); + const inner = new MockSenderWithStatusCodesAndHeaders([429, 200]); + const sleeper = new MockSleeper(); await sendWithRetry(5, inner, sleeper); @@ -111,29 +93,40 @@ describe("Retry Sender tests", function () { }); it("test rate limit error return", async function () { - let inner = new CompatibleMockSender([429], { "Retry-After": 7 }); - const sleeper = new CompatibleMockSleeper(); - - await sendWithRetry(10, inner, sleeper); - - expect(sleeper.sleepDurations).to.deep.equal([7]); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "7" }); + const sleeper = new MockSleeper(); + + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch { + expect(sleeper.sleepDurations).to.deep.equal([7]); + } }); it("test retry after invalid value", async function () { - let inner = new CompatibleMockSender([429], { "Retry-After": "a" }); - const sleeper = new CompatibleMockSleeper(); - - await sendWithRetry(10, inner, sleeper); - - expect(sleeper.sleepDurations).to.deep.equal([10]); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "a" }); + const sleeper = new MockSleeper(); + + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch { + expect(sleeper.sleepDurations).to.deep.equal([10]); + } }); it("test retry error", async function () { - let inner = new CompatibleMockSender([429], undefined, "Big Bad"); - const sleeper = new CompatibleMockSleeper(); - - const response = await sendWithRetry(10, inner, sleeper); - - expect(response.error).to.equal("Big Bad"); + const inner = new MockSenderWithStatusCodesAndHeaders([429], undefined, "Big Bad"); + const sleeper = new MockSleeper(); + + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number; error: Error }; + expect(response.error).to.be.an.instanceOf(Error); + expect(response.error.message).to.equal("Big Bad"); + } }); }); diff --git a/tests/us_street/test_Client.ts b/tests/us_street/test_Client.ts index f8f3bcb..60bc5d1 100644 --- a/tests/us_street/test_Client.ts +++ b/tests/us_street/test_Client.ts @@ -220,8 +220,9 @@ describe("A US Street client", function () { client.send(batch); - expect(mockSender.request.payload[0].match).to.equal("strict"); - expect(mockSender.request.payload[1].match).to.equal("strict"); + const payload1 = mockSender.request.payload as Record[]; + expect(payload1[0]["match"]).to.equal("strict"); + expect(payload1[1]["match"]).to.equal("strict"); }); it("sends defaults in batch mode.", function () { @@ -233,8 +234,9 @@ describe("A US Street client", function () { client.send(batch); - expect(mockSender.request.payload[0].match).to.equal("enhanced"); - expect(mockSender.request.payload[0].candidates).to.equal(5); + const payload2 = mockSender.request.payload as Record[]; + expect(payload2[0]["match"]).to.equal("enhanced"); + expect(payload2[0]["candidates"]).to.equal(5); }); it("doesn't send an empty batch.", function () {