Skip to content

mgvdev/fanion-adonisjs

Repository files navigation

AdonisJS Fanion 🏁

AdonisJS adapter for the Fanion feature flagging library

npm version npm downloads license

AdonisJS Fanion is the official AdonisJS adapter for the Fanion feature flagging library. It provides a seamless integration with AdonisJS applications, allowing you to control feature rollouts, conduct A/B tests, and manage application behavior dynamically.

Features

  • 🚀 AdonisJS Native - Built specifically for AdonisJS v6 with full IoC support
  • 🔧 TypeScript First - Complete type safety and IntelliSense support
  • 🏪 Multiple Storage Options - Memory, database, or custom storage providers
  • 🎯 Context-Aware - Automatic HTTP context integration
  • 🛡️ Middleware Support - Protect routes with feature flags
  • 🎨 Decorators - Use decorators in controllers for feature gating
  • 📊 A/B Testing - Built-in utilities for A/B testing and gradual rollouts
  • 🌍 Environment Support - Environment-based feature flags

Installation

npm i @mgvdev/fanion-adonisjs

Next, configure the package using the configure command:

node ace configure @mgvdev/fanion-adonisjs

This will:

  • Create a config/fanion.ts configuration file
  • Register the service provider
  • Set up environment variables
  • Optionally install database drivers

Quick Start

Basic Usage

// Define feature flags in config/fanion.ts
export default defineConfig({
  features: [
    {
      name: 'new-dashboard',
      description: 'Enable new dashboard design',
      check: () => true,
    },
    {
      name: 'beta-features',
      description: 'Enable beta features for specific users',
      check: (context) => context.user?.isBetaUser === true,
    },
  ],
})
// Use in controllers
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { FanionService } from '@fanion/adonisjs/types'

export default class DashboardController {
  @inject()
  async index({ response, auth }: HttpContext, fanion: FanionService) {
    const showNewDashboard = await fanion.activeForRequest('new-dashboard', { auth })

    if (showNewDashboard) {
      return response.ok({ message: 'New dashboard!' })
    }

    return response.ok({ message: 'Classic dashboard' })
  }
}

Using Middleware

// routes/web.ts
import router from '@adonisjs/core/services/router'
import { featureFlag } from '@fanion/adonisjs'

// Protect entire route
router.get('/beta', [featureFlag('beta-features')]).use(async ({ response }) => {
  return response.ok({ message: 'Beta feature accessed!' })
})

// Redirect when disabled
router.get('/new-ui', [featureFlagWithRedirect('new-ui', '/old-ui')]).use(async ({ response }) => {
  return response.ok({ message: 'New UI!' })
})

Using Decorators

import { requireFeature } from '@fanion/adonisjs'

export default class AdminController {
  @requireFeature('admin-panel')
  async index({ response }: HttpContext) {
    return response.ok({ message: 'Admin panel' })
  }

  @requireFeature('super-admin', {
    onDisabled: 'redirect',
    redirectTo: '/dashboard',
  })
  async users({ response }: HttpContext) {
    return response.ok({ users: [] })
  }
}

Configuration

Basic Configuration

// config/fanion.ts
import { defineConfig } from '@fanion/adonisjs/types'
import env from '#start/env'

export default defineConfig({
  // Enable debug logging
  debug: env.get('NODE_ENV') === 'development',

  // Auto-initialize storage on app start
  autoInit: true,

  // Default context provider
  defaultContextProvider: () => ({
    environment: env.get('NODE_ENV'),
    appVersion: env.get('APP_VERSION', '1.0.0'),
  }),
})

Database Storage

// config/fanion.ts
import { defineConfig } from '@fanion/adonisjs/types'
import db from '@adonisjs/lucid/services/db'

export default defineConfig({
  storageDriver: {
    type: 'knex',
    config: {
      connection: db.connection(),
      tableName: 'feature_flags',
      featureNameColumn: 'feature_name',
      valueColumn: 'value',
    },
  },
})

Feature Flag Definitions

// config/fanion.ts
export default defineConfig({
  features: [
    // Simple boolean flag (stored in database)
    {
      name: 'maintenance-mode',
      description: 'Enable maintenance mode',
      store: true,
      defaultValue: false,
    },

    // User-based flag
    {
      name: 'premium-features',
      description: 'Enable premium features',
      check: (context) => {
        return context.user?.plan === 'premium' && context.user?.verified
      },
    },

    // Percentage-based rollout
    {
      name: 'new-feature',
      description: 'Roll out new feature to 25% of users',
      check: (context) => {
        const userId = context.user?.id || 0
        return userId % 100 < 25
      },
    },

    // Environment-based flag
    {
      name: 'debug-mode',
      description: 'Enable debug mode in development',
      check: () => process.env.NODE_ENV === 'development',
    },
  ],
})

Advanced Usage

A/B Testing

import { ABTesting } from '@fanion/adonisjs'

// In config/fanion.ts
export default defineConfig({
  features: [
    {
      name: 'checkout-variant-a',
      description: 'A/B test for checkout flow - Variant A',
      check: ABTesting.createABTest('checkout', 50, (ctx) => ctx.user?.id || 0),
    },
  ],
})

// In your controller
export default class CheckoutController {
  @inject()
  async show({ response }: HttpContext, fanion: FanionService) {
    const showVariantA = await fanion.active('checkout-variant-a', { user: auth.user })

    return response.ok({
      variant: showVariantA ? 'A' : 'B',
      checkoutFlow: showVariantA ? 'simplified' : 'standard',
    })
  }
}

Custom Context

// Create custom context for feature evaluation
import { createFeatureContext } from '@fanion/adonisjs'

export default class ApiController {
  @inject()
  async data({ request, auth }: HttpContext, fanion: FanionService) {
    const context = createFeatureContext(
      { auth },
      {
        apiVersion: request.header('api-version'),
        clientType: request.header('client-type'),
        country: request.header('cf-ipcountry'), // Cloudflare country header
      }
    )

    const features = await fanion.activeMany(
      ['enhanced-api', 'geo-restrictions', 'rate-limiting'],
      context
    )

    return response.ok({ features })
  }
}

Environment-Based Flags

import { EnvironmentFlags } from '@fanion/adonisjs'

export default defineConfig({
  features: [
    {
      name: 'dev-tools',
      check: EnvironmentFlags.developmentOnly(),
    },
    {
      name: 'analytics',
      check: EnvironmentFlags.productionOnly(),
    },
    {
      name: 'staging-banner',
      check: EnvironmentFlags.createEnvironmentFlag(['staging', 'development']),
    },
  ],
})

Multiple Flag Checks

export default class DashboardController {
  @inject()
  async index({ auth }: HttpContext, fanion: FanionService) {
    const flags = await fanion.activeMany(
      ['new-dashboard', 'advanced-analytics', 'export-feature', 'real-time-updates'],
      { user: auth.user }
    )

    return response.ok({
      dashboard: {
        showNewDesign: flags['new-dashboard'],
        showAnalytics: flags['advanced-analytics'],
        allowExport: flags['export-feature'],
        realTimeUpdates: flags['real-time-updates'],
      },
    })
  }
}

Middleware Options

Basic Middleware

import { featureFlag, featureFlagWithRedirect, featureFlagWithHandler } from '@fanion/adonisjs'

// Simple abort on disabled
router.get('/feature', [featureFlag('my-feature')])

// Redirect when disabled
router.get('/feature', [featureFlagWithRedirect('my-feature', '/coming-soon')])

// Custom handler when disabled
router.get('/feature', [
  featureFlagWithHandler('my-feature', ({ response }) => {
    return response.status(503).json({ message: 'Feature temporarily unavailable' })
  }),
])

Advanced Middleware

import { createFanionMiddleware } from '@fanion/adonisjs'

// Custom middleware with context provider
const betaMiddleware = createFanionMiddleware({
  flag: 'beta-features',
  contextProvider: async ({ auth, request }) => ({
    user: auth.user,
    userAgent: request.header('user-agent'),
    isMobile: request.header('user-agent')?.includes('Mobile'),
  }),
  onDisabled: 'custom',
  customHandler: ({ response }) => {
    return response.status(404).json({
      error: 'Feature not found',
      message: 'This feature is not available in your current plan',
    })
  },
})

router.get('/beta', [betaMiddleware])

Helpers and Utilities

Global Helpers

import { isFeatureActive, getUserFeatures, ifFeatureActive } from '@fanion/adonisjs'

// Check feature globally
const isEnabled = await isFeatureActive('my-feature')

// Get all features for a user
const userFeatures = await getUserFeatures(user, ['feature1', 'feature2'])

// Conditional execution
await ifFeatureActive('email-notifications', async () => {
  await sendWelcomeEmail(user)
})

View Helpers

// In a service provider or preloader
import { ViewHelpers } from '@fanion/adonisjs'

export default class AppProvider {
  async boot() {
    const fanion = await this.app.container.make('fanion')
    const view = await this.app.container.make('view')

    // Add global view helpers
    view.global(ViewHelpers.createViewGlobals(fanion))
  }
}
{{-- In your Edge templates --}}
@if(await isFeatureActive('new-ui'))
  <div class="new-ui-component">
    <!-- New UI content -->
  </div>
@else
  <div class="legacy-ui-component">
    <!-- Legacy UI content -->
  </div>
@end

Database Schema

When using database storage, the following table structure is created:

CREATE TABLE feature_flags (
  feature_name VARCHAR PRIMARY KEY,
  value BOOLEAN NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

You can customize the table name and column names in the configuration:

export default defineConfig({
  storageDriver: {
    type: 'knex',
    config: {
      connection: db.connection(),
      tableName: 'app_feature_flags',
      featureNameColumn: 'flag_name',
      valueColumn: 'is_enabled',
    },
  },
})

Best Practices

1. Use Descriptive Names

// Good
'user-profile-redesign'
'checkout-express-shipping'
'admin-advanced-analytics'

// Avoid
'flag1'
'test'
'new-feature'

2. Document Your Flags

export default defineConfig({
  features: [
    {
      name: 'payment-method-apple-pay',
      description: 'Enable Apple Pay as payment method in checkout',
      check: (context) => {
        // Only enable for iOS users
        return context.userAgent?.includes('iPhone') || context.userAgent?.includes('iPad')
      },
    },
  ],
})

3. Use Kill Switches for Risky Features

// Store critical features in database for quick toggles
{
  name: 'payment-processing',
  description: 'Kill switch for payment processing',
  store: true,
  defaultValue: true,
}

4. Implement Gradual Rollouts

{
  name: 'new-search-algorithm',
  description: 'Gradual rollout of new search algorithm',
  check: (context) => {
    const userId = context.user?.id || 0
    const rolloutPercentage = 10 // Start with 10%
    return (userId % 100) < rolloutPercentage
  },
}

5. Handle Errors Gracefully

export default class FeatureController {
  @inject()
  async show({ response }: HttpContext, fanion: FanionService) {
    try {
      const isActive = await fanion.active('experimental-feature')
      // Use feature
    } catch (error) {
      // Log error and fall back to safe default
      console.error('Feature flag error:', error)
      // Provide fallback behavior
    }
  }
}

Testing

Testing Feature Flags

// tests/functional/feature_flags.spec.ts
import { test } from '@japa/runner'
import { FanionServiceImpl } from '@fanion/adonisjs'

test.group('Feature Flags', () => {
  test('should enable premium features for premium users', async ({ assert }) => {
    const fanion = new FanionServiceImpl()

    fanion.define('premium-features', (context) => {
      return context.user?.plan === 'premium'
    })

    const result = await fanion.active('premium-features', {
      user: { plan: 'premium' },
    })

    assert.isTrue(result)
  })
})

Testing with Middleware

test('should protect routes with feature flags', async ({ client }) => {
  const response = await client.get('/beta-feature')

  // Should return 404 if feature is disabled
  response.assertStatus(404)
})

Migration Guide

From Manual Feature Flags

If you're currently using manual feature flag implementations:

// Before
if (process.env.ENABLE_NEW_FEATURE === 'true') {
  // Feature logic
}

// After
if (await fanion.active('new-feature')) {
  // Feature logic
}

From Other Feature Flag Libraries

Most feature flag libraries can be migrated by:

  1. Defining your existing flags in config/fanion.ts
  2. Replacing flag checks with fanion.active()
  3. Updating middleware to use Fanion middleware
  4. Migrating stored flags to your database

Troubleshooting

Common Issues

Feature flag not found error

FeatureNotExistsError: Feature flag 'my-feature' is not defined

Solution: Make sure the feature is defined in your configuration or via fanion.define().

Storage provider not initialized

Error: No store provider defined

Solution: Configure a storage driver in your configuration or set autoInit: true.

Middleware not working

Error: Binding not found: fanion

Solution: Make sure the FanionProvider is registered in your adonisrc.ts providers array.

Debug Mode

Enable debug mode to see detailed logging:

export default defineConfig({
  debug: true,
})

This will log all feature flag evaluations to help with debugging.

API Reference

FanionService

  • define<T>(name: string, check?: (context: T) => boolean | Promise<boolean>): void
  • defineAndStore(name: string, defaultValue?: boolean): Promise<void>
  • active<T>(name: string, context?: T): Promise<boolean>
  • activeForRequest(name: string, ctx: HttpContext, additionalContext?: any): Promise<boolean>
  • activeMany<T>(flags: string[], context?: T): Promise<Record<string, boolean>>
  • getDefinedFlags(): string[]

Middleware

  • featureFlag(flagName: string, onDisabled?: 'abort' | 'next')
  • featureFlagWithRedirect(flagName: string, redirectTo: string)
  • featureFlagWithHandler(flagName: string, customHandler: Function)
  • createFanionMiddleware(options: FanionMiddlewareOptions)

Helpers

  • isFeatureActive<T>(flagName: string, context?: T): Promise<boolean>
  • isFeatureActiveForRequest(flagName: string, ctx: HttpContext, additionalContext?: any): Promise<boolean>
  • getUserFeatures(user: any, flagNames?: string[]): Promise<Record<string, boolean>>
  • ifFeatureActive<T>(flagName: string, callback: Function, context?: T): Promise<any>
  • requireFeature(flagName: string, options?: object): MethodDecorator

Utilities

  • ABTesting.createABTest(testName: string, percentage: number, identifier?: Function)
  • ABTesting.percentageRollout(identifier: number | string, percentage: number): boolean
  • EnvironmentFlags.developmentOnly(): () => boolean
  • EnvironmentFlags.productionOnly(): () => boolean
  • EnvironmentFlags.createEnvironmentFlag(environments: string[]): () => boolean

Contributing

We welcome contributions! Please see our Contributing Guide for details.

License

MIT © Maxence Guyonvarho

Related


Made with ❤️ by Maxence Guyonvarho

About

AdonisJS adapter for Fanion feature flagging library

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published