diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b44764e..05f0e12 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: run: npm run build - name: check type run: npm run type-check - - name: check Axios header compatibility + - name: check authentication run: npm test - name: check npm diff id: diff diff --git a/README.md b/README.md index 0c4a0b0..a5f6ab8 100644 --- a/README.md +++ b/README.md @@ -29,27 +29,6 @@ api.getChannels().then(res => { }); ``` -### Custom Axios header defaults - -When making requests through this SDK, custom class instances in Axios header -defaults (`headers.common`, `headers.get`, `headers.post`, and other supported -methods) retain their behavior from Axios 1.20. Their own enumerable properties -are copied to plain header maps before Axios 1.21 prepares the request. Common -defaults apply to all methods; method defaults apply only to that method. - -This applies to both a supplied Axios instance and headers in -`Configuration.baseOptions` or per-request options. The original instance and -default objects are preserved, including request and response interceptors. -Plain maps, null-prototype maps, `AxiosHeaders`, and literal headers such as -`Link` remain supported. -Class instances under these reserved names are treated as defaults; serialize -a custom object first when it is intended as a literal method-named header. - -Direct requests made outside this SDK follow -[Axios 1.21's header-default rules](https://github.com/axios/axios/releases/tag/v1.21.0). -For those requests, convert custom class defaults explicitly, for example -`axios.create({ headers: { get: { ...customDefaults } } })`. - ## Versioning `x.y.z-n` diff --git a/build/addApis.ts b/build/addApis.ts index 386fb2e..f0d518d 100644 --- a/build/addApis.ts +++ b/build/addApis.ts @@ -41,20 +41,11 @@ export default async function(generatedFolder: string) { // Use Promise> to avoid Axios' non-exported types in declarations. const commonFile = project.getSourceFileOrThrow("common.ts") - commonFile.addImportDeclaration({ - namedImports: ["normalizeAxiosRequestHeaders"], - moduleSpecifier: "../../src/axiosHeaders", - }) const request = commonFile.getVariableDeclarationOrThrow("createRequestFunction") .getInitializerIfKindOrThrow(SyntaxKind.FunctionExpression) .getFirstDescendantByKindOrThrow(SyntaxKind.ArrowFunction) request.getTypeParameterOrThrow("R").remove() request.setReturnType("Promise>") - request.getVariableDeclarationOrThrow("axiosRequestArgs").setInitializer(`{ - ...axiosArgs.options, - url: (configuration?.basePath || basePath) + axiosArgs.url, - headers: normalizeAxiosRequestHeaders(axios, axiosArgs.options.headers), - }`) const response = request.getFirstDescendantByKindOrThrow(SyntaxKind.ReturnStatement) .getExpressionOrThrow() .asKindOrThrow(SyntaxKind.CallExpression) diff --git a/package.json b/package.json index 3ec5662..84a055c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "scripts": { "build": "tsx build/index.ts && tsdown", - "test": "node --test tests/axios-headers.test.mjs", + "test": "node --test tests/auth.test.mjs", "clean": "rimraf bin dist", "type-check": "tsc --noEmit --allowJS --project tsconfig.json" }, diff --git a/src/axiosHeaders.ts b/src/axiosHeaders.ts deleted file mode 100644 index eb07493..0000000 --- a/src/axiosHeaders.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { mergeConfig } from "axios"; -import type { AxiosInstance, AxiosRequestConfig, HeadersDefaults } from "axios"; - -type RequestHeaders = AxiosRequestConfig["headers"]; - -const defaultHeaderNames = new Set([ - "common", "delete", "get", "head", "post", "put", "patch", "options", - "purge", "link", "unlink", "query", -] satisfies (keyof HeadersDefaults)[]); - -function isClassHeaderDefaults(value: unknown): value is Record { - if (!value || typeof value !== "object") return false; - - try { - const prototype = Object.getPrototypeOf(value); - // Leave plain maps, AxiosHeaders and literal Date/Buffer/array values to Axios. - return prototype !== null && prototype !== Object.prototype && - Object.prototype.toString.call(value) === "[object Object]"; - } catch { - return false; - } -} - -function normalizeHeaderDefaults(headers: RequestHeaders, defaults: RequestHeaders): RequestHeaders { - if (!headers) return headers; - - let normalized: Record | undefined; - for (const [name, value] of Object.entries(headers)) { - if (defaultHeaderNames.has(name.toLowerCase()) && isClassHeaderDefaults(value)) { - normalized ??= { ...headers }; - // Axios merges defaults once more in request(). Clear fields replaced by a class bucket. - const previousKeys = Object.entries(defaults ?? {}) - .filter(([key]) => key.toLowerCase() === name.toLowerCase()) - .flatMap(([, previous]) => previous && typeof previous === "object" ? Object.keys(previous) : []); - const cleared = Object.fromEntries(previousKeys.map(key => [key, undefined])); - normalized[name] = { ...cleared, ...value }; - } - } - return normalized ? normalized as RequestHeaders : headers; -} - -function hasClassHeaderDefaults(headers: RequestHeaders): boolean { - return !!headers && Object.entries(headers).some(([name, value]) => - defaultHeaderNames.has(name.toLowerCase()) && isClassHeaderDefaults(value) - ); -} - -export function normalizeAxiosRequestHeaders( - axios: AxiosInstance, - headers: RequestHeaders, -): RequestHeaders { - // Axios accepts its defaults here, although request types narrow method maps to AxiosHeaders. - const defaults = axios.defaults.headers as RequestHeaders; - if (!hasClassHeaderDefaults(defaults) && !hasClassHeaderDefaults(headers)) return headers; - - // Merge first: a request class bucket replaces its instance bucket in Axios 1.20. - // Converting both before merging would retain overridden headers, including credentials. - const merged = mergeConfig({ headers: defaults }, { headers }).headers; - return normalizeHeaderDefaults(merged, defaults); -} diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs new file mode 100644 index 0000000..dc6c7cf --- /dev/null +++ b/tests/auth.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; +import axios from "axios"; +import * as esm from "../dist/index.js"; + +const require = createRequire(import.meta.url); + +for (const [format, sdk] of [["ESM", esm], ["CommonJS", require("../dist/index.cjs")]]) { + test(`${format}: sends the configured access token`, async () => { + const client = axios.create({ + adapter: async config => ({ + data: config.headers.get("Authorization"), + status: 200, + statusText: "OK", + headers: {}, + config, + }), + }); + const api = new sdk.Apis(new sdk.Configuration({ accessToken: "test-token" }), undefined, client); + const response = await api.getChannels(); + assert.equal(response.data, "Bearer test-token"); + }); +} diff --git a/tests/axios-headers.test.mjs b/tests/axios-headers.test.mjs deleted file mode 100644 index 0fd1c50..0000000 --- a/tests/axios-headers.test.mjs +++ /dev/null @@ -1,184 +0,0 @@ -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { test } from "node:test"; -import axios from "axios"; -import * as esm from "../dist/index.js"; - -const require = createRequire(import.meta.url); -const cjsAxios = require("axios"); -const formats = [ - ["ESM", esm, axios, cjsAxios], - ["CommonJS", require("../dist/index.cjs"), cjsAxios, axios], -]; - -class HeaderDefaults { - constructor(headers) { - Object.assign(this, headers); - } -} - -function captureRequests(axiosBuild, headers = {}) { - const requests = []; - const client = axiosBuild.create({ - headers, - adapter: async config => { - requests.push(config); - return { data: [], status: 200, statusText: "OK", headers: {}, config }; - }, - }); - return { client, requests }; -} - -for (const [format, sdk, axiosBuild, otherAxiosBuild] of formats) { - test(`${format}: custom instance defaults retain authorization and method scope`, async () => { - const common = Object.freeze(new HeaderDefaults({ "X-Common": "common" })); - const get = Object.freeze(new HeaderDefaults({ Authorization: "get-token" })); - const post = Object.freeze(new HeaderDefaults({ Authorization: "post-token" })); - const { client, requests } = captureRequests(axiosBuild, { common, get, post }); - Object.freeze(client.defaults.headers); - let requestInterceptors = 0; - let responseInterceptors = 0; - client.interceptors.request.use(config => { - requestInterceptors++; - config.headers.set("X-Interceptor", "present"); - return config; - }); - client.interceptors.response.use(response => { - responseInterceptors++; - return response; - }); - - const api = new sdk.Apis(undefined, undefined, client); - await api.getChannels(); - await api.postMessage("channel/id", { content: "test" }); - - assert.equal(requests[0].headers.get("Authorization"), "get-token"); - assert.equal(requests[1].headers.get("Authorization"), "post-token"); - for (const request of requests) { - assert.equal(request.headers.get("X-Common"), "common"); - assert.equal(request.headers.get("X-Interceptor"), "present"); - for (const name of ["common", "get", "post"]) { - assert.equal(request.headers.has(name), false); - } - } - assert.equal(client.defaults.headers.common, common); - assert.equal(client.defaults.headers.get, get); - assert.equal(client.defaults.headers.post, post); - assert.equal(requestInterceptors, 2); - assert.equal(responseInterceptors, 2); - assert.equal(requests[1].url.endsWith("/channels/channel%2Fid/messages"), true); - assert.deepEqual(JSON.parse(requests[1].data), { content: "test" }); - }); - - test(`${format}: method-specific credentials never become unrelated literal headers`, async () => { - const { client, requests } = captureRequests(axiosBuild, { - post: new HeaderDefaults({ Authorization: "post-only-secret" }), - query: new HeaderDefaults({ "X-Query-Secret": "query-only-secret" }), - link: new HeaderDefaults({ "X-Link-Secret": "link-only-secret" }), - }); - const api = new sdk.Apis(undefined, undefined, client); - await api.getChannels(); - const headers = requests[0].headers.toJSON(); - assert.equal(requests[0].headers.has("Authorization"), false); - for (const name of ["post", "query", "link", "X-Query-Secret", "X-Link-Secret"]) { - assert.equal(requests[0].headers.has(name), false); - } - assert.equal(JSON.stringify(headers).includes("secret"), false); - }); - - test(`${format}: configuration and request defaults preserve header precedence`, async () => { - const { client, requests } = captureRequests(axiosBuild, { - common: new HeaderDefaults({ "X-Value": "instance-common", "X-Instance": "present" }), - get: new HeaderDefaults({ "X-Value": "instance-get" }), - }); - const baseHeaders = Object.freeze({ - Common: Object.freeze(new HeaderDefaults({ "X-Value": "base-common", "X-Base": "present" })), - }); - const configuration = new sdk.Configuration({ - accessToken: "configuration-token", - baseOptions: { headers: baseHeaders }, - }); - const api = new sdk.Apis(configuration, undefined, client); - const requestHeaders = Object.freeze({ - GeT: Object.freeze(new HeaderDefaults({ "x-value": "request-get" })), - "X-Direct": "present", - }); - await api.getChannels(undefined, undefined, { headers: requestHeaders }); - assert.equal(requests[0].headers.get("X-Value"), "request-get"); - assert.equal(requests[0].headers.has("X-Instance"), false); - assert.equal(requests[0].headers.get("X-Base"), "present"); - assert.equal(requests[0].headers.get("X-Direct"), "present"); - assert.equal(requests[0].headers.get("Authorization"), "Bearer configuration-token"); - assert.equal(requestHeaders.GeT["x-value"], "request-get"); - - await api.getChannels(undefined, undefined, { headers: { "X-Value": "direct", Authorization: false } }); - assert.equal(requests[1].headers.get("X-Value"), "direct"); - assert.equal(requests[1].headers.toJSON().Authorization, undefined); - }); - - test(`${format}: replacing a class defaults bucket does not retain its credentials`, async () => { - for (const defaults of [ - { Authorization: "replaced-secret", "X-Old": "old" }, - new HeaderDefaults({ Authorization: "replaced-secret", "X-Old": "old" }), - ]) { - const { client, requests } = captureRequests(axiosBuild, { common: defaults }); - client.defaults.headers.Common = { Authorization: "case-variant-secret", "X-Alternate": "old" }; - const api = new sdk.Apis(undefined, undefined, client); - // Plain maps merge; class instances replace the entire bucket in Axios 1.20. - for (const common of [new HeaderDefaults({ "X-New": "class" }), null]) { - await api.getChannels(undefined, undefined, { headers: { common } }); - const request = requests.at(-1); - assert.equal(request.headers.has("Authorization"), false); - assert.equal(request.headers.has("X-Old"), false); - assert.equal(request.headers.has("X-Alternate"), false); - assert.equal(request.headers.has("common"), false); - } - assert.equal(client.defaults.headers.common.Authorization, "replaced-secret"); - } - }); - - test(`${format}: plain maps, null-prototype maps and foreign AxiosHeaders remain supported`, async () => { - const { client, requests } = captureRequests(axiosBuild, { - common: new otherAxiosBuild.AxiosHeaders({ "X-Common": "foreign" }), - get: Object.assign(Object.create(null), { "X-Get": "null-prototype" }), - }); - const api = new sdk.Apis(undefined, undefined, client); - await api.getChannels(undefined, undefined, { headers: { "X-Plain": "plain" } }); - assert.equal(requests[0].headers.get("X-Common"), "foreign"); - assert.equal(requests[0].headers.get("X-Get"), "null-prototype"); - assert.equal(requests[0].headers.get("X-Plain"), "plain"); - }); - - test(`${format}: literal method-named headers keep Axios 1.21 serialization`, async () => { - const date = new Date("2026-01-01T00:00:00Z"); - const { client, requests } = captureRequests(axiosBuild, { - common: new HeaderDefaults({ "X-Common": "present" }), - }); - const api = new sdk.Apis(undefined, undefined, client); - await api.getChannels(undefined, undefined, { - headers: { Link: "; rel=next", Get: date, Query: Buffer.from("literal") }, - }); - assert.equal(requests[0].headers.get("Link"), "; rel=next"); - assert.equal(requests[0].headers.get("Get"), String(date)); - assert.equal(requests[0].headers.get("Query"), "literal"); - }); - - test(`${format}: compatibility conversion does not copy inherited or prototype-polluting defaults`, async () => { - class UnsafeDefaults extends HeaderDefaults {} - UnsafeDefaults.prototype["X-Inherited"] = "must-not-be-sent"; - const defaults = new UnsafeDefaults({ "X-Safe": "present" }); - for (const name of ["__proto__", "constructor", "prototype"]) { - Object.defineProperty(defaults, name, { value: { polluted: true }, enumerable: true }); - } - const { client, requests } = captureRequests(axiosBuild); - client.defaults.headers.common = defaults; - const api = new sdk.Apis(undefined, undefined, client); - await api.getChannels(); - assert.equal(requests[0].headers.get("X-Safe"), "present"); - assert.equal(requests[0].headers.has("X-Inherited"), false); - assert.equal(requests[0].headers.has("__proto__"), false); - assert.equal(requests[0].headers.has("constructor"), false); - assert.equal(requests[0].headers.has("prototype"), false); - assert.equal(Object.prototype.polluted, undefined); - }); -}