diff --git a/.prettierignore b/.prettierignore
index 2d3d01a2..c2163a57 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,4 +2,5 @@
**/gen
**/node_modules
**/dist
+**/.next
**/.expo
diff --git a/README.md b/README.md
index 4f5f2c13..1ee6a4eb 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,12 @@ Use this for Cloudflare Workers as well.
[Read the docs](packages/node-sdk/README.md)
+## REST API SDK (beta)
+
+Typed SDK for Reflag's REST API.
+
+[Read the docs](packages/rest-api-sdk/README.md)
+
## Reflag CLI
CLI to interact with Reflag and generate types
diff --git a/package.json b/package.json
index d947068f..9d4d4872 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,8 @@
"license": "MIT",
"workspaces": [
"packages/*",
+ "packages/rest-api-sdk/examples/*",
+ "packages/react-sdk/dev/*",
"packages/react-native-sdk/dev/*",
"packages/openfeature-browser-provider/example"
],
diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts
index 7f024393..1e8663f4 100644
--- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts
+++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts
@@ -1,7 +1,7 @@
import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk";
-const secretKey = process.env.REFLAG_SECRET_KEY || "";
-const offline = process.env.CI === "true";
+const secretKey = process.env.REFLAG_SECRET_KEY;
+const offline = process.env.CI === "true" || !secretKey;
declare global {
var serverClient: ReflagNodeClient;
diff --git a/packages/rest-api-sdk/.prettierignore b/packages/rest-api-sdk/.prettierignore
new file mode 100644
index 00000000..71e1d20f
--- /dev/null
+++ b/packages/rest-api-sdk/.prettierignore
@@ -0,0 +1,3 @@
+dist/
+src/generated/
+**/.next/
diff --git a/packages/rest-api-sdk/README.md b/packages/rest-api-sdk/README.md
new file mode 100644
index 00000000..bcd62a51
--- /dev/null
+++ b/packages/rest-api-sdk/README.md
@@ -0,0 +1,315 @@
+# @reflag/rest-api-sdk (beta)
+
+Typed SDK for Reflag's REST API.
+
+## Installation
+
+```bash
+npm install @reflag/rest-api-sdk
+# or
+yarn add @reflag/rest-api-sdk
+```
+
+## Create a client
+
+Initialize the SDK with a [Reflag REST API Key](https://app.reflag.com/env-current/settings/org-api-access).
+
+```typescript
+import { Api } from "@reflag/rest-api-sdk";
+
+const api = new Api({
+ accessToken: process.env.REFLAG_API_KEY,
+});
+```
+
+## API surface
+
+Main exports:
+
+- `Api`: base client
+- `createAppClient(appId, config)`: app-scoped client
+- `ReflagApiError`: normalized API error type
+- Generated request/response types and models from `@reflag/rest-api-sdk`
+
+Core method groups:
+
+- Applications: `listApps`, `getApp`
+- Environments: `listEnvironments`, `getEnvironment`
+- Flags: `listFlags`, `createFlag`, `updateFlag`
+- User/company evaluation: `getUserFlags`, `updateUserFlags`, `getCompanyFlags`, `updateCompanyFlags`
+
+## Quick start
+
+```typescript
+const apps = await api.listApps();
+console.log(apps.data);
+// [
+// {
+// "org": { "id": "org-1", "name": "Acme Org" },
+// "id": "app-123",
+// "name": "Acme App",
+// "demo": false,
+// "flagKeyFormat": "kebabCaseLower",
+// "environments": [
+// { "id": "env-123", "name": "Development", "isProduction": false, "order": 0 },
+// { "id": "env-456", "name": "Production", "isProduction": true, "order": 1 }
+// ]
+// }
+// ]
+
+const app = apps.data[0];
+const appId = app?.id;
+
+if (appId) {
+ const environments = await api.listEnvironments({
+ appId,
+ sortBy: "order",
+ sortOrder: "asc",
+ });
+
+ console.log(environments.data);
+ // [
+ // { "id": "env-456", "name": "Production", "isProduction": true, "order": 1 }
+ // ]
+}
+```
+
+## App-scoped client
+
+If most calls are for one app, use `createAppClient` to avoid repeating `appId`.
+
+```typescript
+import { createAppClient } from "@reflag/rest-api-sdk";
+
+const appApi = createAppClient("app-123", {
+ accessToken: process.env.REFLAG_API_KEY,
+});
+
+const environments = await appApi.listEnvironments({
+ sortBy: "order",
+ sortOrder: "asc",
+});
+console.log(environments.data);
+// [
+// { "id": "env-456", "name": "Production", "isProduction": true, "order": 1 }
+// ]
+
+const flags = await appApi.listFlags({});
+console.log(flags.data);
+// [
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout",
+// "description": "Rollout for redesigned checkout flow",
+// "stage": { "id": "stage-1", "name": "Beta", "color": "#4f46e5", "order": 2 },
+// "owner": {
+// "id": "user-99",
+// "name": "Jane Doe",
+// "email": "jane@acme.com",
+// "avatarUrl": "https://example.com/avatar.png"
+// },
+// "archived": false,
+// "stale": false,
+// "permanent": false,
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "lastCheckAt": "2026-03-03T09:30:00.000Z",
+// "lastTrackAt": "2026-03-03T09:31:00.000Z"
+// }
+// ]
+```
+
+## Common workflows
+
+### Create and update a flag
+
+`createFlag` and `updateFlag` return `{ flag }` with the latest flag details.
+
+Use `null` to clear nullable fields like `description` or `ownerUserId` on update.
+
+```typescript
+const created = await api.createFlag({
+ appId: "app-123",
+ key: "new-checkout",
+ name: "New checkout",
+ description: "Rollout for redesigned checkout flow",
+ secret: false,
+});
+
+const updated = await api.updateFlag({
+ appId: "app-123",
+ flagId: created.flag.id,
+ name: "New checkout experience",
+ ownerUserId: null,
+});
+console.log(updated.flag);
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout experience",
+// "description": "Rollout for redesigned checkout flow",
+// "stage": { "id": "stage-1", "name": "Beta", "color": "#4f46e5", "order": 2 },
+// "owner": {
+// "id": "user-99",
+// "name": "Jane Doe",
+// "email": "jane@acme.com",
+// "avatarUrl": "https://example.com/avatar.png"
+// },
+// "archived": false,
+// "stale": false,
+// "permanent": false,
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "lastCheckAt": "2026-03-03T09:35:00.000Z",
+// "lastTrackAt": "2026-03-03T09:36:00.000Z",
+// "rolledOutToEveryoneAt": "2026-03-10T12:00:00.000Z",
+// "parentFlagId": "flag-parent-1"
+// }
+```
+
+### Read user flags for an environment
+
+`getUserFlags` evaluates flag results for one user in one environment and returns
+the user’s current values plus exposure/check metadata for each flag.
+
+```typescript
+const userFlags = await api.getUserFlags({
+ appId: "app-123",
+ envId: "env-456",
+ userId: "user-1",
+});
+
+console.log(userFlags.data);
+// [
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout",
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "value": true,
+// "specificTargetValue": true,
+// "firstExposureAt": "2026-03-03T09:05:00.000Z",
+// "lastExposureAt": "2026-03-03T09:30:00.000Z",
+// "lastCheckAt": "2026-03-03T09:31:00.000Z",
+// "exposureCount": 12,
+// "firstTrackAt": "2026-03-03T09:06:00.000Z",
+// "lastTrackAt": "2026-03-03T09:32:00.000Z",
+// "trackCount": 5
+// }
+// ]
+```
+
+### Toggle a user flag
+
+Use `true` to explicitly target on, and `null` to remove specific targeting.
+
+```typescript
+const updatedUserFlags = await api.updateUserFlags({
+ appId: "app-123",
+ envId: "env-456",
+ userId: "user-1",
+ updates: [{ flagKey: "new-checkout", specificTargetValue: true }],
+});
+console.log(updatedUserFlags.data);
+// [
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout",
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "value": true,
+// "specificTargetValue": true,
+// "firstExposureAt": "2026-03-03T09:05:00.000Z",
+// "lastExposureAt": "2026-03-03T09:35:00.000Z",
+// "lastCheckAt": "2026-03-03T09:36:00.000Z",
+// "exposureCount": 13,
+// "firstTrackAt": "2026-03-03T09:06:00.000Z",
+// "lastTrackAt": "2026-03-03T09:37:00.000Z",
+// "trackCount": 6
+// }
+// ]
+```
+
+### Read company flags for an environment
+
+```typescript
+const companyFlags = await api.getCompanyFlags({
+ appId: "app-123",
+ envId: "env-456",
+ companyId: "company-1",
+});
+console.log(companyFlags.data);
+// [
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout",
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "value": false,
+// "specificTargetValue": null,
+// "firstExposureAt": null,
+// "lastExposureAt": null,
+// "lastCheckAt": "2026-03-03T09:31:00.000Z",
+// "exposureCount": 0,
+// "firstTrackAt": null,
+// "lastTrackAt": null,
+// "trackCount": 0
+// }
+// ]
+```
+
+### Toggle a company flag
+
+Use `true` to explicitly target on, and `null` to remove specific targeting.
+
+```typescript
+const updatedCompanyFlags = await api.updateCompanyFlags({
+ appId: "app-123",
+ envId: "env-456",
+ companyId: "company-1",
+ // Use `null` to stop targeting the company specifically for that flag.
+ updates: [{ flagKey: "new-checkout", specificTargetValue: null }],
+});
+console.log(updatedCompanyFlags.data);
+// [
+// {
+// "id": "flag-1",
+// "key": "new-checkout",
+// "name": "New checkout",
+// "createdAt": "2026-03-03T09:00:00.000Z",
+// "value": false,
+// "specificTargetValue": null,
+// "firstExposureAt": null,
+// "lastExposureAt": null,
+// "lastCheckAt": "2026-03-03T09:36:00.000Z",
+// "exposureCount": 0,
+// "firstTrackAt": null,
+// "lastTrackAt": null,
+// "trackCount": 0
+// }
+// ]
+```
+
+## Error handling
+
+The SDK throws `ReflagApiError` for non-2xx API responses.
+
+```typescript
+import { ReflagApiError } from "@reflag/rest-api-sdk";
+
+try {
+ await api.listApps();
+} catch (error) {
+ if (error instanceof ReflagApiError) {
+ console.error(error.status, error.code, error.message, error.details);
+ }
+ throw error;
+}
+```
+
+## Example app
+
+See `packages/rest-api-sdk/examples/customer-admin-panel/README.md` for a small Next.js app using this SDK in server actions.
+
+## License
+
+MIT
diff --git a/packages/rest-api-sdk/eslint.config.js b/packages/rest-api-sdk/eslint.config.js
new file mode 100644
index 00000000..85c50578
--- /dev/null
+++ b/packages/rest-api-sdk/eslint.config.js
@@ -0,0 +1,8 @@
+const base = require("@reflag/eslint-config");
+
+module.exports = [
+ ...base,
+ {
+ ignores: ["dist/", "src/generated/", "examples/**", "**/.next/**"],
+ },
+];
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/README.md b/packages/rest-api-sdk/examples/customer-admin-panel/README.md
new file mode 100644
index 00000000..873c6ea0
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/README.md
@@ -0,0 +1,25 @@
+# Customer Admin Panel
+
+Small Next.js (App Router) app that uses `@reflag/rest-api-sdk` with server actions to view and toggle flags for users and companies.
+
+
+
+## Setup
+
+Create a `.env.local` file in this folder with:
+
+```
+REFLAG_API_KEY=your-api-key
+# Optional
+REFLAG_BASE_URL=https://app.reflag.com/api
+```
+
+## Run
+
+```bash
+yarn dev
+```
+
+Visit:
+
+- http://localhost:3000/
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/AppEnvForm.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/AppEnvForm.tsx
new file mode 100644
index 00000000..a87cff56
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/AppEnvForm.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+type Option = {
+ id: string;
+ name: string;
+};
+
+type AppEnvFormProps = {
+ apps: Option[];
+ envs: Option[];
+ selectedAppId: string;
+ selectedEnvId: string;
+};
+
+export default function AppEnvForm({
+ apps,
+ envs,
+ selectedAppId,
+ selectedEnvId,
+}: AppEnvFormProps) {
+ return (
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/EntityFlagsFilterForm.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/EntityFlagsFilterForm.tsx
new file mode 100644
index 00000000..f8721d44
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/EntityFlagsFilterForm.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+type Option = {
+ id: string;
+ name: string;
+};
+
+type EntityFlagsFilterFormProps = {
+ action: "/flags/user" | "/flags/company";
+ apps: Option[];
+ envs: Option[];
+ selectedAppId: string;
+ selectedEnvId: string;
+ entityIdName: "userId" | "companyId";
+ entityIdLabel: string;
+ entityIdValue: string;
+ submitLabel: string;
+};
+
+export default function EntityFlagsFilterForm({
+ action,
+ apps,
+ envs,
+ selectedAppId,
+ selectedEnvId,
+ entityIdName,
+ entityIdLabel,
+ entityIdValue,
+ submitLabel,
+}: EntityFlagsFilterFormProps) {
+ return (
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/actions.ts b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/actions.ts
new file mode 100644
index 00000000..8bbaf6d5
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/actions.ts
@@ -0,0 +1,133 @@
+"use server";
+
+import type {
+ AppHeaderCollection,
+ EnvironmentHeaderCollection,
+ EntityFlagsResponse,
+ FlagHeaderCollection,
+} from "@reflag/rest-api-sdk";
+import { Api, createAppClient } from "@reflag/rest-api-sdk";
+
+const apiKey = process.env.REFLAG_API_KEY;
+const basePath = process.env.REFLAG_BASE_URL ?? "https://app.reflag.com/api";
+
+function getClient() {
+ if (!apiKey) {
+ throw new Error("REFLAG_API_KEY is not set");
+ }
+ return new Api({
+ accessToken: apiKey,
+ basePath,
+ });
+}
+
+function getAppClient(appId: string) {
+ if (!appId) {
+ throw new Error("appId is required");
+ }
+ if (!apiKey) {
+ throw new Error("REFLAG_API_KEY is not set");
+ }
+ return createAppClient(appId, {
+ accessToken: apiKey,
+ basePath,
+ });
+}
+
+export async function listApps(): Promise {
+ const client = getClient();
+ return await client.listApps();
+}
+
+export async function listEnvironments(
+ appId: string,
+): Promise {
+ const client = getAppClient(appId);
+ return await client.listEnvironments({
+ sortBy: "order",
+ sortOrder: "asc",
+ });
+}
+
+export async function listFlags(appId: string): Promise {
+ const client = getAppClient(appId);
+ return await client.listFlags({});
+}
+
+export async function fetchUserFlags(
+ appId: string,
+ envId: string,
+ userId: string,
+): Promise {
+ if (!envId) {
+ throw new Error("envId is required");
+ }
+ if (!userId) {
+ throw new Error("userId is required");
+ }
+ const client = getAppClient(appId);
+ return await client.getUserFlags({
+ envId,
+ userId,
+ });
+}
+
+export async function toggleUserFlag(
+ appId: string,
+ envId: string,
+ userId: string,
+ flagKey: string,
+ enabled: boolean,
+): Promise {
+ if (!envId) {
+ throw new Error("envId is required");
+ }
+ if (!userId || !flagKey) {
+ throw new Error("userId and flagKey are required");
+ }
+ const client = getAppClient(appId);
+ await client.updateUserFlags({
+ envId,
+ userId,
+ updates: [{ flagKey, specificTargetValue: enabled ? true : null }],
+ });
+}
+
+export async function fetchCompanyFlags(
+ appId: string,
+ envId: string,
+ companyId: string,
+): Promise {
+ if (!envId) {
+ throw new Error("envId is required");
+ }
+ if (!companyId) {
+ throw new Error("companyId is required");
+ }
+ const client = getAppClient(appId);
+ return await client.getCompanyFlags({
+ envId,
+ companyId,
+ });
+}
+
+export async function toggleCompanyFlag(
+ appId: string,
+ envId: string,
+ companyId: string,
+ flagKey: string,
+ enabled: boolean,
+): Promise {
+ if (!envId) {
+ throw new Error("envId is required");
+ }
+ if (!companyId || !flagKey) {
+ throw new Error("companyId and flagKey are required");
+ }
+ const client = getAppClient(appId);
+ await client.updateCompanyFlags({
+ envId,
+ companyId,
+ updates: [{ flagKey, specificTargetValue: enabled ? true : null }],
+ });
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/company/page.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/company/page.tsx
new file mode 100644
index 00000000..bbbcb275
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/company/page.tsx
@@ -0,0 +1,154 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import EntityFlagsFilterForm from "../EntityFlagsFilterForm";
+import {
+ fetchCompanyFlags,
+ listApps,
+ listEnvironments,
+ toggleCompanyFlag,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type QueryValue = string | string[] | undefined;
+
+type PageProps = {
+ searchParams?: {
+ appId?: QueryValue;
+ envId?: QueryValue;
+ companyId?: QueryValue;
+ };
+};
+
+function getQueryValue(value: QueryValue) {
+ return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
+}
+
+export default async function CompanyFlagsPage({ searchParams }: PageProps) {
+ const apps = (await listApps()).data ?? [];
+ const requestedAppId = getQueryValue(searchParams?.appId);
+ const selectedAppId =
+ apps.find((app) => app.id === requestedAppId)?.id ?? apps[0]?.id ?? "";
+
+ const envs = selectedAppId
+ ? ((await listEnvironments(selectedAppId)).data ?? [])
+ : [];
+
+ const requestedEnvId = getQueryValue(searchParams?.envId);
+ const selectedEnvId =
+ envs.find((env) => env.id === requestedEnvId)?.id ?? envs[0]?.id ?? "";
+
+ const companyId = getQueryValue(searchParams?.companyId);
+
+ const flags =
+ selectedAppId && selectedEnvId && companyId
+ ? ((await fetchCompanyFlags(selectedAppId, selectedEnvId, companyId))
+ .data ?? [])
+ : [];
+
+ async function updateCompanyFlagAction(formData: FormData) {
+ "use server";
+
+ const appId = String(formData.get("appId") ?? "");
+ const envId = String(formData.get("envId") ?? "");
+ const nextCompanyId = String(formData.get("companyId") ?? "");
+ const flagKey = String(formData.get("flagKey") ?? "");
+ const nextValue = String(formData.get("nextValue") ?? "") === "true";
+
+ await toggleCompanyFlag(appId, envId, nextCompanyId, flagKey, nextValue);
+
+ const query = new URLSearchParams({
+ appId,
+ envId,
+ companyId: nextCompanyId,
+ });
+ revalidatePath("/flags/company");
+ redirect(`/flags/company?${query.toString()}`);
+ }
+
+ return (
+
+ Back to flags
+ Company Flags
+
+
+
+ {!companyId ? Enter a company ID to load flags.
: null}
+ {companyId && flags.length === 0 ? No flags loaded.
: null}
+
+ {flags.length > 0 ? (
+
+
+
+ | Flag |
+ Key |
+ Enabled |
+ Action |
+
+
+
+ {flags.map((flag) => {
+ const isInherited =
+ flag.value && flag.specificTargetValue === null;
+
+ return (
+
+ | {flag.name} |
+ {flag.key} |
+
+ {flag.value
+ ? isInherited
+ ? "Yes (implicitly)"
+ : "Yes"
+ : "No"}
+ |
+
+ {!isInherited && (
+
+ )}
+ |
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/page.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/page.tsx
new file mode 100644
index 00000000..ca33c750
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/page.tsx
@@ -0,0 +1,128 @@
+import Link from "next/link";
+import AppEnvForm from "./AppEnvForm";
+import { listApps, listEnvironments, listFlags } from "./actions";
+
+export const dynamic = "force-dynamic";
+
+type QueryValue = string | string[] | undefined;
+
+type PageProps = {
+ searchParams?: {
+ appId?: QueryValue;
+ envId?: QueryValue;
+ };
+};
+
+function getQueryValue(value: QueryValue) {
+ return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
+}
+
+export default async function FlagsPage({ searchParams }: PageProps) {
+ const apps = (await listApps()).data ?? [];
+ const requestedAppId = getQueryValue(searchParams?.appId);
+ const selectedAppId =
+ apps.find((app) => app.id === requestedAppId)?.id ?? apps[0]?.id ?? "";
+
+ const envs = selectedAppId
+ ? ((await listEnvironments(selectedAppId)).data ?? [])
+ : [];
+
+ const requestedEnvId = getQueryValue(searchParams?.envId);
+ const selectedEnvId =
+ envs.find((env) => env.id === requestedEnvId)?.id ?? envs[0]?.id ?? "";
+
+ const flags = selectedAppId
+ ? ((await listFlags(selectedAppId)).data ?? [])
+ : [];
+
+ return (
+
+ Flags
+
+
+
+
+
+
+
+
+
+ {flags.length === 0 ? No flags loaded.
: null}
+ {flags.length > 0 ? (
+
+
+
+ | Flag |
+ Key |
+ Open |
+
+
+
+ {flags.map((flag) => (
+
+ | {flag.name} |
+ {flag.key} |
+
+
+ User
+
+ {" / "}
+
+ Company
+
+ |
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/user/page.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/user/page.tsx
new file mode 100644
index 00000000..e50ea01e
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/flags/user/page.tsx
@@ -0,0 +1,146 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import EntityFlagsFilterForm from "../EntityFlagsFilterForm";
+import {
+ fetchUserFlags,
+ listApps,
+ listEnvironments,
+ toggleUserFlag,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type QueryValue = string | string[] | undefined;
+
+type PageProps = {
+ searchParams?: {
+ appId?: QueryValue;
+ envId?: QueryValue;
+ userId?: QueryValue;
+ };
+};
+
+function getQueryValue(value: QueryValue) {
+ return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
+}
+
+export default async function UserFlagsPage({ searchParams }: PageProps) {
+ const apps = (await listApps()).data ?? [];
+ const requestedAppId = getQueryValue(searchParams?.appId);
+ const selectedAppId =
+ apps.find((app) => app.id === requestedAppId)?.id ?? apps[0]?.id ?? "";
+
+ const envs = selectedAppId
+ ? ((await listEnvironments(selectedAppId)).data ?? [])
+ : [];
+
+ const requestedEnvId = getQueryValue(searchParams?.envId);
+ const selectedEnvId =
+ envs.find((env) => env.id === requestedEnvId)?.id ?? envs[0]?.id ?? "";
+
+ const userId = getQueryValue(searchParams?.userId);
+
+ const flags =
+ selectedAppId && selectedEnvId && userId
+ ? ((await fetchUserFlags(selectedAppId, selectedEnvId, userId)).data ??
+ [])
+ : [];
+
+ async function updateUserFlagAction(formData: FormData) {
+ "use server";
+
+ const appId = String(formData.get("appId") ?? "");
+ const envId = String(formData.get("envId") ?? "");
+ const nextUserId = String(formData.get("userId") ?? "");
+ const flagKey = String(formData.get("flagKey") ?? "");
+ const nextValue = String(formData.get("nextValue") ?? "") === "true";
+
+ await toggleUserFlag(appId, envId, nextUserId, flagKey, nextValue);
+
+ const query = new URLSearchParams({ appId, envId, userId: nextUserId });
+ revalidatePath("/flags/user");
+ redirect(`/flags/user?${query.toString()}`);
+ }
+
+ return (
+
+ Back to flags
+ User Flags
+
+
+
+ {!userId ? Enter a user ID to load flags.
: null}
+ {userId && flags.length === 0 ? No flags loaded.
: null}
+
+ {flags.length > 0 ? (
+
+
+
+ | Flag |
+ Key |
+ Enabled |
+ Action |
+
+
+
+ {flags.map((flag) => {
+ const isInherited =
+ flag.value && flag.specificTargetValue === null;
+
+ return (
+
+ | {flag.name} |
+ {flag.key} |
+
+ {flag.value
+ ? isInherited
+ ? "Yes (implicitly)"
+ : "Yes"
+ : "No"}
+ |
+
+ {!isInherited && (
+
+ )}
+ |
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/globals.css b/packages/rest-api-sdk/examples/customer-admin-panel/app/globals.css
new file mode 100644
index 00000000..18ac133d
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/globals.css
@@ -0,0 +1,82 @@
+:root {
+ color-scheme: light;
+ font-family:
+ "Inter",
+ "SF Pro Text",
+ system-ui,
+ -apple-system,
+ sans-serif;
+ line-height: 1.5;
+ background: #f7f7f8;
+ color: #111827;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: #f7f7f8;
+}
+
+main {
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 16px;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
+}
+
+h1 {
+ margin-top: 0;
+}
+
+label span {
+ font-size: 0.9rem;
+ color: #4b5563;
+}
+
+input,
+select,
+button {
+ font: inherit;
+ border-radius: 10px;
+ border: 1px solid #d1d5db;
+ padding: 8px 12px;
+}
+
+button {
+ background: #111827;
+ color: #ffffff;
+ cursor: pointer;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+a {
+ color: #111827;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+thead th {
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #6b7280;
+}
+
+tbody tr:nth-child(even) {
+ background: #f9fafb;
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/layout.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/layout.tsx
new file mode 100644
index 00000000..c797f32b
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/layout.tsx
@@ -0,0 +1,19 @@
+import "./globals.css";
+
+export const metadata = {
+ title: "Customer Admin Panel",
+ description:
+ "Customer admin panel example app powered by the Reflag REST API SDK",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/app/page.tsx b/packages/rest-api-sdk/examples/customer-admin-panel/app/page.tsx
new file mode 100644
index 00000000..fa5397a9
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/app/page.tsx
@@ -0,0 +1,7 @@
+import FlagsPage from "./flags/page";
+
+export const dynamic = "force-dynamic";
+
+export default function Home() {
+ return ;
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/docs/company-flags-screenshot.png b/packages/rest-api-sdk/examples/customer-admin-panel/docs/company-flags-screenshot.png
new file mode 100644
index 00000000..f2f208b5
Binary files /dev/null and b/packages/rest-api-sdk/examples/customer-admin-panel/docs/company-flags-screenshot.png differ
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts b/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts
new file mode 100644
index 00000000..fd36f949
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/next.config.js b/packages/rest-api-sdk/examples/customer-admin-panel/next.config.js
new file mode 100644
index 00000000..22a21b45
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/next.config.js
@@ -0,0 +1,7 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ transpilePackages: ["@reflag/rest-api-sdk"],
+ reactStrictMode: true,
+};
+
+module.exports = nextConfig;
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/package.json b/packages/rest-api-sdk/examples/customer-admin-panel/package.json
new file mode 100644
index 00000000..ccab0eba
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "customer-admin-panel",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "echo 'No lint configured for customer-admin-panel example; skipping.'"
+ },
+ "dependencies": {
+ "@reflag/rest-api-sdk": "workspace:*",
+ "next": "14.2.5",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ }
+}
diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/tsconfig.json b/packages/rest-api-sdk/examples/customer-admin-panel/tsconfig.json
new file mode 100644
index 00000000..8e906d60
--- /dev/null
+++ b/packages/rest-api-sdk/examples/customer-admin-panel/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "@reflag/tsconfig/library",
+ "compilerOptions": {
+ "lib": ["ES2020", "DOM"],
+ "module": "ESNext",
+ "jsx": "preserve",
+ "noEmit": true,
+ "moduleResolution": "Bundler",
+ "allowJs": true,
+ "skipLibCheck": true,
+ "incremental": true,
+ "isolatedModules": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "strictNullChecks": true
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/rest-api-sdk/openapi-generator.config.yaml b/packages/rest-api-sdk/openapi-generator.config.yaml
new file mode 100644
index 00000000..d0adc698
--- /dev/null
+++ b/packages/rest-api-sdk/openapi-generator.config.yaml
@@ -0,0 +1,15 @@
+generatorName: typescript-fetch
+inputSpec: https://app.reflag.com/openapi.json
+outputDir: src/generated
+templateDir: ./openapi-templates
+additionalProperties:
+ supportsES6: true
+ typescriptThreePlus: true
+ useSingleRequestParameter: true
+ withInterfaces: true
+ useTags: false
+globalProperties:
+ apiDocs: false
+ modelDocs: false
+ apiTests: false
+ modelTests: false
diff --git a/packages/rest-api-sdk/openapi-templates/apis.mustache b/packages/rest-api-sdk/openapi-templates/apis.mustache
new file mode 100644
index 00000000..33c8ddf6
--- /dev/null
+++ b/packages/rest-api-sdk/openapi-templates/apis.mustache
@@ -0,0 +1,485 @@
+/* tslint:disable */
+/* eslint-disable */
+{{>licenseInfo}}
+
+
+import * as runtime from '../runtime{{importFileExtension}}';
+{{#imports.0}}
+import type {
+ {{#imports}}
+ {{className}},
+ {{/imports}}
+} from '../models/index{{importFileExtension}}';
+{{^withoutRuntimeChecks}}
+import {
+ {{#imports}}
+ {{className}}FromJSON,
+ {{className}}ToJSON,
+ {{/imports}}
+} from '../models/index{{importFileExtension}}';
+{{/withoutRuntimeChecks}}
+{{/imports.0}}
+
+{{#operations}}
+{{#operation}}
+{{#allParams.0}}
+export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request {{#bodyParam}}extends {{{dataType}}} {{/bodyParam}}{
+{{#allParams}}
+ {{^isBodyParam}}
+ {{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{#hasReadOnly}}Omit<{{{dataType}}}, {{#readOnlyVars}}'{{baseName}}'{{^-last}}|{{/-last}}{{/readOnlyVars}}>{{/hasReadOnly}}{{^hasReadOnly}}{{{dataType}}}{{/hasReadOnly}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}};
+ {{/isBodyParam}}
+{{/allParams}}
+}
+
+{{/allParams.0}}
+{{/operation}}
+{{/operations}}
+{{#withInterfaces}}
+{{#operations}}
+/**
+ * {{classname}} - interface
+ * {{#lambda.indented_1}}{{{unescapedDescription}}}{{/lambda.indented_1}}
+ * @export
+ * @interface {{classname}}Interface
+ */
+export interface {{classname}}Interface {
+{{#operation}}
+ /**
+ * {{¬es}}
+ {{#summary}}
+ * @summary {{&summary}}
+ {{/summary}}
+ {{#allParams}}
+ {{#isBodyParam}}
+ {{#vars}}
+ * @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{name}}{{^required}}]{{/required}} {{description}}
+ {{/vars}}
+ {{/isBodyParam}}
+ {{^isBodyParam}}
+ * @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
+ {{/isBodyParam}}
+ {{/allParams}}
+ * @param {*} [options] Override http request option.
+ {{#isDeprecated}}
+ * @deprecated
+ {{/isDeprecated}}
+ * @throws {RequiredError}
+ * @memberof {{classname}}Interface
+ */
+ {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>;
+
+ /**
+ {{#notes}}
+ * {{¬es}}
+ {{/notes}}
+ {{#summary}}
+ * {{&summary}}
+ {{/summary}}
+ {{#isDeprecated}}
+ * @deprecated
+ {{/isDeprecated}}
+ */
+ {{^useSingleRequestParameter}}
+ {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
+ {{/useSingleRequestParameter}}
+ {{#useSingleRequestParameter}}
+ {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>;
+ {{/useSingleRequestParameter}}
+
+{{/operation}}
+}
+
+{{/operations}}
+{{/withInterfaces}}
+{{#operations}}
+/**
+ * {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}}
+ */
+{{#withInterfaces}}
+export class {{classname}} extends runtime.BaseAPI implements {{classname}}Interface {
+{{/withInterfaces}}
+{{^withInterfaces}}
+export class {{classname}} extends runtime.BaseAPI {
+{{/withInterfaces}}
+
+ {{#operation}}
+ /**
+ {{#notes}}
+ * {{¬es}}
+ {{/notes}}
+ {{#summary}}
+ * {{&summary}}
+ {{/summary}}
+ {{#isDeprecated}}
+ * @deprecated
+ {{/isDeprecated}}
+ */
+ async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> {
+ {{#allParams}}
+ {{#required}}
+ if (requestParameters['{{paramName}}'] == null) {
+ throw new runtime.RequiredError(
+ '{{paramName}}',
+ 'Required parameter "{{paramName}}" was null or undefined when calling {{nickname}}().'
+ );
+ }
+
+ {{/required}}
+ {{/allParams}}
+ const queryParameters: any = {};
+
+ {{#queryParams}}
+ {{#isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ {{#isCollectionFormatMulti}}
+ queryParameters['{{baseName}}'] = requestParameters['{{paramName}}'];
+ {{/isCollectionFormatMulti}}
+ {{^isCollectionFormatMulti}}
+ queryParameters['{{baseName}}'] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters['{{paramName}}']{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]);
+ {{/isCollectionFormatMulti}}
+ }
+
+ {{/isArray}}
+ {{^isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ {{#isExplode}}
+ {{#isContainer}}
+ for (let key of Object.keys(requestParameters['{{paramName}}'])) {
+ queryParameters[key] = requestParameters['{{paramName}}'][key];
+ }
+ {{/isContainer}}
+ {{^isContainer}}
+{{>apisAssignQueryParam}}
+ {{/isContainer}}
+ {{/isExplode}}
+ {{^isExplode}}
+{{>apisAssignQueryParam}}
+ {{/isExplode}}
+ }
+
+ {{/isArray}}
+ {{/queryParams}}
+ const headerParameters: runtime.HTTPHeaders = {};
+
+ {{#bodyParam}}
+ {{^consumes}}
+ headerParameters['Content-Type'] = 'application/json';
+
+ {{/consumes}}
+ {{#consumes.0}}
+ headerParameters['Content-Type'] = '{{{mediaType}}}';
+
+ {{/consumes.0}}
+ {{/bodyParam}}
+ {{#headerParams}}
+ {{#isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ headerParameters['{{baseName}}'] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters['{{paramName}}']{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]);
+ }
+
+ {{/isArray}}
+ {{^isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ headerParameters['{{baseName}}'] = String(requestParameters['{{paramName}}']);
+ }
+
+ {{/isArray}}
+ {{/headerParams}}
+ {{#authMethods}}
+ {{#isBasic}}
+ {{#isBasicBasic}}
+ if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) {
+ headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password);
+ }
+ {{/isBasicBasic}}
+ {{#isBasicBearer}}
+ if (this.configuration && this.configuration.accessToken) {
+ const token = this.configuration.accessToken;
+ const tokenString = await token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]);
+
+ if (tokenString) {
+ headerParameters["Authorization"] = `Bearer ${tokenString}`;
+ }
+ }
+ {{/isBasicBearer}}
+ {{/isBasic}}
+ {{#isApiKey}}
+ {{#isKeyInHeader}}
+ if (this.configuration && this.configuration.apiKey) {
+ headerParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication
+ }
+
+ {{/isKeyInHeader}}
+ {{#isKeyInQuery}}
+ if (this.configuration && this.configuration.apiKey) {
+ queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication
+ }
+
+ {{/isKeyInQuery}}
+ {{/isApiKey}}
+ {{#isOAuth}}
+ if (this.configuration && this.configuration.accessToken) {
+ // oauth required
+ headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]);
+ }
+
+ {{/isOAuth}}
+ {{/authMethods}}
+ {{#hasFormParams}}
+ const consumes: runtime.Consume[] = [
+ {{#consumes}}
+ { contentType: '{{{mediaType}}}' },
+ {{/consumes}}
+ ];
+ // @ts-ignore: canConsumeForm may be unused
+ const canConsumeForm = runtime.canConsumeForm(consumes);
+
+ let formParams: { append(param: string, value: any): any };
+ let useForm = false;
+ {{#formParams}}
+ {{#isFile}}
+ // use FormData to transmit files using content-type "multipart/form-data"
+ useForm = canConsumeForm;
+ {{/isFile}}
+ {{/formParams}}
+ if (useForm) {
+ formParams = new FormData();
+ } else {
+ formParams = new URLSearchParams();
+ }
+
+ {{#formParams}}
+ {{#isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ {{#isCollectionFormatMulti}}
+ requestParameters['{{paramName}}'].forEach((element) => {
+ formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', element as any);
+ })
+ {{/isCollectionFormatMulti}}
+ {{^isCollectionFormatMulti}}
+ formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters['{{paramName}}']{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]));
+ {{/isCollectionFormatMulti}}
+ }
+
+ {{/isArray}}
+ {{^isArray}}
+ if (requestParameters['{{paramName}}'] != null) {
+ {{#isDateTimeType}}
+ formParams.append('{{baseName}}', (requestParameters['{{paramName}}'] as any).toISOString());
+ {{/isDateTimeType}}
+ {{^isDateTimeType}}
+ {{#isPrimitiveType}}
+ formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any);
+ {{/isPrimitiveType}}
+ {{^isPrimitiveType}}
+ {{#isEnumRef}}
+ formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any);
+ {{/isEnumRef}}
+ {{^isEnumRef}}
+ {{^withoutRuntimeChecks}}
+ formParams.append('{{baseName}}', new Blob([JSON.stringify({{{dataType}}}ToJSON(requestParameters['{{paramName}}']))], { type: "application/json", }));
+ {{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}}
+ formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters['{{paramName}}'])], { type: "application/json", }));
+ {{/withoutRuntimeChecks}}
+ {{/isEnumRef}}
+ {{/isPrimitiveType}}
+ {{/isDateTimeType}}
+ }
+
+ {{/isArray}}
+ {{/formParams}}
+ {{/hasFormParams}}
+
+ let urlPath = `{{{path}}}`;
+ {{#pathParams}}
+ {{#isDateTimeType}}
+ if (requestParameters['{{paramName}}'] instanceof Date) {
+ urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(requestParameters['{{paramName}}'].toISOString()));
+ } else {
+ urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters['{{paramName}}'])));
+ }
+ {{/isDateTimeType}}
+ {{^isDateTimeType}}
+ {{#isDateType}}
+ if (requestParameters['{{paramName}}'] instanceof Date) {
+ urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(requestParameters['{{paramName}}'].toISOString().substring(0,10)));
+ } else {
+ urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters['{{paramName}}'])));
+ }
+ {{/isDateType}}
+ {{^isDateType}}
+ urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters['{{paramName}}'])));
+ {{/isDateType}}
+ {{/isDateTimeType}}
+ {{/pathParams}}
+
+ const response = await this.request({
+ path: urlPath,
+ method: '{{httpMethod}}',
+ headers: headerParameters,
+ query: queryParameters,
+ {{#hasBodyParam}}
+ {{#bodyParam}}
+ {{^withoutRuntimeChecks}}
+ body: {{dataType}}ToJSON({
+ {{#vars}}
+ {{name}}: requestParameters['{{name}}'],
+ {{/vars}}
+ }),
+ {{/withoutRuntimeChecks}}
+ {{#withoutRuntimeChecks}}
+ body: {
+ {{#vars}}
+ {{name}}: requestParameters['{{name}}'],
+ {{/vars}}
+ },
+ {{/withoutRuntimeChecks}}
+ {{/bodyParam}}
+ {{/hasBodyParam}}
+ {{#hasFormParams}}
+ body: formParams,
+ {{/hasFormParams}}
+ }, initOverrides);
+
+ {{#returnType}}
+ {{#isResponseFile}}
+ return new runtime.BlobApiResponse(response);
+ {{/isResponseFile}}
+ {{^isResponseFile}}
+ {{#returnTypeIsPrimitive}}
+ {{#isMap}}
+ return new runtime.JSONApiResponse(response);
+ {{/isMap}}
+ {{#isArray}}
+ return new runtime.JSONApiResponse(response);
+ {{/isArray}}
+ {{#returnSimpleType}}
+ if (this.isJsonMime(response.headers.get('content-type'))) {
+ return new runtime.JSONApiResponse<{{returnType}}>(response);
+ } else {
+ return new runtime.TextApiResponse(response) as any;
+ }
+ {{/returnSimpleType}}
+ {{/returnTypeIsPrimitive}}
+ {{^returnTypeIsPrimitive}}
+ {{#isArray}}
+ return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{#uniqueItems}}new Set({{/uniqueItems}}jsonValue.map({{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}){{#uniqueItems}}){{/uniqueItems}};
+ {{/isArray}}
+ {{^isArray}}
+ {{#isMap}}
+ return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{returnBaseType}}FromJSON){{/withoutRuntimeChecks}});
+ {{/isMap}}
+ {{^isMap}}
+ return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{returnBaseType}}FromJSON(jsonValue){{/withoutRuntimeChecks}});
+ {{/isMap}}
+ {{/isArray}}
+ {{/returnTypeIsPrimitive}}
+ {{/isResponseFile}}
+ {{/returnType}}
+ {{^returnType}}
+ return new runtime.VoidApiResponse(response);
+ {{/returnType}}
+ }
+
+ /**
+ {{#notes}}
+ * {{¬es}}
+ {{/notes}}
+ {{#summary}}
+ * {{&summary}}
+ {{/summary}}
+ {{#isDeprecated}}
+ * @deprecated
+ {{/isDeprecated}}
+ */
+ {{^useSingleRequestParameter}}
+ async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
+ {{#returnType}}
+ const response = await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/allParams.0}}initOverrides);
+ {{#isResponseOptional}}
+ switch (response.raw.status) {
+ {{#responses}}
+ {{#is2xx}}
+ case {{code}}:
+ return {{#dataType}}await response.value(){{/dataType}}{{^dataType}}null{{/dataType}};
+ {{/is2xx}}
+ {{/responses}}
+ default:
+ return await response.value();
+ }
+ {{/isResponseOptional}}
+ {{^isResponseOptional}}
+ return await response.value();
+ {{/isResponseOptional}}
+ {{/returnType}}
+ {{^returnType}}
+ await this.{{nickname}}Raw({{#allParams.0}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/allParams.0}}initOverrides);
+ {{/returnType}}
+ }
+ {{/useSingleRequestParameter}}
+ {{#useSingleRequestParameter}}
+ async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
+ {{#returnType}}
+ const response = await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides);
+ {{#isResponseOptional}}
+ switch (response.raw.status) {
+ {{#responses}}
+ {{#is2xx}}
+ case {{code}}:
+ return {{#dataType}}await response.value(){{/dataType}}{{^dataType}}null{{/dataType}};
+ {{/is2xx}}
+ {{/responses}}
+ default:
+ return await response.value();
+ }
+ {{/isResponseOptional}}
+ {{^isResponseOptional}}
+ return await response.value();
+ {{/isResponseOptional}}
+ {{/returnType}}
+ {{^returnType}}
+ await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides);
+ {{/returnType}}
+ }
+ {{/useSingleRequestParameter}}
+
+ {{/operation}}
+}
+{{/operations}}
+{{#hasEnums}}
+
+{{#operations}}
+{{#operation}}
+{{#allParams}}
+{{#isEnum}}
+{{#stringEnums}}
+/**
+ * @export
+ * @enum {string}
+ */
+export enum {{operationIdCamelCase}}{{enumName}} {
+{{#allowableValues}}
+ {{#enumVars}}
+ {{{name}}} = {{{value}}}{{^-last}},{{/-last}}
+ {{/enumVars}}
+{{/allowableValues}}
+}
+{{/stringEnums}}
+{{^stringEnums}}
+/**
+ * @export
+ */
+export const {{operationIdCamelCase}}{{enumName}} = {
+{{#allowableValues}}
+ {{#enumVars}}
+ {{{name}}}: {{{value}}}{{^-last}},{{/-last}}
+ {{/enumVars}}
+{{/allowableValues}}
+} as const;
+export type {{operationIdCamelCase}}{{enumName}} = typeof {{operationIdCamelCase}}{{enumName}}[keyof typeof {{operationIdCamelCase}}{{enumName}}];
+{{/stringEnums}}
+{{/isEnum}}
+{{/allParams}}
+{{/operation}}
+{{/operations}}
+{{/hasEnums}}
diff --git a/packages/rest-api-sdk/openapi.json b/packages/rest-api-sdk/openapi.json
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/rest-api-sdk/openapitools.json b/packages/rest-api-sdk/openapitools.json
new file mode 100644
index 00000000..dae25538
--- /dev/null
+++ b/packages/rest-api-sdk/openapitools.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
+ "spaces": 2,
+ "generator-cli": {
+ "version": "7.19.0"
+ }
+}
diff --git a/packages/rest-api-sdk/package.json b/packages/rest-api-sdk/package.json
new file mode 100644
index 00000000..114484e8
--- /dev/null
+++ b/packages/rest-api-sdk/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@reflag/rest-api-sdk",
+ "version": "0.0.2",
+ "description": "Reflag REST API SDK",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/reflagcom/javascript.git"
+ },
+ "scripts": {
+ "generate": "rm -rf src/generated && npx @openapitools/openapi-generator-cli generate -c openapi-generator.config.yaml",
+ "build": "yarn generate && tsc --project tsconfig.build.json",
+ "test": "vitest run --passWithNoTests",
+ "test:watch": "vitest",
+ "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml",
+ "lint": "eslint .",
+ "lint:ci": "eslint --max-warnings=0 --output-file eslint-report.json --format json .",
+ "prettier": "prettier --check .",
+ "format": "yarn lint --fix && yarn prettier --write",
+ "preversion": "yarn lint && yarn prettier && yarn test && yarn build"
+ },
+ "files": [
+ "dist"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/types/index.d.ts",
+ "devDependencies": {
+ "@openapitools/openapi-generator-cli": "^2.13.5",
+ "@reflag/eslint-config": "~0.0.2",
+ "@reflag/tsconfig": "~0.0.2",
+ "@types/node": "^22.12.0",
+ "@vitest/coverage-v8": "~1.6.0",
+ "eslint": "^9.21.0",
+ "prettier": "^3.5.2",
+ "typescript": "^5.7.3",
+ "vitest": "~1.6.0"
+ }
+}
diff --git a/packages/rest-api-sdk/src/api.ts b/packages/rest-api-sdk/src/api.ts
new file mode 100644
index 00000000..64dce4b5
--- /dev/null
+++ b/packages/rest-api-sdk/src/api.ts
@@ -0,0 +1,162 @@
+import type {
+ ConfigurationParameters,
+ InitOverrideFunction,
+ RequestOpts,
+} from "./generated/runtime";
+import { ResponseError } from "./generated/runtime";
+import { Configuration, DefaultApi } from "./generated";
+
+type OmitAppIdParam = F extends (
+ arg1: infer A,
+ ...rest: infer R
+) => infer Ret
+ ? A extends { appId: string }
+ ? (arg1: Omit, ...rest: R) => Ret
+ : F
+ : F;
+
+export type AppScopedApi = {
+ [K in keyof T]: OmitAppIdParam;
+};
+
+export class ReflagApiError extends Error {
+ status: number;
+ code?: string;
+ details?: unknown;
+
+ constructor(
+ status: number,
+ message: string,
+ code?: string,
+ details?: unknown,
+ ) {
+ super(message);
+ this.name = "ReflagApiError";
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+async function buildApiError(response: Response) {
+ const status = response.status;
+ const contentType = response.headers.get("content-type") ?? "";
+ let details: unknown = undefined;
+ let message = `Request failed with ${status}`;
+ let code: string | undefined;
+
+ if (contentType.includes("application/json")) {
+ try {
+ details = await response.clone().json();
+ const maybeError = (
+ details as { error?: { message?: string; code?: string } }
+ )?.error;
+ if (maybeError?.message) {
+ message = maybeError.message;
+ }
+ if (maybeError?.code) {
+ code = maybeError.code;
+ }
+ } catch {
+ details = undefined;
+ }
+ } else {
+ try {
+ const text = await response.clone().text();
+ if (text) {
+ details = text;
+ message = `Request failed with ${status}: ${text}`;
+ }
+ } catch {
+ details = undefined;
+ }
+ }
+
+ return new ReflagApiError(status, message, code, details);
+}
+
+export class Api extends DefaultApi {
+ constructor(config?: ConfigurationParameters) {
+ super(new Configuration(config));
+ }
+
+ protected async request(
+ context: RequestOpts,
+ initOverrides?: RequestInit | InitOverrideFunction,
+ ): Promise {
+ try {
+ return await super.request(context, initOverrides);
+ } catch (error) {
+ if (error instanceof ResponseError) {
+ throw await buildApiError(error.response);
+ }
+ throw error;
+ }
+ }
+}
+
+function isPlainObject(
+ value: unknown,
+): value is Record | Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return false;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+function scopeApiToAppId(
+ api: T,
+ appId: string,
+ scopedCache: WeakMap