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
20 changes: 20 additions & 0 deletions errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = "NetworkError";
}
}

export class APIError extends Error {
constructor(message: string, public status: number, public statusText: string) {
super(message);
this.name = "APIError";
}
}

export class InvalidResponseError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidResponseError";
}
}
134 changes: 112 additions & 22 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APIError, InvalidResponseError, NetworkError } from "./errors";
import createRocketflagClient from "./index";
import { FlagStatus } from "./index";

Expand All @@ -7,50 +8,94 @@ global.fetch = jest.fn() as jest.Mock<Promise<Response>>;
describe("createRocketflagClient", () => {
const apiUrl = "https://api.rocketflag.app";
const flagId = "test-flag";
const userContext = { userId: "user123" };
const userContext = { cohort: "user123" };

beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(console, "error").mockImplementation(jest.fn());
});

it("should create a client with default options", () => {
const client = createRocketflagClient();
expect(client).toBeDefined();
expect(client.getFlag).toBeDefined();
expect(client.getFlag).toBeInstanceOf(Function);
});
describe("custom client options", () => {
it("can create a client with a custom version", async () => {
const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId };
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) });

const client = createRocketflagClient("v2");
await client.getFlag(flagId);

const expectedUrl = `${apiUrl}/v2/flags/${flagId}`;
const expectedURLObject = new URL(expectedUrl);

expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" });
});

it("can create a client with a custom url", async () => {
const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId };
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) });

const client = createRocketflagClient("v2", "https://example.com");
await client.getFlag(flagId);

const expectedUrl = `https://example.com/v2/flags/${flagId}`;
const expectedURLObject = new URL(expectedUrl);

it("should create a client with custom options", () => {
const client = createRocketflagClient("v2", "https://example.com/api");
expect(client).toBeDefined();
expect(client.getFlag).toBeDefined();
expect(client.getFlag).toBeInstanceOf(Function);
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" });
});
});

describe("getFlag", () => {
it("should fetch and cache a flag", async () => {
it("should fetch a flag", async () => {
const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId };
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) });

const client = createRocketflagClient();
const flag = await client.getFlag(flagId, userContext);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(new URL(`${apiUrl}/v1/flags/${flagId}?userId=${userContext.userId}`), { method: "GET" });
expect(flag).toEqual(mockFlag);

// Fetch again to test caching
const cachedFlag = await client.getFlag(flagId, userContext);
expect(fetch).toHaveBeenCalledTimes(1); // Still only called once
expect(cachedFlag).toEqual(mockFlag);
const expectedUrl = `${apiUrl}/v1/flags/${flagId}?cohort=user123`;
const expectedURLObject = new URL(expectedUrl);

expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" });
expect(flag).toEqual(mockFlag);
});

it("should handle non-ok responses from the server", async () => {
(fetch as jest.Mock).mockResolvedValue({ ok: false, statusText: "Not Found" });
it("should fetch a flag with special characters in the query", async () => {
userContext.cohort = "user+testing_rocketflag@example.com";
const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId };
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) });

const client = createRocketflagClient();
const flag = await client.getFlag(flagId, userContext);

expect(fetch).toHaveBeenCalledTimes(1);

const expectedUrl = `${apiUrl}/v1/flags/${flagId}?cohort=${"user%2Btesting_rocketflag%40example.com"}`;
const expectedURLObject = new URL(expectedUrl);

expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" });
expect(flag).toEqual(mockFlag);
});

it("should throw an APIError on non-ok response with correct status and statusText", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 404,
statusText: "Flag Not Found",
});
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("Not Found");
await expect(client.getFlag(flagId, userContext)).rejects.toThrow(APIError);
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("API request failed with status 404");

try {
await client.getFlag(flagId, userContext);
} catch (error) {
expect(error).toBeInstanceOf(APIError);
if (error instanceof APIError) {
expect(error.status).toBe(404);
expect(error.statusText).toBe("Flag Not Found");
}
}
});

it("should handle invalid responses from the server", async () => {
Expand All @@ -66,5 +111,50 @@ describe("createRocketflagClient", () => {
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("Network error");
});

it("should throw an error if flagId is empty", async () => {
const client = createRocketflagClient();
await expect(client.getFlag("", userContext)).rejects.toThrow("flagId is required");
});

it("should throw an error if flagId is not a string", async () => {
const client = createRocketflagClient();
await expect(client.getFlag(123 as any, userContext)).rejects.toThrow("flagId must be a string");
});

it("should throw a NetworkError on network error", async () => {
(fetch as jest.Mock).mockRejectedValue(new Error("Some network error"));
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow(NetworkError);
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("Some network error");
});

it("should throw an InvalidResponseError on invalid JSON response", async () => {
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.reject(new Error("Syntax error")) });
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow(InvalidResponseError);
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("Failed to parse JSON response");
});

it("should throw an InvalidResponseError if response is not an object", async () => {
(fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve("not an object") });
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow(InvalidResponseError);
await expect(client.getFlag(flagId, userContext)).rejects.toThrow("Invalid response format: response is not an object");
});

it("should throw an InvalidResponseError if validateFlag fails", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
name: "Test Flag",
enabled: true,
// id: flagId, // Missing ID to make it fail validation
}),
});
const client = createRocketflagClient();
await expect(client.getFlag(flagId, userContext)).rejects.toThrow(InvalidResponseError);
});
});
});
45 changes: 30 additions & 15 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,56 @@
import { APIError, InvalidResponseError, NetworkError } from "./errors";
import { validateFlag } from "./validateFlag";

const GET_METHOD = "GET";
const DEFAULT_API_URL = "https://api.rocketflag.app";
const DEFAULT_VERSION = "v1";

export type FlagStatus = {
name: string;
enabled: boolean;
id: string;
};

interface UserContext {
[key: string]: string | number | boolean;
cohort?: string | number | boolean;
}

const createRocketflagClient = (version = "v1", apiUrl = "https://api.rocketflag.app") => {
const cache: { [key: string]: FlagStatus } = {};

const createRocketflagClient = (version = DEFAULT_VERSION, apiUrl = DEFAULT_API_URL) => {
const getFlag = async (flagId: string, userContext: UserContext = {}): Promise<FlagStatus> => {
if (cache[flagId]) {
return cache[flagId];
if (!flagId) {
throw new Error("flagId is required");
}
if (typeof flagId !== "string") {
throw new Error("flagId must be a string");
}
if (typeof userContext !== "object") {
throw new Error("userContext must be an object");
}

const url = new URL(`${apiUrl}/${version}/flags/${flagId}`);
Object.entries(userContext).forEach(([key, value]) => {
url.searchParams.append(key, value.toString());
});

const raw = await fetch(url, {
method: "GET",
});
let raw: Response;
try {
raw = await fetch(url, { method: GET_METHOD });
} catch (error) {
throw new NetworkError(`Network error: ${error instanceof Error ? error.message : "Unknown error"}`);
}

if (!raw.ok) throw new Error(raw.statusText);
if (!raw.ok) throw new APIError(`API request failed with status ${raw.status}`, raw.status, raw.statusText);

const response: unknown = await raw.json();
let response: unknown;
try {
response = await raw.json();
} catch (error) {
throw new InvalidResponseError("Failed to parse JSON response");
}

if (!response) throw new Error("Invalid response from server");
if (typeof response !== "object") throw new Error("Invalid response from server");
if (!validateFlag(response)) throw new Error("Invalid response from server");
if (!response || typeof response !== "object") throw new InvalidResponseError("Invalid response format: response is not an object");
if (!validateFlag(response)) throw new InvalidResponseError("Invalid response from server");

cache[flagId] = response;
return response;
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rocketflag/node-sdk",
"version": "0.1.5",
"version": "0.1.6",
"author": "RocketFlag Developers (https://rocketflag.app)",
"main": "index.js",
"scripts": {
Expand Down
Loading