Custom Cache Providers

Extend the BoxCache your way!

Overview

BoxLang's caching system provides a flexible architecture that allows you to create custom cache providers to integrate with any caching backend. Whether you want to integrate with Caffeine, Hazelcast, database storage, or any other caching solution, BoxLang's provider interface makes it straightforward.

Architecture Overview

The BoxLang cache system consists of several key components:

  • ICacheProvider - The main interface that all cache providers must implement

  • AbstractCacheProvider - An optional base class that provides common functionality

  • ICacheEntry - Interface representing individual cache entries

  • ICacheStats - Interface for tracking cache performance statistics

  • @BoxCache - Annotation for registering cache provider metadata

Required Interfaces

ICacheProvider

All cache providers must implement the ICacheProvider interface, which defines the contract for cache operations:

public interface ICacheProvider {
    // Configuration and lifecycle
    ICacheProvider configure( CacheService cacheService, CacheConfig config );
    void shutdown();
    
    // Basic operations
    Attempt<Object> get( String key );
    void set( String key, Object value );
    boolean clear( String key );
    void clearAll();
    
    // Bulk operations
    IStruct get( String... keys );
    void set( IStruct entries );
    
    // Cache management
    Array getKeys();
    boolean lookup( String key );
    int getSize();
    void reap();
    
    // Advanced operations
    Object getOrSet( String key, Supplier<Object> provider );
    CompletableFuture<Attempt<Object>> getAsync( String key );
    
    // Statistics and reporting
    ICacheStats getStats();
    IStruct getStoreMetadataReport();
}

ICacheStats

Cache providers must track performance statistics through the ICacheStats interface:

public interface ICacheStats {
    // Performance metrics
    int hitRate();
    long hits();
    long misses();
    
    // Cache status
    int objectCount();
    int expiredCount();
    long size();
    
    // Maintenance operations
    long garbageCollections();
    long evictionCount();
    long reapCount();
    Instant lastReapDatetime();
    Instant started();
    
    // Recording methods
    ICacheStats recordHit();
    ICacheStats recordMiss();
    ICacheStats recordEviction();
    ICacheStats recordGCHit();
    ICacheStats recordReap();
    ICacheStats reset();
    
    // Conversion
    IStruct toStruct();
}

ICacheEntry

Cache entries encapsulate the stored data along with metadata:

public interface ICacheEntry extends Serializable {
    Key key();
    Attempt<Object> value();
    Object rawValue();
    
    long timeout();
    long lastAccessTimeout();
    boolean isEternal();
    
    Instant created();
    Instant lastAccessed();
    long hits();
    
    IStruct metadata();
    IStruct toStruct();
    
    // Modification methods
    ICacheEntry setValue( Object value );
    ICacheEntry setMetadata( Struct metadata );
    ICacheEntry touchLastAccessed();
    ICacheEntry incrementHits();
}

Step-by-Step Implementation Guide

Step 1: Create the Provider Class

Create a new class that extends AbstractCacheProvider (recommended) or implements ICacheProvider directly:

package com.mycompany.cache;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.RemovalListener;
import ortus.boxlang.runtime.cache.BoxCache;
import ortus.boxlang.runtime.cache.providers.AbstractCacheProvider;

@BoxCache( 
    alias = "Caffeine", 
    description = "Caffeine-based high-performance cache provider",
    distributed = false,
    distributedLocking = false,
    tags = { "caffeine", "memory", "high-performance" }
)
public class CaffeineCacheProvider extends AbstractCacheProvider {
    
    private Cache<String, ICacheEntry> caffeineCache;
    private Duration defaultTimeout;
    private Duration defaultLastAccessTimeout;
    private int maxSize;
    
    @Override
    public synchronized ICacheProvider configure( CacheService cacheService, CacheConfig config ) {
        // Call parent configuration
        super.configure( cacheService, config );
        
        // Get configuration properties
        this.maxSize = IntegerCaster.cast( config.properties.get( Key.maxObjects ) );
        this.defaultTimeout = Duration.ofSeconds( 
            IntegerCaster.cast( config.properties.get( Key.defaultTimeout ) ) 
        );
        this.defaultLastAccessTimeout = Duration.ofSeconds(
            IntegerCaster.cast( config.properties.get( Key.defaultLastAccessTimeout ) )
        );
        
        // Build Caffeine cache
        Caffeine<Object, Object> builder = Caffeine.newBuilder()
            .maximumSize( this.maxSize )
            .removalListener( createRemovalListener() );
            
        // Add expiration if configured
        if ( this.defaultTimeout.toSeconds() > 0 ) {
            builder.expireAfterWrite( this.defaultTimeout );
        }
        
        if ( this.defaultLastAccessTimeout.toSeconds() > 0 ) {
            builder.expireAfterAccess( this.defaultLastAccessTimeout );
        }
        
        this.caffeineCache = builder.build();
        
        // Enable the provider
        this.enabled.set( true );
        
        logger.info( "Caffeine cache provider [{}] initialized with max size [{}]", 
                    getName().getName(), this.maxSize );
        
        return this;
    }
    
    @Override
    public void shutdown() {
        if ( this.caffeineCache != null ) {
            this.caffeineCache.invalidateAll();
        }
        logger.info( "Caffeine cache provider [{}] shutdown", getName().getName() );
    }
    
    /**
     * Create a removal listener for cache events
     */
    private RemovalListener<String, ICacheEntry> createRemovalListener() {
        return ( key, entry, cause ) -> {
            if ( entry != null ) {
                // Announce removal event
                announce(
                    BoxEvent.AFTER_CACHE_ELEMENT_REMOVED,
                    Struct.of( 
                        "cache", this, 
                        "key", key, 
                        "entry", entry,
                        "cause", cause.toString()
                    )
                );
                
                // Record eviction stats
                if ( cause.wasEvicted() ) {
                    getStats().recordEviction();
                }
            }
        };
    }
}

Step 2: Implement Core Cache Operations

@Override
public Attempt<Object> get( String key ) {
    try {
        ICacheEntry entry = this.caffeineCache.getIfPresent( key );
        
        if ( entry == null ) {
            getStats().recordMiss();
            return Attempt.empty();
        }
        
        // Check if expired (for custom expiration logic)
        if ( isExpired( entry ) ) {
            this.caffeineCache.invalidate( key );
            getStats().recordMiss();
            return Attempt.empty();
        }
        
        // Update access time and hits
        entry.touchLastAccessed();
        entry.incrementHits();
        
        getStats().recordHit();
        return entry.value();
        
    } catch ( Exception e ) {
        logger.error( "Error getting key [{}] from Caffeine cache", key, e );
        getStats().recordMiss();
        return Attempt.empty();
    }
}

@Override
public void set( String key, Object value, Object timeout, Object lastAccessTimeout ) {
    try {
        Duration dTimeout = toDuration( timeout, this.defaultTimeout );
        Duration dLastAccessTimeout = toDuration( lastAccessTimeout, this.defaultLastAccessTimeout );
        
        // Check if updating or not
        ICacheEntry oldEntry = this.caffeineCache.getIfPresent( key );
        
        // Create cache entry
        ICacheEntry entry = new BoxCacheEntry(
            getName(),
            dTimeout.toSeconds(),
            dLastAccessTimeout.toSeconds(),
            Key.of( key ),
            value,
            new Struct()
        );
        
        // Store in Caffeine cache
        this.caffeineCache.put( key, entry );
        
        // Announce the event
        if ( oldEntry != null ) {
            announce(
                BoxEvent.AFTER_CACHE_ELEMENT_UPDATED,
                Struct.of(
                    "cache", this,
                    "key", Key.of( key ),
                    "oldEntry", oldEntry,
                    "newEntry", entry
                )
            );
        } else {
            announce(
                BoxEvent.AFTER_CACHE_ELEMENT_INSERT,
                Struct.of(
                    "cache", this,
                    "key", Key.of( key ),
                    "entry", entry
                )
            );
        }
        
    } catch ( Exception e ) {
        logger.error( "Error setting key [{}] in Caffeine cache", key, e );
    }
}

@Override
public boolean clear( String key ) {
    try {
        // Announce before clearing
        announce(
            BoxEvent.BEFORE_CACHE_ELEMENT_REMOVED,
            Struct.of( "cache", this, "key", key )
        );
        
        ICacheEntry entry = this.caffeineCache.getIfPresent( key );
        boolean existed = entry != null;
        
        if ( existed ) {
            this.caffeineCache.invalidate( key );
        }
        
        // Announce after clearing
        announce(
            BoxEvent.AFTER_CACHE_ELEMENT_REMOVED,
            Struct.of( "cache", this, "key", key, "cleared", existed )
        );
        
        return existed;
        
    } catch ( Exception e ) {
        logger.error( "Error clearing key [{}] from Caffeine cache", key, e );
        return false;
    }
}

@Override
public void clearAll() {
    try {
        this.caffeineCache.invalidateAll();
        
        // Announce it
        announce(
            BoxEvent.AFTER_CACHE_CLEAR_ALL,
            Struct.of( "cache", this )
        );
        
    } catch ( Exception e ) {
        logger.error( "Error clearing all entries from Caffeine cache", e );
    }
}

Step 3: Implement Bulk Operations

@Override
public IStruct get( String... keys ) {
    IStruct results = new Struct();
    
    try {
        // Use Caffeine's bulk get for efficiency
        Map<String, ICacheEntry> entries = this.caffeineCache.getAllPresent( Arrays.asList( keys ) );
        
        for ( String key : keys ) {
            ICacheEntry entry = entries.get( key );
            
            if ( entry != null && !isExpired( entry ) ) {
                entry.touchLastAccessed();
                entry.incrementHits();
                results.put( key, entry.value() );
                getStats().recordHit();
            } else {
                if ( entry != null ) {
                    // Entry was expired, remove it
                    this.caffeineCache.invalidate( key );
                }
                getStats().recordMiss();
                results.put( key, Attempt.empty() );
            }
        }
        
    } catch ( Exception e ) {
        logger.error( "Error getting multiple keys from Caffeine cache", e );
        // Return empty attempts for all keys
        for ( String key : keys ) {
            results.put( key, Attempt.empty() );
        }
    }
    
    return results;
}

@Override
public Array getKeys() {
    try {
        return this.caffeineCache.asMap().keySet()
            .stream()
            .collect( BLCollector.toArray() );
    } catch ( Exception e ) {
        logger.error( "Error getting keys from Caffeine cache", e );
        return new Array();
    }
}

@Override
public Array getKeys( ICacheKeyFilter filter ) {
    try {
        return this.caffeineCache.asMap().keySet()
            .stream()
            .map( Key::of )
            .filter( filter )
            .map( Key::getName )
            .collect( BLCollector.toArray() );
    } catch ( Exception e ) {
        logger.error( "Error getting filtered keys from Caffeine cache", e );
        return new Array();
    }
}

@Override
public int getSize() {
    return ( int ) this.caffeineCache.estimatedSize();
}

@Override
public boolean lookup( String key ) {
    boolean found = this.caffeineCache.getIfPresent( key ) != null;
    
    // Update stats
    if ( found ) {
        getStats().recordHit();
    } else {
        getStats().recordMiss();
    }
    
    return found;
}

@Override
public boolean lookupQuiet( String key ) {
    return this.caffeineCache.getIfPresent( key ) != null;
}

Step 4: Implement Advanced Features

@Override
public void reap() {
    try {
        long start = System.currentTimeMillis();
        
        // Caffeine handles most cleanup automatically, but we can clean up
        // entries that have exceeded their lastAccessTimeout manually
        Instant now = Instant.now();
        Map<String, ICacheEntry> allEntries = this.caffeineCache.asMap();
        
        allEntries.entrySet().removeIf( entrySet => {
            ICacheEntry entry = entrySet.getValue();
            
            // Check last access timeout if configured
            if ( BooleanCaster.cast( config.properties.get( Key.useLastAccessTimeouts ) ) &&
                 entry.lastAccessTimeout() > 0 &&
                 entry.lastAccessed().plusSeconds( entry.lastAccessTimeout() ).isBefore( now ) ) {
                return true;
            }
            
            // Check main timeout for non-eternal entries
            if ( !entry.isEternal() &&
                 entry.created().plusSeconds( entry.timeout() ).isBefore( now ) ) {
                return true;
            }
            
            return false;
        } );
        
        // Trigger Caffeine's own cleanup
        this.caffeineCache.cleanUp();
        
        getStats().recordReap();
        
        logger.debug(
            "Finished reaping Caffeine cache [{}] in [{}]ms",
            getName().getName(),
            System.currentTimeMillis() - start
        );
        
    } catch ( Exception e ) {
        logger.error( "Error during Caffeine cache reap", e );
    }
}

@Override
public Object getOrSet( String key, Supplier<Object> provider, Object timeout, Object lastAccessTimeout ) {
    // Try to get the value first
    Attempt<Object> result = get( key );
    if ( result.isPresent() ) {
        return result.get();
    }
    
    // Use synchronized block for thread safety
    String lockKey = this.getName().getNameNoCase() + "-" + key;
    
    synchronized ( lockKey.intern() ) {
        // Double-check pattern
        result = get( key );
        if ( result.isPresent() ) {
            return result.get();
        }
        
        try {
            // Generate the value
            Object value = provider.get();
            
            // Store it
            set( key, value, timeout, lastAccessTimeout );
            
            return value;
            
        } catch ( Exception e ) {
            logger.error( "Error in getOrSet for key [{}]", key, e );
            throw new BoxRuntimeException( "Failed to generate value for key: " + key, e );
        }
    }
}

@Override
public Attempt<Object> getQuiet( String key ) {
    try {
        ICacheEntry entry = this.caffeineCache.getIfPresent( key );
        
        if ( entry == null ) {
            return Attempt.empty();
        }
        
        // Check if expired
        if ( isExpired( entry ) ) {
            this.caffeineCache.invalidate( key );
            return Attempt.empty();
        }
        
        return entry.value();
        
    } catch ( Exception e ) {
        logger.error( "Error getting key [{}] quietly from Caffeine cache", key, e );
        return Attempt.empty();
    }
}

/**
 * Check if an entry is expired based on custom business rules
 */
private boolean isExpired( ICacheEntry entry ) {
    if ( entry.isEternal() ) {
        return false;
    }
    
    Instant now = Instant.now();
    
    // Check main timeout
    if ( entry.timeout() > 0 && 
         entry.created().plusSeconds( entry.timeout() ).isBefore( now ) ) {
        return true;
    }
    
    // Check last access timeout
    if ( BooleanCaster.cast( config.properties.get( Key.useLastAccessTimeouts ) ) &&
         entry.lastAccessTimeout() > 0 &&
         entry.lastAccessed().plusSeconds( entry.lastAccessTimeout() ).isBefore( now ) ) {
        return true;
    }
    
    return false;
}

Step 5: Implement Statistics and Reporting

@Override
public IStruct getStoreMetadataReport( int limit ) {
    IStruct report = new Struct();
    
    try {
        Stream<Map.Entry<String, ICacheEntry>> entryStream = 
            this.caffeineCache.asMap().entrySet().stream();
        
        if ( limit > 0 ) {
            entryStream = entryStream.limit( limit );
        }
        
        entryStream.forEach( entry => {
            try {
                String key = entry.getKey();
                ICacheEntry cacheEntry = entry.getValue();
                
                if ( cacheEntry != null ) {
                    report.put( key, cacheEntry.toStruct() );
                } else {
                    report.put( key, new Struct() );
                }
            } catch ( Exception e ) {
                logger.debug( "Error getting metadata for key [{}]", entry.getKey(), e );
                report.put( entry.getKey(), new Struct() );
            }
        } );
        
    } catch ( Exception e ) {
        logger.error( "Error generating store metadata report", e );
    }
    
    return report;
}

@Override
public IStruct getStoreMetadataReport() {
    return getStoreMetadataReport( 0 ); // No limit
}

@Override
public IStruct getStoreMetadataKeyMap() {
    return Struct.of(
        "cacheName", "cacheName",
        "hits", "hits",
        "timeout", "timeout",
        "lastAccessTimeout", "lastAccessTimeout",
        "created", "created",
        "lastAccessed", "lastAccessed",
        "metadata", "metadata",
        "key", "key",
        "isEternal", "isEternal"
    );
}

@Override
public IStruct getCachedObjectMetadata( String key ) {
    try {
        ICacheEntry entry = this.caffeineCache.getIfPresent( key );
        if ( entry != null ) {
            return entry.toStruct();
        }
    } catch ( Exception e ) {
        logger.error( "Error getting metadata for key [{}]", key, e );
    }
    
    return new Struct();
}

/**
 * Get Caffeine's native statistics if recording is enabled
 */
public IStruct getCaffeineStats() {
    try {
        com.github.benmanes.caffeine.cache.stats.CacheStats caffeineStats = 
            this.caffeineCache.stats();
            
        return Struct.of(
            "requestCount", caffeineStats.requestCount(),
            "hitCount", caffeineStats.hitCount(),
            "hitRate", caffeineStats.hitRate(),
            "missCount", caffeineStats.missCount(),
            "missRate", caffeineStats.missRate(),
            "loadCount", caffeineStats.loadCount(),
            "loadExceptionCount", caffeineStats.loadExceptionCount(),
            "totalLoadTime", caffeineStats.totalLoadTime(),
            "averageLoadPenalty", caffeineStats.averageLoadPenalty(),
            "evictionCount", caffeineStats.evictionCount()
        );
    } catch ( Exception e ) {
        logger.debug( "Caffeine stats not available (recording may not be enabled)", e );
        return new Struct();
    }
}

// Additional utility methods for the Caffeine provider

@Override
public void set( String key, Object value ) {
    set( key, value, this.defaultTimeout, this.defaultLastAccessTimeout );
}

@Override
public void set( String key, Object value, Object timeout ) {
    set( key, value, timeout, this.defaultLastAccessTimeout );
}

@Override
public void set( String key, Object value, Object timeout, Object lastAccessTimeout ) {
    set( key, value, timeout, lastAccessTimeout, new Struct() );
}

@Override
public void set( IStruct entries ) {
    entries.forEach( ( key, value ) => {
        set( key.getName(), value, this.defaultTimeout, this.defaultLastAccessTimeout );
    } );
}

@Override
public void set( IStruct entries, Object timeout, Object lastAccessTimeout ) {
    entries.forEach( ( key, value ) => {
        set( key.getName(), value, timeout, lastAccessTimeout );
    } );
}

@Override
public void setQuiet( Key key, ICacheEntry value ) {
    this.caffeineCache.put( key.getName(), value );
}

@Override
public Object getOrSet( String key, Supplier<Object> provider ) {
    return getOrSet( key, provider, this.defaultTimeout, this.defaultLastAccessTimeout );
}

@Override
public Object getOrSet( String key, Supplier<Object> provider, Object timeout ) {
    return getOrSet( key, provider, timeout, this.defaultLastAccessTimeout );
}

@Override
public Object getOrSet( String key, Supplier<Object> provider, Object timeout, Object lastAccessTimeout ) {
    return getOrSet( key, provider, timeout, lastAccessTimeout, new Struct() );
}

Configuration

Provider Configuration

Define your cache provider configuration in the BoxLang configuration:

{
    "caches": {
        "caffeine-cache": {
            "provider": "Caffeine",
            "properties": {
                "maxObjects": 10000,
                "defaultTimeout": 3600,
                "defaultLastAccessTimeout": 1800,
                "reapFrequency": 300,
                "useLastAccessTimeouts": true,
                "recordStats": true,
                "weakKeys": false,
                "weakValues": false,
                "softValues": false
            }
        }
    }
}

Configuration Properties

The Caffeine cache provider supports the following configuration properties:

  • maxObjects - Maximum number of entries in the cache (default: 10000)

  • defaultTimeout - Default expiration time in seconds (default: 3600)

  • defaultLastAccessTimeout - Default idle time before expiration in seconds (default: 1800)

  • reapFrequency - How often to run cleanup tasks in seconds (default: 300)

  • useLastAccessTimeouts - Whether to enforce idle timeouts (default: true)

  • recordStats - Enable Caffeine's built-in statistics recording (default: true)

  • weakKeys - Use weak references for keys (default: false)

  • weakValues - Use weak references for values (default: false)

  • softValues - Use soft references for values (default: false)

Registration

Register your custom provider with BoxLang:

// In your module or application startup
public class CaffeineModule implements IBoxModule {
    
    @Override
    public void onStartup( BoxRuntime runtime ) {
        // Register the custom cache provider
        CacheService cacheService = runtime.getCacheService();
        cacheService.registerProvider( "Caffeine", CaffeineCacheProvider.class );
        
        logger.info( "Caffeine cache provider registered successfully" );
    }
}

Best Practices

1. Error Handling

Always wrap cache operations in try-catch blocks and provide fallback behavior:

@Override
public Attempt<Object> get( String key ) {
    try {
        // Cache operation
        return getCacheValue( key );
    } catch ( Exception e ) {
        logger.error( "Cache get failed for key [{}]", key, e );
        getStats().recordMiss();
        return Attempt.empty();
    }
}

2. Serialization

Implement robust serialization for complex objects when needed:

private String serializeValue( Object value ) throws JsonProcessingException {
    if ( value instanceof String || value instanceof Number || value instanceof Boolean ) {
        return value.toString();
    }
    return objectMapper.writeValueAsString( value );
}

private Object deserializeValue( String serialized, Class<?> expectedType ) throws JsonProcessingException {
    if ( expectedType == String.class ) {
        return serialized;
    }
    return objectMapper.readValue( serialized, expectedType );
}

3. Resource Management

Implement proper resource cleanup and lifecycle management:

@Override
public void shutdown() {
    try {
        if ( cache != null ) {
            cache.cleanUp();
            cache.invalidateAll();
        }
        
        if ( scheduledReaper != null ) {
            scheduledReaper.cancel( false );
        }
        
        logger.info( "Cache provider [{}] shutdown completed", getName().getName() );
        
    } catch ( Exception e ) {
        logger.error( "Error during cache provider shutdown", e );
    }
}

4. Thread Safety

Ensure your provider is thread-safe:

private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();

private Object getLock( String key ) {
    return locks.computeIfAbsent( key, k => new Object() );
}

@Override
public Object getOrSet( String key, Supplier<Object> provider, Object timeout, Object lastAccessTimeout ) {
    synchronized ( getLock( key ) ) {
        // Double-check pattern implementation
        Attempt<Object> result = get( key );
        if ( result.isPresent() ) {
            return result.get();
        }
        
        Object value = provider.get();
        set( key, value, timeout, lastAccessTimeout );
        return value;
    }
}

5. Performance Optimization

  • Use bulk operations when possible to reduce overhead

  • Implement efficient key filtering and streaming

  • Consider memory usage and implement appropriate eviction policies

  • Use async operations for better throughput when appropriate

@Override
public IStruct get( String... keys ) {
    // Bulk operation implementation
    Map<String, ICacheEntry> bulkResult = cache.getAllPresent( Arrays.asList( keys ) );
    
    IStruct results = new Struct();
    for ( String key : keys ) {
        ICacheEntry entry = bulkResult.get( key );
        if ( entry != null && !isExpired( entry ) ) {
            results.put( key, entry.value() );
            getStats().recordHit();
        } else {
            results.put( key, Attempt.empty() );
            getStats().recordMiss();
        }
    }
    
    return results;
}

6. Statistics and Monitoring

Implement comprehensive statistics tracking:

@Override
public ICacheProvider configure( CacheService cacheService, CacheConfig config ) {
    super.configure( cacheService, config );
    
    // Create custom stats implementation or use BoxCacheStats
    this.stats = new BoxCacheStats();
    
    // Enable monitoring if configured
    boolean enableMetrics = BooleanCaster.cast( config.properties.get( Key.of( "enableMetrics" ) ) );
    if ( enableMetrics ) {
        // Register with metrics system
        registerMetrics();
    }
    
    return this;
}

private void registerMetrics() {
    // Register cache metrics with monitoring system
    MetricsRegistry.register( "cache." + getName().getName() + ".hits", () => getStats().hits() );
    MetricsRegistry.register( "cache." + getName().getName() + ".misses", () => getStats().misses() );
    MetricsRegistry.register( "cache." + getName().getName() + ".size", () => getSize() );
}

7. Configuration Validation

Validate configuration parameters during setup:

@Override
public ICacheProvider configure( CacheService cacheService, CacheConfig config ) {
    super.configure( cacheService, config );
    
    // Validate required properties
    validateConfiguration( config );
    
    // Extract and validate numeric properties
    this.maxSize = IntegerCaster.cast( config.properties.get( Key.maxObjects ) );
    if ( this.maxSize <= 0 ) {
        throw new BoxRuntimeException( "maxObjects must be greater than 0" );
    }
    
    return this;
}

private void validateConfiguration( CacheConfig config ) {
    // Check for required properties
    String[] requiredProps = { "maxObjects", "defaultTimeout" };
    
    for ( String prop : requiredProps ) {
        if ( !config.properties.containsKey( Key.of( prop ) ) ) {
            throw new BoxRuntimeException( "Required property missing: " + prop );
        }
    }
}

Last updated

Was this helpful?