Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions packages/adapter-devunus/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
extends: ['custom'],
root: true,
};
7 changes: 7 additions & 0 deletions packages/adapter-devunus/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @flags-sdk/devunus

## 0.1.0

### Initial Release

- Initial release of the Devunus adapter for flags-sdk
35 changes: 35 additions & 0 deletions packages/adapter-devunus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# @flags-sdk/devunus

Devunus adapter for [flags-sdk](https://github.com/vercel/flags).

- An adapter for loading feature flags from devunus (coming soon).
- A getProviderData function for use with the Flags Explorer (available today).

## Installation

```bash
npm install @flags-sdk/devunus
```

## Usage getProviderData

Use a server env key for DEVUNUS_ENV_KEY. You can find your environment key in the [Devunus Admin Console](https://app.devunus.com/admin/def/project/1/get-started/e0-0/keys).

`app/.well-known/vercel/flags/route.ts`:

```tsx
import { verifyAccess, type ApiData } from 'flags';
import { getProviderData } from '@flags-sdk/devunus';
import { NextResponse, type NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
const access = await verifyAccess(request.headers.get('Authorization'));
if (!access) return NextResponse.json(null, { status: 401 });

const flagData = await getProviderData({
envKey: process.env.DEVUNUS_ENV_KEY,
});

return NextResponse.json<ApiData>(flagData);
}
```
57 changes: 57 additions & 0 deletions packages/adapter-devunus/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@flags-sdk/devunus",
"version": "0.1.0",
"description": "Devunus adapter for flags-sdk",
"keywords": [
"feature-flags",
"devunus",
"flags-sdk"
],
"license": "MIT",
"author": "",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.js",
"typesVersions": {
"*": {
".": [
"dist/*.d.ts",
"dist/*.d.cts"
]
}
},
"files": [
"dist",
"CHANGELOG.md"
],
"scripts": {
"build": "rimraf dist && tsup",
"dev": "tsup --watch --clean=false",
"eslint": "eslint-runner",
"eslint:fix": "eslint-runner --fix",
"test": "vitest --run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "20.11.17",
"eslint-config-custom": "workspace:*",
"eslint-plugin-vitest": "0.5.4",
"flags": "workspace:*",
"msw": "2.6.4",
"rimraf": "6.0.1",
"tsconfig": "workspace:*",
"tsup": "8.0.1",
"typescript": "5.6.3",
"vitest": "1.4.0"
},
"publishConfig": {
"access": "public"
}
}
83 changes: 83 additions & 0 deletions packages/adapter-devunus/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
describe,
it,
expect,
vi,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from 'vitest';
import { devunusAdapter } from './index';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
http.get('https://api.devunus.com/api/flags', ({ request }) => {
const authHeader = request.headers.get('Authorization');
return HttpResponse.json({
flags: [
{
id: 'flag1',
name: 'enableFeatureX',
description: 'Enable feature X',
value: true,
type: 'boolean',
createdAt: 1615000000000,
updatedAt: 1620000000000,
},
],
baseUrl: 'https://app.devunus.com/admin/333/project/1',
});
}),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

describe('Devunus default adapter', () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
process.env.DEVUNUS_ENV_KEY = 'test-key';
});

afterEach(() => {
process.env = originalEnv;
});

it('should create an adapter with correct interface', () => {
const adapter = devunusAdapter.getFeature();
expect(adapter.provider).toBe('devunus');
expect(typeof adapter.getProviderData).toBe('function');
});

it('should pass environment variables to provider', async () => {
const customKey = 'custom-key';
process.env.DEVUNUS_ENV_KEY = customKey;
const adapter = devunusAdapter.getFeature();
await adapter.getProviderData();
// We only test that the adapter properly passes the env var
// The actual API response handling is tested in provider tests
});

it('should construct correct origin URL with project ID', async () => {
process.env.DEVUNUS_PROJECT_ID = 'test-project';
const adapter = devunusAdapter.getFeature();
const result = await adapter.getProviderData();

const featureX = result.definitions.enableFeatureX;
expect(featureX?.origin).toEqual(
'https://app.devunus.com/admin/333/project/1/flag/flag1',
);
});

it('should properly initialize with default configuration', () => {
const adapter = devunusAdapter.getFeature();
expect(adapter).toBeDefined();
expect(adapter.provider).toBe('devunus');
});
});
17 changes: 17 additions & 0 deletions packages/adapter-devunus/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getProviderData } from './provider';
export * from './provider';

/**
* Default adapter that uses environment variables for configuration
*/
export const devunusAdapter = {
getFeature: () => ({
provider: 'devunus',
getProviderData: () =>
getProviderData({
envKey: process.env.DEVUNUS_ENV_KEY || '',
}),
}),
};

export default devunusAdapter;
74 changes: 74 additions & 0 deletions packages/adapter-devunus/src/provider/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { getProviderData } from './index';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
http.get('https://api.devunus.com/api/flags', ({ request }) => {
const authHeader = request.headers.get('Authorization');

if (!authHeader || authHeader !== 'valid-key') {
return new HttpResponse(null, { status: 401 });
}

return HttpResponse.json({
flags: [
{
id: 'flag1',
name: 'enableFeatureX',
description: 'Enable feature X',
value: true,
type: 'boolean',
createdAt: 1615000000000,
updatedAt: 1620000000000,
},
{
id: 'flag2',
name: 'userTheme',
description: 'User theme preference',
value: 'dark',
type: 'string',
createdAt: 1615000000000,
updatedAt: 1620000000000,
},
],
baseUrl: 'https://app.devunus.com/admin/333/project/1',
});
}),
);

describe('Devunus provider', () => {
beforeAll(() => server.listen());
afterAll(() => server.close());

it('should return empty definitions and a hint when no env key is provided', async () => {
const result = await getProviderData({ envKey: '' });

expect(result.definitions).toEqual({});
expect(result.hints).toBeDefined();
expect(result.hints?.length).toBe(1);
expect(result.hints?.[0]?.key).toBe('devunus/missing-env-key');
});

it('should return empty definitions and a hint when the API returns an error', async () => {
const result = await getProviderData({ envKey: 'invalid-key' });

expect(result.definitions).toEqual({});
expect(result.hints).toBeDefined();
expect(result.hints?.length).toBe(1);
expect(result.hints?.[0]?.key).toBe('devunus/response-not-ok');
});

it('should return flag definitions when the API returns valid data', async () => {
const result = await getProviderData({ envKey: 'valid-key' });

expect(Object.keys(result.definitions)).toHaveLength(2);
expect(result.definitions.enableFeatureX).toBeDefined();
expect(result.definitions.userTheme).toBeDefined();
const enableFeatureX = result.definitions.enableFeatureX;
const userTheme = result.definitions.userTheme;
expect(enableFeatureX?.description).toBe('Enable feature X');
expect(userTheme?.description).toBe('User theme preference');
expect(result.hints?.length).toBe(0);
});
});
98 changes: 98 additions & 0 deletions packages/adapter-devunus/src/provider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ProviderData, JsonValue } from 'flags';

interface DevunusFlag {
id: string;
name: string;
description: string;
value: string;
type: 'boolean' | 'string' | 'number' | 'json';
createdAt: number;
updatedAt: number;
}

interface DevunusResponse {
flags: DevunusFlag[];
baseUrl: string;
}

export async function getProviderData(options: {
/**
* The Devunus environment key.
*/
envKey: string;
}): Promise<ProviderData> {
if (!options.envKey) {
return {
definitions: {},
hints: [
{
key: 'devunus/missing-env-key',
text: 'Missing DevUnus environment key',
},
],
};
}

try {
// Get from edge API
const response = await fetch(`https://api.devunus.com/api/flags`, {
headers: { Authorization: `${options.envKey}` },
cache: 'no-store',
});

if (response.status !== 200) {
return {
definitions: {},
hints: [
{
key: 'devunus/response-not-ok',
text: `Failed to fetch DevUnus flag definitions (received ${response.status} response)`,
},
],
};
}

const data = (await response.json()) as DevunusResponse;
const { flags, baseUrl } = data;

const definitions: ProviderData['definitions'] = {};

for (const flag of flags) {
const flagOptions = [];

// For boolean flags, provide true/false options
if (flag.type === 'boolean') {
flagOptions.push({ value: true, label: 'On' });
flagOptions.push({ value: false, label: 'Off' });
}

// For string flags, include the current value as an option
if (flag.type === 'string') {
flagOptions.push({ value: flag.value });
}

definitions[flag.name] = {
description: flag.description,
options: flagOptions,
origin: `${baseUrl}/flag/${flag.id}`,
updatedAt: flag.updatedAt,
createdAt: flag.createdAt,
defaultValue: flag.value,
};
}

return { definitions, hints: [] };
} catch (error) {
return {
definitions: {},
hints: [
{
key: 'devunus/unexpected-error',
text: `Unexpected error fetching DevUnus flag definitions: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
}
9 changes: 9 additions & 0 deletions packages/adapter-devunus/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "tsconfig/base.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist",
"types": ["node", "vitest/globals"]
}
}
Loading