diff --git a/.changeset/x-client-header.md b/.changeset/x-client-header.md new file mode 100644 index 0000000..ea44b58 --- /dev/null +++ b/.changeset/x-client-header.md @@ -0,0 +1,5 @@ +--- +"tesla-fleet-api": minor +--- + +A new `client` option names your application, and is sent as the `X-Client` header on every request. `X-Library` is unchanged. diff --git a/README.md b/README.md index d5f3f64..ea29c40 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ await site.live_status(); Replies keep the service's `{ response }` envelope. `api.vehicles.byVin` and `api.energySites.byId` are `Map`s of what each factory created. More: [vehicles](docs/fleet_api_vehicles.md), [energy sites](docs/fleet_api_energy_sites.md). -`accessToken` may be a function returning `Promise`, which is called for every request. Other options are `fetch`, `logger`, `debug` and `timeout` (milliseconds). +`accessToken` may be a function returning `Promise`, which is called for every request. Other options are `fetch`, `logger`, `debug`, `timeout` (milliseconds) and `client`, a short name for your application sent as the `X-Client` header on every request. It must be printable ASCII, or the constructor throws. ## Signed commands diff --git a/src/teslafleetapi.ts b/src/teslafleetapi.ts index f64900b..1890629 100644 --- a/src/teslafleetapi.ts +++ b/src/teslafleetapi.ts @@ -57,6 +57,8 @@ export default class TeslaFleetApi { timeout: number | null; /** Transport identity for debug logging; Teslemetry and Tessie override this. */ readonly _transport_name: string = "fleet"; + /** Sent as `X-Client` on every request when set. */ + client: string | null; #fetch: typeof fetch | null; // Native private fields, behind accessors: a key must not be reachable by // walking the instance, e.g. in a structured log. @@ -77,11 +79,17 @@ export default class TeslaFleetApi { /** Used for every request in place of the global `fetch`. */ fetch?: typeof fetch; timeout?: number; + /** A short name for the application, sent as `X-Client` on every request. */ + client?: string; }) { this.debug = !!options.debug; this.logger = options.logger ?? null; this.#fetch = options.fetch ?? null; this.timeout = options.timeout ?? null; + if (options.client && !/^[\x20-\x7e]+$/.test(options.client)) { + throw new Error("client must contain only printable ASCII characters."); + } + this.client = options.client || null; this.accessToken = options.accessToken ?? null; if (options.server) { @@ -221,6 +229,9 @@ export default class TeslaFleetApi { Authorization: `Bearer ${accessToken}`, "X-Library": `node tesla_fleet_api ${__VERSION__}`, }; + if (this.client) { + headers["X-Client"] = this.client; + } // Remove null and undefined values from params and json const query = new URLSearchParams(); diff --git a/src/teslemetry.ts b/src/teslemetry.ts index b389d31..7242bb1 100644 --- a/src/teslemetry.ts +++ b/src/teslemetry.ts @@ -20,6 +20,8 @@ export type TeslemetryOptions = { logger?: Logger; fetch?: typeof fetch; timeout?: number; + /** A short name for the application, sent as `X-Client` on every request. */ + client?: string; }; /** Teslemetry's OAuth dynamic client registration endpoint. */ diff --git a/src/tessie.ts b/src/tessie.ts index eff8377..cacdcbf 100644 --- a/src/tessie.ts +++ b/src/tessie.ts @@ -16,6 +16,8 @@ export type TessieOptions = { logger?: Logger; fetch?: typeof fetch; timeout?: number; + /** A short name for the application, sent as `X-Client` on every request. */ + client?: string; /** Default for each command's `wait_for_completion`. */ wait_for_completion?: boolean; /** Default for each command's `max_attempts`. */ diff --git a/test/request.test.ts b/test/request.test.ts index 2fbe1eb..7c4b690 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -141,6 +141,23 @@ describe("_request", () => { expect(headersOf(fetch).get("X-Library")).toBe(`node tesla_fleet_api ${version}`); }); + it("sends X-Client only when client is set", async () => { + const withClient = fakeFetch(); + const without = fakeFetch(); + + await makeApi(withClient, { client: "node-red-contrib-teslemetry" })._request("GET", "api/1/products"); + await makeApi(without)._request("GET", "api/1/products"); + + expect(headersOf(withClient).get("X-Client")).toBe("node-red-contrib-teslemetry"); + expect(headersOf(withClient).get("X-Library")).toMatch(/^node tesla_fleet_api /); + expect(headersOf(without).has("X-Client")).toBe(false); + }); + + it("rejects a client that is not printable ASCII", () => { + expect(() => makeApi(fakeFetch(), { client: "café" })).toThrow(/printable ASCII/); + expect(() => makeApi(fakeFetch(), { client: "app\n" })).toThrow(/printable ASCII/); + }); + it("raises ResponseError for a non-JSON 2xx", async () => { const fetch = fakeFetch(() => new Response("ok", { status: 200, headers: { "Content-Type": "text/html" } }));