diff --git a/errors.ts b/errors.ts new file mode 100644 index 0000000..7ba85fd --- /dev/null +++ b/errors.ts @@ -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"; + } +} diff --git a/index.test.ts b/index.test.ts index 4ba0d74..af6a782 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,3 +1,4 @@ +import { APIError, InvalidResponseError, NetworkError } from "./errors"; import createRocketflagClient from "./index"; import { FlagStatus } from "./index"; @@ -7,29 +8,43 @@ global.fetch = jest.fn() as jest.Mock>; 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) }); @@ -37,20 +52,50 @@ describe("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 () => { @@ -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); + }); }); }); diff --git a/index.ts b/index.ts index 0dbaa40..a3fb6e5 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,10 @@ +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; @@ -7,15 +12,19 @@ export type FlagStatus = { }; 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 => { - 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}`); @@ -23,19 +32,25 @@ const createRocketflagClient = (version = "v1", apiUrl = "https://api.rocketflag 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; }; diff --git a/package.json b/package.json index 52f4e47..f43f876 100644 --- a/package.json +++ b/package.json @@ -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": {