diff --git a/.github/workflows/build-test-release.yml b/.github/workflows/build-test-release.yml index d1ebbf8..8934131 100644 --- a/.github/workflows/build-test-release.yml +++ b/.github/workflows/build-test-release.yml @@ -52,7 +52,8 @@ jobs: - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 id: release with: - release-type: node + config-file: release-please-config.json + manifest-file: .release-please-manifest.json token: ${{ secrets.RELEASE_PLEASE_TOKEN }} - uses: actions/checkout@v6 if: ${{ steps.release.outputs.release_created }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..9318fd8 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "3.0.2" +} diff --git a/README.md b/README.md index 0f049af..de951a3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Octopus Deploy OpenFeature Provider for TypeScript/JavaScript (web clients) +# Octopus Deploy OpenFeature Provider for TypeScript/JavaScript (web clients) [![Build test and release](https://github.com/OctopusDeploy/openfeature-provider-ts-web/actions/workflows/build-test-release.yml/badge.svg)](https://github.com/OctopusDeploy/openfeature-provider-ts-web/actions/workflows/build-test-release.yml) The OctopusDeploy TypeScript/JavaScript [OpenFeature provider ](https://openfeature.dev/docs/reference/concepts/provider/) for web clients, to be used with the [OpenFeature web SDK](https://openfeature.dev/docs/reference/technologies/client/web/). -## About Octopus Deploy +## About Octopus Deploy [Octopus Deploy](https://octopus.com) is a sophisticated, best-of-breed continuous delivery (CD) platform for modern software teams. Octopus offers powerful release orchestration, deployment automation, and runbook automation, while handling the scale, complexity and governance expectations of even the largest organizations with the most complex deployment challenges. @@ -20,14 +20,20 @@ npm i @octopusdeploy/openfeature ### Usage ```ts -const provider = new OctopusFeatureProvider({ clientIdentifier: "YourClientIdentifier" }); +import { OctopusFeatureProvider, ProductMetadata } from "@octopusdeploy/openfeature"; +import { OpenFeature } from "@openfeature/web-sdk"; + +const provider = new OctopusFeatureProvider({ + clientIdentifier: "YourClientIdentifier", + productMetadata: new ProductMetadata("YourProductName", "1.0.0"), +}); await OpenFeature.setContext({ userid: "bob@octopus.com" }); await OpenFeature.setProviderAndWait(provider); const client = OpenFeature.getClient(); - + if (client.getBooleanValue("to-the-moon-feature", false, {})) { - console.log('🚀🚀🚀'); + console.log("🚀🚀🚀"); } ``` diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..8bc4b09 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,8 @@ +{ + "packages": { + ".": { + "release-type": "node", + "extra-files": ["src/version.ts"] + } + } +} diff --git a/src/index.ts b/src/index.ts index 56df78b..4ff9f43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./octopusFeatureProvider"; +export * from "./productMetadata"; diff --git a/src/octopusFeatureClient.test.ts b/src/octopusFeatureClient.test.ts index d2a94dc..77acb20 100644 --- a/src/octopusFeatureClient.test.ts +++ b/src/octopusFeatureClient.test.ts @@ -1,4 +1,6 @@ import { OctopusFeatureClient } from "./octopusFeatureClient"; +import { ProductMetadata } from "./productMetadata"; +import { PROVIDER_VERSION } from "./version"; import axios from "axios"; import axiosRetry from "axios-retry"; import MockAdapter from "axios-mock-adapter"; @@ -21,7 +23,10 @@ describe("OctopusFeatureClient", () => { test("Should invoke the toggles evaluations v3 endpoint", async () => { mockAdapter.onGet().reply(200, {}); - const client = new OctopusFeatureClient({ clientIdentifier: "a.b.c" }); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("TestClient"), + }); await client.getEvaluationContext(); @@ -31,10 +36,56 @@ describe("OctopusFeatureClient", () => { test("Should include releaseVersionOverride in HTTP header if provided in configuration", async () => { const releaseVersionOverride = "1.2.3"; mockAdapter.onGet().reply(200, {}); - const client = new OctopusFeatureClient({ clientIdentifier: "a.b.c", releaseVersionOverride }); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("TestClient"), + releaseVersionOverride, + }); await client.getEvaluationContext(); expect(mockAdapter.history.get[0].headers!["X-Release-Version"]).toEqual(releaseVersionOverride); }); + + test("Should include X-Octopus-Client header with product name only", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("MyProduct"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); + + test("Should include X-Octopus-Client header with product name and version", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("MyProduct", "2024.1.0"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct/2024.1.0 openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); + + test("Should strip unsupported chars from product name in X-Octopus-Client header", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("My Product"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); }); diff --git a/src/octopusFeatureClient.ts b/src/octopusFeatureClient.ts index a3c1103..25edc03 100644 --- a/src/octopusFeatureClient.ts +++ b/src/octopusFeatureClient.ts @@ -3,6 +3,8 @@ import axiosRetry from "axios-retry"; import { V2FeatureToggleEvaluation, V2FeatureToggles, OctopusFeatureContext } from "./octopusFeatureContext"; import { OctopusFeatureConfiguration } from "./octopusFeatureProvider"; import { DefaultLogger, Logger } from "@openfeature/web-sdk"; +import { ProductMetadata } from "./productMetadata"; +import { PROVIDER_VERSION } from "./version"; interface V2CacheEntry { schemaVersion: "v2"; @@ -16,11 +18,13 @@ export class OctopusFeatureClient { private readonly axiosInstance: AxiosInstance; private readonly localStorageKey = "octopus-openfeature-ts-feature-manifest"; private readonly releaseVersionOverride?: string; + private readonly productMetadata: ProductMetadata; constructor(configuration: OctopusFeatureConfiguration) { this.clientIdentifier = configuration.clientIdentifier; this.serverUri = configuration.serverUri ? configuration.serverUri.replace(/\/$/, "") : "https://features.octopus.com"; this.releaseVersionOverride = configuration.releaseVersionOverride; + this.productMetadata = configuration.productMetadata; this.logger = configuration.logger ?? new DefaultLogger(); this.axiosInstance = axios.create(); axiosRetry(this.axiosInstance, { @@ -78,6 +82,7 @@ export class OctopusFeatureClient { responseType: "json", headers: { Authorization: `Bearer ${this.clientIdentifier}`, + "X-Octopus-Client": this.buildOctopusClientHeaderValue(), }, }; if (this.releaseVersionOverride) { @@ -100,4 +105,12 @@ export class OctopusFeatureClient { return { evaluations: response.data, contentHash: contentHash }; } + + private buildOctopusClientHeaderValue(): string { + let value = this.productMetadata.name; + if (this.productMetadata.version) { + value += `/${this.productMetadata.version}`; + } + return `${value} openfeature-provider-ts-web/${PROVIDER_VERSION}`; + } } diff --git a/src/octopusFeatureProvider.test.ts b/src/octopusFeatureProvider.test.ts index d354b06..c2fb524 100644 --- a/src/octopusFeatureProvider.test.ts +++ b/src/octopusFeatureProvider.test.ts @@ -1,4 +1,5 @@ import { OctopusFeatureProvider } from "./octopusFeatureProvider"; +import { ProductMetadata } from "./productMetadata"; import { OpenFeature } from "@openfeature/web-sdk"; import { OctopusFeatureClient } from "./octopusFeatureClient"; import { OctopusFeatureContext } from "./octopusFeatureContext"; @@ -19,6 +20,7 @@ describe.skip("octopusFeatureProvider", () => { test("use this to verify that the provider is happy end to end", async () => { const client = new OctopusFeatureProvider({ clientIdentifier: "TODO", + productMetadata: new ProductMetadata("TestClient"), }); await OpenFeature.setProviderAndWait(client); @@ -51,7 +53,10 @@ describe("Context is available for segment evaluation immediately after provider }); test("setContext before setProviderAndWait — SDK passes context to initialize", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setContext(context); await OpenFeature.setProviderAndWait(provider); @@ -61,7 +66,10 @@ describe("Context is available for segment evaluation immediately after provider }); test("setProviderAndWait with context — context passed directly to initialize", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setProviderAndWait(provider, context); @@ -73,7 +81,10 @@ describe("Context is available for segment evaluation immediately after provider // so any flag evaluation (e.g. from React hooks) between these two calls will fail // segment matching. Prefer calling setContext before setProviderAndWait to avoid this. test("setProviderAndWait then setContext — context is not available until setContext completes", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setProviderAndWait(provider); diff --git a/src/octopusFeatureProvider.ts b/src/octopusFeatureProvider.ts index 7b96b91..3957c00 100644 --- a/src/octopusFeatureProvider.ts +++ b/src/octopusFeatureProvider.ts @@ -1,11 +1,15 @@ import { EvaluationContext, JsonValue, Logger, Provider, ResolutionDetails } from "@openfeature/web-sdk"; import { OctopusFeatureClient } from "./octopusFeatureClient"; import { OctopusFeatureContext } from "./octopusFeatureContext"; +import { ProductMetadata } from "./productMetadata"; export interface OctopusFeatureConfiguration { /** The ClientIdentifier provided by the Octopus variable Octopus.FeatureToggles.ClientIdentifier */ clientIdentifier: string; + /** Metadata about the application using the OpenFeature provider. Used to populate header for telemetry. */ + productMetadata: ProductMetadata; + serverUri?: string; logger?: Logger; @@ -26,7 +30,7 @@ export class OctopusFeatureProvider implements Provider { } metadata = { - name: OctopusFeatureProvider.name, + name: "octopus-ts-web-provider", }; readonly runsOn = "client"; diff --git a/src/productMetadata.test.ts b/src/productMetadata.test.ts new file mode 100644 index 0000000..6dba4fb --- /dev/null +++ b/src/productMetadata.test.ts @@ -0,0 +1,53 @@ +import { ProductMetadata } from "./productMetadata"; + +describe("ProductMetadata", () => { + describe("name", () => { + test("valid name characters are preserved unchanged", () => { + const metadata = new ProductMetadata("OctopusDeploy"); + expect(metadata.name).toBe("OctopusDeploy"); + }); + + test("spaces and punctuation are stripped from name", () => { + const metadata = new ProductMetadata("My ,Product (v2.0)/release@2024:final"); + expect(metadata.name).toBe("MyProductv2.0release2024final"); + }); + + test("hyphens are preserved in name", () => { + const metadata = new ProductMetadata("My-Product"); + expect(metadata.name).toBe("My-Product"); + }); + + test("throws when name is whitespace only", () => { + expect(() => new ProductMetadata(" ")).toThrow("Product name"); + }); + + test("throws when name becomes empty after stripping invalid chars", () => { + expect(() => new ProductMetadata(" @@@ ")).toThrow("Product name"); + }); + }); + + describe("version", () => { + test("version is undefined when not provided", () => { + const metadata = new ProductMetadata("MyProduct"); + expect(metadata.version).toBeUndefined(); + }); + + test("valid version characters are preserved unchanged", () => { + const metadata = new ProductMetadata("MyProduct", "2024.1.0"); + expect(metadata.version).toBe("2024.1.0"); + }); + + test("unsupported chars in version are stripped", () => { + const metadata = new ProductMetadata("MyProduct", "2024.1 (beta)"); + expect(metadata.version).toBe("2024.1beta"); + }); + + test("throws when version is whitespace only", () => { + expect(() => new ProductMetadata("MyProduct", " ")).toThrow("Product version"); + }); + + test("throws when version becomes empty after stripping invalid chars", () => { + expect(() => new ProductMetadata("MyProduct", " @@@ ")).toThrow("Product version"); + }); + }); +}); diff --git a/src/productMetadata.ts b/src/productMetadata.ts new file mode 100644 index 0000000..25fc85e --- /dev/null +++ b/src/productMetadata.ts @@ -0,0 +1,37 @@ +// RFC 9110 token characters: https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens +const INVALID_TCHAR = /[^a-zA-Z0-9!#$%&'*+\-.^_`|~]/g; + +function clean(value: string): string { + return value.replace(INVALID_TCHAR, ""); +} + +export class ProductMetadata { + readonly name: string; + readonly version: string | undefined; + + constructor(name: string); + constructor(name: string, version: string); + constructor(name: string, version?: string) { + this.name = clean(name); + this.validateName(); + + if (version !== undefined) { + this.version = clean(version); + this.validateVersion(); + } else { + this.version = undefined; + } + } + + private validateName(): void { + if (!this.name) { + throw new Error("Product name must contain at least one valid token character."); + } + } + + private validateVersion(): void { + if (!this.version) { + throw new Error("Product version must contain at least one valid token character."); + } + } +} diff --git a/src/specificationTests/fixtureEvaluationTests.test.ts b/src/specificationTests/fixtureEvaluationTests.test.ts index ef8d33c..721350b 100644 --- a/src/specificationTests/fixtureEvaluationTests.test.ts +++ b/src/specificationTests/fixtureEvaluationTests.test.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { ErrorCode } from "@openfeature/core"; import { OpenFeature } from "@openfeature/web-sdk"; import { OctopusFeatureProvider } from "../octopusFeatureProvider"; +import { ProductMetadata } from "../productMetadata"; import { Server } from "./server"; interface Fixture { @@ -66,6 +67,7 @@ test.each(testCases)("$testCase.description", async ({ testResponse, testCase }) const provider = new OctopusFeatureProvider({ clientIdentifier: token, serverUri: server.url, + productMetadata: new ProductMetadata("TestClient"), }); await OpenFeature.setProviderAndWait(provider); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..4b35be8 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const PROVIDER_VERSION = "3.0.2"; // x-release-please-version