Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/build-test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "3.0.2"
}
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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("🚀🚀🚀");
}
```
8 changes: 8 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"packages": {
".": {
"release-type": "node",
"extra-files": ["src/version.ts"]
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./octopusFeatureProvider";
export * from "./productMetadata";
55 changes: 53 additions & 2 deletions src/octopusFeatureClient.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();

Expand All @@ -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}`
);
});
});
13 changes: 13 additions & 0 deletions src/octopusFeatureClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, {
Expand Down Expand Up @@ -78,6 +82,7 @@ export class OctopusFeatureClient {
responseType: "json",
headers: {
Authorization: `Bearer ${this.clientIdentifier}`,
"X-Octopus-Client": this.buildOctopusClientHeaderValue(),
},
};
if (this.releaseVersionOverride) {
Expand All @@ -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}`;
}
}
17 changes: 14 additions & 3 deletions src/octopusFeatureProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand All @@ -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);

Expand Down
6 changes: 5 additions & 1 deletion src/octopusFeatureProvider.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,7 +30,7 @@ export class OctopusFeatureProvider implements Provider {
}

metadata = {
name: OctopusFeatureProvider.name,
name: "octopus-ts-web-provider",
};
Comment on lines 32 to 34
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an aside, I am aligning these between the three libraries.

https://openfeature.dev/specification/sections/providers/#requirement-211


readonly runsOn = "client";
Expand Down
53 changes: 53 additions & 0 deletions src/productMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
37 changes: 37 additions & 0 deletions src/productMetadata.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
2 changes: 2 additions & 0 deletions src/specificationTests/fixtureEvaluationTests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PROVIDER_VERSION = "3.0.2"; // x-release-please-version
Loading