From a93c143efef6fece36bcfd56f3deec00b5db2e43 Mon Sep 17 00:00:00 2001 From: Robin Fievet Date: Sun, 3 Aug 2025 12:47:30 -0400 Subject: [PATCH] Add complete Ruby gem implementation with TypeScript improvements Ruby Implementation: - Complete Ruby gem with garde_fou package name - Multiple calling patterns: call(), [], protect() - GuardedClient mixin for class-level protection - Comprehensive RSpec test suite (30 tests) - Ruby-idiomatic API design - Release automation scripts - Full documentation and examples TypeScript Improvements: - Python-like callable syntax: guard(fn, args) - Both direct call and explicit method patterns - Enhanced test coverage (22 tests) - Updated documentation with both calling patterns - Real-world usage examples Features: - Call counting with configurable limits - Duplicate call detection - Flexible violation handlers (warn/raise/custom) - JSON/YAML configuration support - Cross-language API consistency All three implementations (Python, TypeScript, Ruby) now have feature parity with language-specific optimizations. --- README.md | 35 +++- js/README.md | 136 ++++++++++++++-- js/examples/example.js | 56 +++++-- js/src/gardefou.ts | 72 ++++----- js/src/index.ts | 2 +- js/src/types.ts | 12 +- js/test/engine.test.ts | 184 +++++++++++++++++---- ruby/.rubocop.yml | 26 +++ ruby/CHANGELOG.md | 24 +++ ruby/Gemfile | 7 + ruby/README.md | 284 +++++++++++++++++++++++++++++++++ ruby/Rakefile | 39 +++++ ruby/SETUP.md | 106 ++++++++++++ ruby/examples/example_usage.rb | 169 ++++++++++++++++++++ ruby/garde_fou.gemspec | 37 ++++- ruby/gardefou.config.json | 5 +- ruby/lib/gardefou.rb | 18 ++- ruby/lib/gardefou/engine.rb | 5 - ruby/lib/gardefou/garde_fou.rb | 52 ++++++ ruby/lib/gardefou/profile.rb | 96 +++++++++++ ruby/lib/gardefou/storage.rb | 55 ++++++- ruby/lib/gardefou/version.rb | 3 + ruby/lib/gardefou/wrapper.rb | 31 +++- ruby/scripts/release.rb | 102 ++++++++++++ ruby/spec/engine_spec.rb | 3 - ruby/spec/examples.txt | 32 ++++ ruby/spec/gardefou_spec.rb | 162 +++++++++++++++++++ ruby/spec/profile_spec.rb | 111 +++++++++++++ ruby/spec/spec_helper.rb | 24 +++ ruby/spec/wrapper_spec.rb | 52 ++++++ 30 files changed, 1817 insertions(+), 123 deletions(-) create mode 100644 ruby/.rubocop.yml create mode 100644 ruby/CHANGELOG.md create mode 100644 ruby/Gemfile create mode 100644 ruby/README.md create mode 100644 ruby/Rakefile create mode 100644 ruby/SETUP.md create mode 100755 ruby/examples/example_usage.rb delete mode 100644 ruby/lib/gardefou/engine.rb create mode 100644 ruby/lib/gardefou/garde_fou.rb create mode 100644 ruby/lib/gardefou/profile.rb create mode 100644 ruby/lib/gardefou/version.rb create mode 100755 ruby/scripts/release.rb delete mode 100644 ruby/spec/engine_spec.rb create mode 100644 ruby/spec/examples.txt create mode 100644 ruby/spec/gardefou_spec.rb create mode 100644 ruby/spec/profile_spec.rb create mode 100644 ruby/spec/spec_helper.rb create mode 100644 ruby/spec/wrapper_spec.rb diff --git a/README.md b/README.md index 25e76ad..224de04 100644 --- a/README.md +++ b/README.md @@ -36,24 +36,51 @@ npm install garde-fou ```typescript import { GardeFou } from 'garde-fou'; -const guard = new GardeFou({ max_calls: 5, on_violation_max_calls: 'warn' }); -const result = guard.call(yourApiFunction, "your", "arguments"); +const guard = GardeFou({ max_calls: 5, on_violation_max_calls: 'warn' }); + +// Two equivalent calling patterns: +// 1. Direct call (Python-like syntax) +const result = guard(yourApiFunction, "your", "arguments"); + +// 2. Explicit method call +const result2 = guard.call(yourApiFunction, "your", "arguments"); // For async functions const asyncResult = await guard.callAsync(yourAsyncApiFunction, "args"); ``` +### Ruby +```bash +gem install garde_fou +``` + +```ruby +require 'gardefou' + +guard = Gardefou::GardeFou.new(max_calls: 5, on_violation_max_calls: 'warn') + +# Three equivalent calling patterns: +# 1. Method call +result = guard.call(your_api_method, "your", "arguments") + +# 2. Bracket syntax (Ruby callable style) +result = guard[your_api_method, "your", "arguments"] + +# 3. Protect method (semantic) +result = guard.protect(your_api_method, "your", "arguments") +``` + ## Repository Layout - **[python/](python/)** – ✅ **Ready!** Full Python package published to PyPI - **[js/](js/)** – ✅ **Ready!** TypeScript/JavaScript package with full type support -- **[ruby/](ruby/)** – 🚧 Ruby gem (in development) +- **[ruby/](ruby/)** – ✅ **Ready!** Ruby gem with multiple calling patterns and mixin support ## Status - **Python**: ✅ Complete and published to PyPI - **JavaScript/TypeScript**: ✅ Complete with TypeScript support and comprehensive test suite -- **Ruby**: 🚧 Planned +- **Ruby**: ✅ Complete with Ruby-idiomatic API and comprehensive RSpec test suite ## Contributing diff --git a/js/README.md b/js/README.md index 8d8daa5..c5fc1cd 100644 --- a/js/README.md +++ b/js/README.md @@ -27,14 +27,45 @@ yarn add garde-fou ```typescript import { GardeFou } from 'garde-fou'; -// Protect any function with call limits -const guard = new GardeFou({ max_calls: 5, on_violation_max_calls: 'warn' }); +const guard = GardeFou({ max_calls: 5, on_violation_max_calls: 'warn' }); -// Instead of: result = expensiveApiCall("query") -// Use: result = guard.call(expensiveApiCall, "query") -const result = guard.call(yourApiFunction, "your", "arguments"); +// Two equivalent ways to call your protected function: + +// 1. Direct call (Python-like syntax) - RECOMMENDED +const result = guard(yourApiFunction, "your", "arguments"); + +// 2. Explicit method call +const result2 = guard.call(yourApiFunction, "your", "arguments"); + +// For async functions, use callAsync method +const asyncResult = await guard.callAsync(yourAsyncApiFunction, "args"); ``` +## Calling Patterns + +garde-fou supports two calling patterns that work identically: + +### Pattern 1: Direct Call (Recommended) +```typescript +const guard = GardeFou({ max_calls: 3 }); + +// Call guard directly like a function (same as Python) +const result = guard(apiFunction, param1, param2); +``` + +### Pattern 2: Explicit Method Call +```typescript +const guard = GardeFou({ max_calls: 3 }); + +// Use explicit .call() method +const result = guard.call(apiFunction, param1, param2); +``` + +**Both patterns:** +- Share the same call count and quota +- Work with duplicate detection +- Have identical behavior and performance + ## Usage Examples ### Basic Call Limiting @@ -42,32 +73,49 @@ const result = guard.call(yourApiFunction, "your", "arguments"); import { GardeFou, QuotaExceededError } from 'garde-fou'; // Create a guard with a 3-call limit -const guard = new GardeFou({ max_calls: 3, on_violation_max_calls: 'raise' }); +const guard = GardeFou({ max_calls: 3, on_violation_max_calls: 'raise' }); try { - for (let i = 0; i < 5; i++) { - const result = guard.call(apiCall, `query ${i}`); - } + // Using direct call syntax (recommended) + guard(apiCall, 'query 1'); + guard(apiCall, 'query 2'); + guard(apiCall, 'query 3'); + guard(apiCall, 'query 4'); // This will throw! } catch (error) { if (error instanceof QuotaExceededError) { console.log('Call limit exceeded!'); } } + +// Alternative: using explicit method calls +try { + guard.call(apiCall, 'query 1'); + guard.call(apiCall, 'query 2'); + guard.call(apiCall, 'query 3'); + guard.call(apiCall, 'query 4'); // This will also throw! +} catch (error) { + console.log('Same behavior with .call()'); +} ``` ### Duplicate Call Detection ```typescript // Warn on duplicate calls -const guard = new GardeFou({ on_violation_duplicate_call: 'warn' }); +const guard = GardeFou({ on_violation_duplicate_call: 'warn' }); -guard.call(apiCall, 'hello'); // First call - OK -guard.call(apiCall, 'hello'); // Duplicate - Warning logged +// Direct call syntax +guard(apiCall, 'hello'); // First call - OK +guard(apiCall, 'hello'); // Duplicate - Warning logged +guard(apiCall, 'world'); // Different call - OK + +// Explicit method syntax (works identically) +guard.call(apiCall, 'hello'); // Also detected as duplicate! guard.call(apiCall, 'world'); // Different call - OK ``` ### Async Function Protection ```typescript -const guard = new GardeFou({ max_calls: 3, on_violation_max_calls: 'raise' }); +const guard = GardeFou({ max_calls: 3, on_violation_max_calls: 'raise' }); try { const result1 = await guard.callAsync(asyncApiCall, 'query 1'); @@ -111,10 +159,14 @@ const customHandler = (profile) => { // Send alert, log to service, etc. }; -const guard = new GardeFou({ +const guard = GardeFou({ max_calls: 5, on_violation_max_calls: customHandler }); + +// Both calling styles work with custom handlers +guard(apiCall, 'test'); // Direct call +guard.call(apiCall, 'test'); // Explicit method call ``` ## Configuration Options @@ -168,6 +220,53 @@ interface ProfileConfig { } ``` +## Real-World Examples + +### OpenAI API Protection +```typescript +import OpenAI from 'openai'; +import { GardeFou } from 'garde-fou'; + +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); +const guard = GardeFou({ + max_calls: 100, + on_violation_max_calls: 'warn', + on_violation_duplicate_call: 'warn' +}); + +// Before: Direct API call +// const response = await openai.chat.completions.create({ +// model: 'gpt-4', +// messages: [{ role: 'user', content: 'Hello!' }] +// }); + +// After: Protected API call (choose your preferred syntax) + +// Option 1: Direct call (Python-like) +const response = await guard.callAsync(openai.chat.completions.create, { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello!' }] +}); + +// Option 2: Explicit method call +const response2 = await guard.callAsync(openai.chat.completions.create, { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello!' }] +}); +``` + +### Multiple API Services +```typescript +const guard = GardeFou({ max_calls: 50 }); + +// Protect different APIs with the same guard +const openaiResult = guard(openai.chat.completions.create, { /* config */ }); +const anthropicResult = guard(anthropic.messages.create, { /* config */ }); +const cohereResult = guard.call(cohere.generate, { /* config */ }); + +console.log(`Total API calls made: ${guard.profile.call_count}`); +``` + ## How It Works garde-fou works by wrapping your function calls. Instead of calling your API function directly, you call it through the guard: @@ -176,8 +275,13 @@ garde-fou works by wrapping your function calls. Instead of calling your API fun // Before const result = openai.chat.completions.create({ messages: [...] }); -// After -const guard = new GardeFou({ max_calls: 10 }); +// After - choose your preferred syntax: +const guard = GardeFou({ max_calls: 10 }); + +// Option 1: Direct call (recommended) +const result = guard(openai.chat.completions.create, { messages: [...] }); + +// Option 2: Explicit method call const result = guard.call(openai.chat.completions.create, { messages: [...] }); ``` diff --git a/js/examples/example.js b/js/examples/example.js index 9ca23f7..33e7abf 100644 --- a/js/examples/example.js +++ b/js/examples/example.js @@ -18,13 +18,14 @@ async function expensiveAsyncApiCall(query, model = 'gpt-4') { async function main() { console.log('=== GardeFou TypeScript Example Usage ===\n'); - // Example 1: Basic usage with max_calls limit + // Example 1: Basic usage with max_calls limit (Python-like syntax!) console.log('1. Basic usage with call limit:'); - const guard = new GardeFou({ max_calls: 3, on_violation_max_calls: 'warn' }); + const guard = GardeFou({ max_calls: 3, on_violation_max_calls: 'warn' }); for (let i = 0; i < 5; i++) { try { - const result = guard.call(expensiveApiCall, `Query ${i + 1}`); + // Now you can call guard directly like in Python! + const result = guard(expensiveApiCall, `Query ${i + 1}`); console.log(` Result: ${result}`); } catch (error) { console.log(` Error: ${error.message}`); @@ -35,18 +36,18 @@ async function main() { // Example 2: Duplicate call detection console.log('2. Duplicate call detection:'); - const guardDup = new GardeFou({ on_violation_duplicate_call: 'warn' }); + const guardDup = GardeFou({ on_violation_duplicate_call: 'warn' }); // First call - should work - const result1 = guardDup.call(expensiveApiCall, 'Hello world'); + const result1 = guardDup(expensiveApiCall, 'Hello world'); console.log(` First call result: ${result1}`); // Duplicate call - should warn - const result2 = guardDup.call(expensiveApiCall, 'Hello world'); + const result2 = guardDup(expensiveApiCall, 'Hello world'); console.log(` Duplicate call result: ${result2}`); // Different call - should work - const result3 = guardDup.call(expensiveApiCall, 'Different query'); + const result3 = guardDup(expensiveApiCall, 'Different query'); console.log(` Different call result: ${result3}`); console.log('\n' + '='.repeat(50) + '\n'); @@ -58,19 +59,19 @@ async function main() { on_violation_max_calls: 'raise', on_violation_duplicate_call: 'warn' }); - const guardProfile = new GardeFou({ profile }); + const guardProfile = GardeFou({ profile }); try { // First call - const result = guardProfile.call(expensiveApiCall, 'Profile test 1'); + const result = guardProfile(expensiveApiCall, 'Profile test 1'); console.log(` Call 1: ${result}`); // Second call - const result2 = guardProfile.call(expensiveApiCall, 'Profile test 2'); + const result2 = guardProfile(expensiveApiCall, 'Profile test 2'); console.log(` Call 2: ${result2}`); // Third call - should raise exception - const result3 = guardProfile.call(expensiveApiCall, 'Profile test 3'); + const result3 = guardProfile(expensiveApiCall, 'Profile test 3'); console.log(` Call 3: ${result3}`); } catch (error) { @@ -81,7 +82,7 @@ async function main() { // Example 4: Async function protection console.log('4. Async function protection:'); - const guardAsync = new GardeFou({ max_calls: 2, on_violation_max_calls: 'raise' }); + const guardAsync = GardeFou({ max_calls: 2, on_violation_max_calls: 'raise' }); try { const asyncResult1 = await guardAsync.callAsync(expensiveAsyncApiCall, 'Async test 1'); @@ -108,15 +109,40 @@ async function main() { callbackTriggered = true; }; - const guardCallback = new GardeFou({ + const guardCallback = GardeFou({ max_calls: 1, on_violation_max_calls: customHandler }); - guardCallback.call(expensiveApiCall, 'Callback test 1'); - guardCallback.call(expensiveApiCall, 'Callback test 2'); // Triggers callback + guardCallback(expensiveApiCall, 'Callback test 1'); + guardCallback(expensiveApiCall, 'Callback test 2'); // Triggers callback console.log(` Callback was triggered: ${callbackTriggered}`); + + console.log('\n' + '='.repeat(50) + '\n'); + + // Example 6: Demonstrating both calling patterns work identically + console.log('6. Both calling patterns (direct vs .call()):'); + const guardBoth = GardeFou({ max_calls: 4, on_violation_max_calls: 'warn' }); + + // Mix both calling patterns - they share the same quota + console.log(` Starting call count: ${guardBoth.profile.call_count}`); + + guardBoth(expensiveApiCall, 'Direct call 1'); + console.log(` After direct call 1: ${guardBoth.profile.call_count}`); + + guardBoth.call(expensiveApiCall, 'Method call 1'); + console.log(` After method call 1: ${guardBoth.profile.call_count}`); + + guardBoth(expensiveApiCall, 'Direct call 2'); + console.log(` After direct call 2: ${guardBoth.profile.call_count}`); + + guardBoth.call(expensiveApiCall, 'Method call 2'); + console.log(` After method call 2: ${guardBoth.profile.call_count}`); + + // This should trigger warning (5th call) + guardBoth(expensiveApiCall, 'Direct call 3 - should warn'); + console.log(` After warning call: ${guardBoth.profile.call_count}`); } if (require.main === module) { diff --git a/js/src/gardefou.ts b/js/src/gardefou.ts index 5be72bc..68edef2 100644 --- a/js/src/gardefou.ts +++ b/js/src/gardefou.ts @@ -1,64 +1,54 @@ /** - * Main GardeFou class for protecting API calls + * Main GardeFou implementation for protecting API calls */ import { Profile } from './profile'; import { ViolationHandler } from './types'; -export class GardeFou { - private _profile: Profile; +interface GardefouOptions { + profile?: Profile; + max_calls?: number; + on_violation?: ViolationHandler; + on_violation_max_calls?: ViolationHandler; + on_violation_duplicate_call?: ViolationHandler; +} - constructor(options: { - profile?: Profile; - max_calls?: number; - on_violation?: ViolationHandler; - on_violation_max_calls?: ViolationHandler; - on_violation_duplicate_call?: ViolationHandler; - } = {}) { - if (options.profile) { - this._profile = options.profile; - } else { - // Create profile from remaining options - const { profile, ...profileOptions } = options; - this._profile = new Profile(profileOptions); - } - } +/** + * Creates a callable guard function that can be used like: guard(fn, ...args) + * This mimics the Python behavior where you can call guard directly + */ +export function GardeFou(options: GardefouOptions = {}) { + // Create the profile + const profile = options.profile || new Profile(options); - /** - * Execute a function with garde-fou protection - * @param fn Function to execute - * @param args Arguments to pass to the function - * @returns Result of the function call - */ - call any>(fn: T, ...args: Parameters): ReturnType { + // Create the main callable function + function guard any>(fn: T, ...args: Parameters): ReturnType { // Run profile checks - this._profile.check(fn.name, args, {}); + profile.check(fn.name, args, {}); // Execute the function return fn(...args); } - /** - * Execute an async function with garde-fou protection - * @param fn Async function to execute - * @param args Arguments to pass to the function - * @returns Promise with the result of the function call - */ - async callAsync Promise>( + // Add async support as a method + guard.callAsync = async function Promise>( fn: T, ...args: Parameters ): Promise>> { // Run profile checks - this._profile.check(fn.name, args, {}); + profile.check(fn.name, args, {}); // Execute the async function return await fn(...args); - } + }; - /** - * Callable interface - allows using guard(fn, ...args) syntax - */ - __call__ any>(fn: T, ...args: Parameters): ReturnType { - return this.call(fn, ...args); - } + // Add explicit call method for those who prefer it + guard.call = function any>(fn: T, ...args: Parameters): ReturnType { + return guard(fn, ...args); + }; + + // Expose the profile for inspection + guard.profile = profile; + + return guard; } \ No newline at end of file diff --git a/js/src/index.ts b/js/src/index.ts index c75e1bc..6f6142c 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,3 +1,3 @@ export { GardeFou } from './gardefou'; export { Profile, QuotaExceededError } from './profile'; -export type { ViolationHandler, ProfileConfig } from './types'; +export type { ViolationHandler, ProfileConfig, GuardFunction } from './types'; diff --git a/js/src/types.ts b/js/src/types.ts index 999be30..2f56da4 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -2,6 +2,8 @@ * Type definitions for garde-fou */ +import { Profile } from './profile'; + export type ViolationHandler = 'warn' | 'raise' | ((profile: any) => void); export interface ProfileConfig { @@ -11,4 +13,12 @@ export interface ProfileConfig { on_violation_duplicate_call?: ViolationHandler; } -export type ConfigSource = string | ProfileConfig; \ No newline at end of file +export type ConfigSource = string | ProfileConfig; + +// Type for the callable guard function +export interface GuardFunction { + any>(fn: T, ...args: Parameters): ReturnType; + callAsync Promise>(fn: T, ...args: Parameters): Promise>>; + call any>(fn: T, ...args: Parameters): ReturnType; + profile: Profile; +} \ No newline at end of file diff --git a/js/test/engine.test.ts b/js/test/engine.test.ts index 0deefba..147b116 100644 --- a/js/test/engine.test.ts +++ b/js/test/engine.test.ts @@ -11,28 +11,35 @@ const multiply = async (a: number, b: number): Promise => a * b; describe('GardeFou', () => { describe('Basic functionality', () => { test('should allow unlimited calls by default', () => { - const guard = new GardeFou(); + const guard = GardeFou(); + + expect(guard(add, 1, 2)).toBe(3); + expect(guard(add, 3, 4)).toBe(7); + expect(guard(add, 5, 6)).toBe(11); + }); + + test('should work with explicit call method too', () => { + const guard = GardeFou(); expect(guard.call(add, 1, 2)).toBe(3); expect(guard.call(add, 3, 4)).toBe(7); - expect(guard.call(add, 5, 6)).toBe(11); }); test('should enforce max_calls limit with raise', () => { - const guard = new GardeFou({ max_calls: 2, on_violation_max_calls: 'raise' }); + const guard = GardeFou({ max_calls: 2, on_violation_max_calls: 'raise' }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(guard.call(add, 3, 4)).toBe(7); + expect(guard(add, 1, 2)).toBe(3); + expect(guard(add, 3, 4)).toBe(7); - expect(() => guard.call(add, 5, 6)).toThrow(QuotaExceededError); + expect(() => guard(add, 5, 6)).toThrow(QuotaExceededError); }); test('should enforce max_calls limit with warn', () => { const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const guard = new GardeFou({ max_calls: 1, on_violation_max_calls: 'warn' }); + const guard = GardeFou({ max_calls: 1, on_violation_max_calls: 'warn' }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(guard.call(add, 3, 4)).toBe(7); // Should still return result + expect(guard(add, 1, 2)).toBe(3); + expect(guard(add, 3, 4)).toBe(7); // Should still return result expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('call quota exceeded') @@ -42,20 +49,52 @@ describe('GardeFou', () => { }); }); + describe('Calling syntax compatibility', () => { + test('direct call and .call() method should behave identically', () => { + const guard = GardeFou({ max_calls: 3, on_violation_max_calls: 'raise' }); + + // Both should work and share the same call count + expect(guard(add, 1, 1)).toBe(2); + expect(guard.call(add, 2, 2)).toBe(4); + expect(guard(add, 3, 3)).toBe(6); + + // Both should trigger the same violation + expect(() => guard.call(add, 4, 4)).toThrow(QuotaExceededError); + expect(() => guard(add, 5, 5)).toThrow(QuotaExceededError); + }); + + test('should maintain call count across different calling styles', () => { + const guard = GardeFou({ max_calls: 5 }); + + guard(add, 1, 1); // Call 1 + guard.call(add, 2, 2); // Call 2 + guard(add, 3, 3); // Call 3 + + expect(guard.profile.call_count).toBe(3); + }); + + test('duplicate detection should work across calling styles', () => { + const guard = GardeFou({ on_violation_duplicate_call: 'raise' }); + + guard(add, 1, 2); + expect(() => guard.call(add, 1, 2)).toThrow(QuotaExceededError); + }); + }); + describe('Duplicate detection', () => { test('should detect duplicate calls with raise', () => { - const guard = new GardeFou({ on_violation_duplicate_call: 'raise' }); + const guard = GardeFou({ on_violation_duplicate_call: 'raise' }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(() => guard.call(add, 1, 2)).toThrow(QuotaExceededError); + expect(guard(add, 1, 2)).toBe(3); + expect(() => guard(add, 1, 2)).toThrow(QuotaExceededError); }); test('should detect duplicate calls with warn', () => { const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const guard = new GardeFou({ on_violation_duplicate_call: 'warn' }); + const guard = GardeFou({ on_violation_duplicate_call: 'warn' }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(guard.call(add, 1, 2)).toBe(3); // Should still return result + expect(guard(add, 1, 2)).toBe(3); + expect(guard(add, 1, 2)).toBe(3); // Should still return result expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('duplicate call detected') @@ -65,17 +104,17 @@ describe('GardeFou', () => { }); test('should allow different calls', () => { - const guard = new GardeFou({ on_violation_duplicate_call: 'raise' }); + const guard = GardeFou({ on_violation_duplicate_call: 'raise' }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(guard.call(add, 2, 3)).toBe(5); - expect(guard.call(add, 3, 4)).toBe(7); + expect(guard(add, 1, 2)).toBe(3); + expect(guard(add, 2, 3)).toBe(5); + expect(guard(add, 3, 4)).toBe(7); }); }); describe('Async support', () => { test('should work with async functions', async () => { - const guard = new GardeFou({ max_calls: 2 }); + const guard = GardeFou({ max_calls: 2 }); expect(await guard.callAsync(multiply, 2, 3)).toBe(6); expect(await guard.callAsync(multiply, 3, 4)).toBe(12); @@ -84,34 +123,125 @@ describe('GardeFou', () => { }); test('should detect async duplicate calls', async () => { - const guard = new GardeFou({ on_violation_duplicate_call: 'raise' }); + const guard = GardeFou({ on_violation_duplicate_call: 'raise' }); expect(await guard.callAsync(multiply, 2, 3)).toBe(6); await expect(guard.callAsync(multiply, 2, 3)).rejects.toThrow(QuotaExceededError); }); + + test('async and sync calls should share the same quota', async () => { + const guard = GardeFou({ max_calls: 2, on_violation_max_calls: 'raise' }); + + // Mix sync and async calls + guard(add, 1, 2); // Call 1 (sync) + expect(await guard.callAsync(multiply, 2, 3)).toBe(6); // Call 2 (async) + + // Third call should fail regardless of type + expect(() => guard(add, 3, 4)).toThrow(QuotaExceededError); + await expect(guard.callAsync(multiply, 3, 4)).rejects.toThrow(QuotaExceededError); + }); + + test('should handle mixed sync/async duplicate detection', async () => { + const guard = GardeFou({ on_violation_duplicate_call: 'raise' }); + + // Note: sync and async versions of same function are different signatures + guard(add, 1, 2); + await guard.callAsync(multiply, 2, 3); + + // These should be detected as duplicates + expect(() => guard(add, 1, 2)).toThrow(QuotaExceededError); + await expect(guard.callAsync(multiply, 2, 3)).rejects.toThrow(QuotaExceededError); + }); }); describe('Profile integration', () => { test('should accept Profile object', () => { const profile = new Profile({ max_calls: 1, on_violation_max_calls: 'raise' }); - const guard = new GardeFou({ profile }); + const guard = GardeFou({ profile }); - expect(guard.call(add, 1, 2)).toBe(3); - expect(() => guard.call(add, 3, 4)).toThrow(QuotaExceededError); + expect(guard(add, 1, 2)).toBe(3); + expect(() => guard(add, 3, 4)).toThrow(QuotaExceededError); }); test('should use custom callback handler', () => { const mockCallback = jest.fn(); - const guard = new GardeFou({ + const guard = GardeFou({ max_calls: 1, on_violation_max_calls: mockCallback }); - expect(guard.call(add, 1, 2)).toBe(3); - guard.call(add, 3, 4); // Should trigger callback + expect(guard(add, 1, 2)).toBe(3); + guard(add, 3, 4); // Should trigger callback expect(mockCallback).toHaveBeenCalledTimes(1); }); + + test('should expose profile for inspection', () => { + const guard = GardeFou({ max_calls: 5 }); + + expect(guard.profile.max_calls).toBe(5); + expect(guard.profile.call_count).toBe(0); + + guard(add, 1, 2); + expect(guard.profile.call_count).toBe(1); + }); + }); + + describe('Real-world usage patterns', () => { + test('should work with OpenAI-style API calls', () => { + // Mock OpenAI-style function + const mockOpenAI = { + chat: { + completions: { + create: jest.fn().mockReturnValue({ choices: [{ message: { content: 'Hello!' } }] }) + } + } + }; + + const guard = GardeFou({ max_calls: 2, on_violation_max_calls: 'warn' }); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Both calling styles should work + const result1 = guard(mockOpenAI.chat.completions.create, { messages: [{ role: 'user', content: 'Hi' }] }); + const result2 = guard.call(mockOpenAI.chat.completions.create, { messages: [{ role: 'user', content: 'Hello' }] }); + const result3 = guard(mockOpenAI.chat.completions.create, { messages: [{ role: 'user', content: 'Hey' }] }); + + expect(result1.choices[0].message.content).toBe('Hello!'); + expect(result2.choices[0].message.content).toBe('Hello!'); + expect(result3.choices[0].message.content).toBe('Hello!'); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('call quota exceeded')); + + consoleSpy.mockRestore(); + }); + + test('should handle function with complex arguments', () => { + const complexApiCall = jest.fn((config, options, callback) => { + callback(null, `Processed ${config.query} with ${options.model}`); + return 'success'; + }); + + const guard = GardeFou({ max_calls: 2 }); + const mockCallback = jest.fn(); + + // Test both calling styles with complex arguments + const result1 = guard(complexApiCall, + { query: 'test1', temperature: 0.7 }, + { model: 'gpt-4', max_tokens: 100 }, + mockCallback + ); + + const result2 = guard.call(complexApiCall, + { query: 'test2', temperature: 0.8 }, + { model: 'gpt-3.5', max_tokens: 150 }, + mockCallback + ); + + expect(result1).toBe('success'); + expect(result2).toBe('success'); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith(null, 'Processed test1 with gpt-4'); + expect(mockCallback).toHaveBeenCalledWith(null, 'Processed test2 with gpt-3.5'); + }); }); }); diff --git a/ruby/.rubocop.yml b/ruby/.rubocop.yml new file mode 100644 index 0000000..79f4207 --- /dev/null +++ b/ruby/.rubocop.yml @@ -0,0 +1,26 @@ +AllCops: + TargetRubyVersion: 2.6 + NewCops: enable + +Style/Documentation: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: single_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: single_quotes + +Layout/LineLength: + Max: 120 + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + - 'examples/**/*' + +Metrics/MethodLength: + Max: 15 + +Metrics/ClassLength: + Max: 150 \ No newline at end of file diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md new file mode 100644 index 0000000..d84cd37 --- /dev/null +++ b/ruby/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2025-07-19 + +### Added +- Initial Ruby implementation of garde-fou +- Call counting with configurable limits +- Duplicate call detection +- Multiple calling patterns (call, [], protect) +- Flexible violation handlers (warn, raise, custom procs) +- Configuration loading from JSON/YAML files +- GuardedClient mixin for class-level protection +- Comprehensive test suite with RSpec +- Ruby-idiomatic API design + +[Unreleased]: https://github.com/rfievet/garde-fou/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/rfievet/garde-fou/releases/tag/v0.1.0 \ No newline at end of file diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 0000000..65fac9b --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in garde_fou.gemspec +gemspec + +# Optional: Add YAML support +gem 'psych', '~> 4.0' diff --git a/ruby/README.md b/ruby/README.md new file mode 100644 index 0000000..396d527 --- /dev/null +++ b/ruby/README.md @@ -0,0 +1,284 @@ +# garde-fou (Ruby) + +[![Gem Version](https://badge.fury.io/rb/garde_fou.svg)](https://badge.fury.io/rb/garde_fou) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +**garde-fou** is a lightweight guard for protecting against accidental over-usage of paid API calls. It provides call counting and duplicate detection to help you avoid unexpected API bills. + +## Features + +- **Call counting** - Set maximum number of calls and get warnings or exceptions when exceeded +- **Duplicate detection** - Detect and handle repeated identical API calls +- **Flexible violation handling** - Choose to warn, raise exceptions, or use custom handlers +- **Configuration support** - Load settings from JSON/YAML files or set programmatically +- **Multiple calling patterns** - Ruby-idiomatic syntax with multiple ways to call +- **Mixin support** - Include GuardedClient module for class-level protection + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'garde_fou' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install garde_fou + +## Quick Start + +```ruby +require 'gardefou' + +# Create a guard with call limits +guard = Gardefou::GardeFou.new(max_calls: 5, on_violation_max_calls: 'warn') + +# Multiple calling patterns available: +# 1. Method call (explicit) +result = guard.call(your_api_method, "your", "arguments") + +# 2. Bracket syntax (Ruby callable style) +result = guard[your_api_method, "your", "arguments"] + +# 3. Protect method (semantic) +result = guard.protect(your_api_method, "your", "arguments") +``` + +## Usage Examples + +### Basic Call Limiting +```ruby +require 'gardefou' + +# Create a guard with a 3-call limit +guard = Gardefou::GardeFou.new(max_calls: 3, on_violation_max_calls: 'raise') + +begin + # All calling patterns work identically + guard.call(api_method, 'query 1') + guard[api_method, 'query 2'] + guard.protect(api_method, 'query 3') + guard.call(api_method, 'query 4') # This will raise! +rescue Gardefou::QuotaExceededError => e + puts "Call limit exceeded: #{e.message}" +end +``` + +### Duplicate Call Detection +```ruby +# Warn on duplicate calls +guard = Gardefou::GardeFou.new(on_violation_duplicate_call: 'warn') + +guard.call(api_method, 'hello') # First call - OK +guard.call(api_method, 'hello') # Duplicate - Warning printed +guard.call(api_method, 'world') # Different call - OK +``` + +### Using Profiles +```ruby +# Create a profile with multiple rules +profile = Gardefou::Profile.new( + max_calls: 10, + on_violation_max_calls: 'raise', + on_violation_duplicate_call: 'warn' +) + +guard = Gardefou::GardeFou.new(profile: profile) +``` + +### Configuration Files +```ruby +# Load from JSON/YAML file +profile = Gardefou::Profile.new(config: 'gardefou.config.json') +guard = Gardefou::GardeFou.new(profile: profile) + +# Or pass config as hash +config = { 'max_calls' => 5, 'on_violation_max_calls' => 'warn' } +profile = Gardefou::Profile.new(config: config) +``` + +### Custom Violation Handlers +```ruby +custom_handler = proc do |profile| + puts "Custom violation! Call count: #{profile.call_count}" + # Send alert, log to service, etc. +end + +guard = Gardefou::GardeFou.new( + max_calls: 5, + on_violation_max_calls: custom_handler +) + +# All calling patterns work with custom handlers +guard.call(api_method, 'test') # Method call +guard[api_method, 'test'] # Bracket syntax +guard.protect(api_method, 'test') # Protect method +``` + +### Using the GuardedClient Mixin +```ruby +class APIClient + include Gardefou::GuardedClient + + def expensive_call(query) + # Your expensive API call here + "Result for #{query}" + end + + def another_call(data) + # Another API call + "Processed #{data}" + end + + # Guard specific methods + guard_method :expensive_call, max_calls: 10, on_violation_max_calls: 'warn' + + # Guard all methods matching a pattern + guard_methods /call$/, max_calls: 5, on_violation_duplicate_call: 'warn' +end + +client = APIClient.new +client.expensive_call('test') # Protected automatically +``` + +## Real-World Examples + +### OpenAI API Protection +```ruby +require 'gardefou' + +# Assuming you have an OpenAI client +guard = Gardefou::GardeFou.new( + max_calls: 100, + on_violation_max_calls: 'warn', + on_violation_duplicate_call: 'warn' +) + +# Before: Direct API call +# response = openai_client.completions(prompt: 'Hello!') + +# After: Protected API call (choose your preferred syntax) +response = guard.call(openai_client.method(:completions), prompt: 'Hello!') +# or +response = guard[openai_client.method(:completions), prompt: 'Hello!'] +# or +response = guard.protect(openai_client.method(:completions), prompt: 'Hello!') +``` + +### Multiple API Services +```ruby +guard = Gardefou::GardeFou.new(max_calls: 50) + +# Protect different APIs with the same guard +openai_result = guard.call(openai_client.method(:completions), prompt: 'test') +anthropic_result = guard[anthropic_client.method(:messages), message: 'test'] +cohere_result = guard.protect(cohere_client.method(:generate), text: 'test') + +puts "Total API calls made: #{guard.profile.call_count}" +``` + +## Configuration Options + +- `max_calls`: Maximum number of calls allowed (-1 for unlimited) +- `on_violation_max_calls`: Handler when call limit exceeded (`'warn'`, `'raise'`, or Proc) +- `on_violation_duplicate_call`: Handler for duplicate calls (`'warn'`, `'raise'`, or Proc) +- `on_violation`: Default handler for all violations + +## API Reference + +### GardeFou Class + +#### Constructor +```ruby +Gardefou::GardeFou.new( + profile: nil, + max_calls: nil, + on_violation: nil, + on_violation_max_calls: nil, + on_violation_duplicate_call: nil +) +``` + +#### Methods +- `call(method, *args, **kwargs, &block)` - Execute a method with protection +- `[method, *args, **kwargs, &block]` - Ruby callable syntax (alias for call) +- `protect(method, *args, **kwargs, &block)` - Semantic alias for call + +### Profile Class + +#### Constructor +```ruby +Gardefou::Profile.new( + config: nil, + max_calls: nil, + on_violation: nil, + on_violation_max_calls: nil, + on_violation_duplicate_call: nil +) +``` + +### GuardedClient Module + +#### Class Methods +- `guard_method(method_name, **options)` - Guard a specific method +- `guard_methods(pattern, **options)` - Guard methods matching a pattern + +#### Instance Methods +- `create_guard(**options)` - Create an instance-level guard + +## How It Works + +garde-fou works by wrapping your method calls. Instead of calling your API method directly, you call it through the guard: + +```ruby +# Before +result = openai_client.completions(prompt: 'Hello!') + +# After - choose your preferred syntax: +guard = Gardefou::GardeFou.new(max_calls: 10) + +# Option 1: Method call +result = guard.call(openai_client.method(:completions), prompt: 'Hello!') + +# Option 2: Bracket syntax (Ruby callable style) +result = guard[openai_client.method(:completions), prompt: 'Hello!'] + +# Option 3: Protect method (semantic) +result = guard.protect(openai_client.method(:completions), prompt: 'Hello!') +``` + +The guard tracks calls and enforces your configured rules before executing the actual method. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +```bash +# Install dependencies +bundle install + +# Run tests +rake spec + +# Run example +rake example + +# Run RuboCop +rake rubocop + +# Run all checks +rake check +``` + +## Contributing + +This is part of the multi-language garde-fou toolkit. See the main repository for contributing guidelines. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). \ No newline at end of file diff --git a/ruby/Rakefile b/ruby/Rakefile new file mode 100644 index 0000000..a50cf47 --- /dev/null +++ b/ruby/Rakefile @@ -0,0 +1,39 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec + +desc 'Run the example' +task :example do + ruby 'examples/example_usage.rb' +end + +desc 'Run RuboCop' +task :rubocop do + sh 'rubocop' +end + +desc 'Run all checks' +task check: %i[spec rubocop] + +desc 'Release gem (patch version)' +task :release do + ruby 'scripts/release.rb patch' +end + +desc 'Release gem (minor version)' +task 'release:minor' do + ruby 'scripts/release.rb minor' +end + +desc 'Release gem (major version)' +task 'release:major' do + ruby 'scripts/release.rb major' +end + +desc 'Dry run release' +task 'release:dry' do + ruby 'scripts/release.rb patch --dry-run' +end diff --git a/ruby/SETUP.md b/ruby/SETUP.md new file mode 100644 index 0000000..21a9f89 --- /dev/null +++ b/ruby/SETUP.md @@ -0,0 +1,106 @@ +# Setup Guide for garde-fou Ruby Gem + +## 🔑 Setting up RubyGems Account (One-time setup) + +### Step 1: Create RubyGems Account +1. **RubyGems**: https://rubygems.org/sign_up + +### Step 2: Generate API Key +1. Go to https://rubygems.org/profile/edit +2. Click "API Keys" tab +3. Create a new API key with appropriate permissions +4. Copy the API key + +### Step 3: Configure gem credentials +```bash +gem signin +# Enter your RubyGems credentials when prompted +``` + +Or manually create `~/.gem/credentials`: +```yaml +--- +:rubygems_api_key: your_api_key_here +``` + +## 🚀 Publishing Workflow + +### Development Setup +```bash +cd ruby +bundle install --path vendor/bundle +``` + +### Test Release (Recommended first) +```bash +cd ruby +bundle exec rake release:dry +``` + +### Production Release +```bash +cd ruby +bundle exec rake release # patch version +bundle exec rake release:minor # minor version +bundle exec rake release:major # major version +``` + +### Version Bump Types +- `patch`: 0.1.0 → 0.1.1 (bug fixes) +- `minor`: 0.1.0 → 0.2.0 (new features) +- `major`: 0.1.0 → 1.0.0 (breaking changes) + +## 🔧 Manual Commands + +### Run tests +```bash +cd ruby +bundle exec rake spec +``` + +### Run example +```bash +cd ruby +bundle exec rake example +``` + +### Check code style +```bash +cd ruby +bundle exec rubocop +``` + +### Build gem manually +```bash +cd ruby +gem build garde_fou.gemspec +``` + +### Push gem manually +```bash +cd ruby +gem push garde_fou-*.gem +``` + +## ✅ Verification + +### Test your gem works +```bash +# Install from RubyGems +gem install garde_fou + +# Test it works +ruby -e "require 'gardefou'; puts '✅ Works!'" +``` + +### Check gem page +- **RubyGems**: https://rubygems.org/gems/garde_fou + +## 🎯 Benefits of This Setup + +✅ **Automated workflow** - `bundle exec rake release` +✅ **Automatic version bumping** - No conflicts +✅ **Git integration** - Auto-commit and tag +✅ **Code quality checks** - RuboCop and RSpec +✅ **Professional workflow** - Industry standard +✅ **Multiple calling patterns** - Ruby-idiomatic API \ No newline at end of file diff --git a/ruby/examples/example_usage.rb b/ruby/examples/example_usage.rb new file mode 100755 index 0000000..e8686e3 --- /dev/null +++ b/ruby/examples/example_usage.rb @@ -0,0 +1,169 @@ +#!/usr/bin/env ruby + +require_relative '../lib/gardefou' + +# Mock API function to demonstrate protection +def expensive_api_call(query, model: 'gpt-4') + puts "Making API call with query: '#{query}' using model: #{model}" + "API response for: #{query}" +end + +def main + puts '=== GardeFou Ruby Example Usage ===' + puts + + # Example 1: Basic usage with max_calls limit + puts '1. Basic usage with call limit:' + guard = Gardefou::GardeFou.new(max_calls: 3, on_violation_max_calls: 'warn') + + 5.times do |i| + # Ruby supports multiple calling patterns + result = case i % 3 + when 0 + guard.call(method(:expensive_api_call), "Query #{i + 1}") + when 1 + guard[method(:expensive_api_call), "Query #{i + 1}"] + else + guard.protect(method(:expensive_api_call), "Query #{i + 1}") + end + puts " Result: #{result}" + rescue Gardefou::QuotaExceededError => e + puts " Error: #{e.message}" + end + + puts + puts '=' * 50 + puts + + # Example 2: Duplicate call detection + puts '2. Duplicate call detection:' + guard_dup = Gardefou::GardeFou.new(on_violation_duplicate_call: 'warn') + + # First call - should work + result1 = guard_dup.call(method(:expensive_api_call), 'Hello world') + puts " First call result: #{result1}" + + # Duplicate call - should warn + result2 = guard_dup.call(method(:expensive_api_call), 'Hello world') + puts " Duplicate call result: #{result2}" + + # Different call - should work + result3 = guard_dup.call(method(:expensive_api_call), 'Different query') + puts " Different call result: #{result3}" + + puts + puts '=' * 50 + puts + + # Example 3: Using Profile with config + puts '3. Using Profile configuration:' + profile = Gardefou::Profile.new( + max_calls: 2, + on_violation_max_calls: 'raise', + on_violation_duplicate_call: 'warn' + ) + guard_profile = Gardefou::GardeFou.new(profile: profile) + + begin + # First call + result = guard_profile.call(method(:expensive_api_call), 'Profile test 1') + puts " Call 1: #{result}" + + # Second call + result2 = guard_profile.call(method(:expensive_api_call), 'Profile test 2') + puts " Call 2: #{result2}" + + # Third call - should raise exception + result3 = guard_profile.call(method(:expensive_api_call), 'Profile test 3') + puts " Call 3: #{result3}" + rescue Gardefou::QuotaExceededError => e + puts " Quota exceeded: #{e.message}" + end + + puts + puts '=' * 50 + puts + + # Example 4: Custom callback handler + puts '4. Custom callback handler:' + callback_triggered = false + custom_handler = proc do |profile| + puts " Custom handler triggered! Call count: #{profile.call_count}" + callback_triggered = true + end + + guard_callback = Gardefou::GardeFou.new( + max_calls: 1, + on_violation_max_calls: custom_handler + ) + + guard_callback.call(method(:expensive_api_call), 'Callback test 1') + guard_callback.call(method(:expensive_api_call), 'Callback test 2') # Triggers callback + + puts " Callback was triggered: #{callback_triggered}" + + puts + puts '=' * 50 + puts + + # Example 5: Using the mixin + puts '5. Using GuardedClient mixin:' + + api_client = Class.new do + include Gardefou::GuardedClient + + def fetch_data(query) + puts "Fetching data for: #{query}" + "Data: #{query}" + end + + def process_data(data) + puts "Processing: #{data}" + "Processed: #{data}" + end + + # Guard specific method + guard_method :fetch_data, max_calls: 2, on_violation_max_calls: 'warn' + end.new + + puts ' First fetch:' + result1 = api_client.fetch_data('user123') + puts " Result: #{result1}" + + puts ' Second fetch:' + result2 = api_client.fetch_data('user456') + puts " Result: #{result2}" + + puts ' Third fetch (should warn):' + result3 = api_client.fetch_data('user789') + puts " Result: #{result3}" + + puts + puts '=' * 50 + puts + + # Example 6: Demonstrating all calling patterns work identically + puts '6. All calling patterns (call vs [] vs protect):' + guard_all = Gardefou::GardeFou.new(max_calls: 4, on_violation_max_calls: 'warn') + + puts " Starting call count: #{guard_all.profile.call_count}" + + guard_all.call(method(:expensive_api_call), 'Call method') + puts " After .call(): #{guard_all.profile.call_count}" + + guard_all[method(:expensive_api_call), 'Bracket method'] + puts " After []: #{guard_all.profile.call_count}" + + guard_all.protect(method(:expensive_api_call), 'Protect method') + puts " After .protect(): #{guard_all.profile.call_count}" + + # This should trigger warning (4th call) + guard_all.call(method(:expensive_api_call), 'Final call') + puts " After final call: #{guard_all.profile.call_count}" + + # This should also warn (5th call) + guard_all[method(:expensive_api_call), 'Over limit call'] + puts " After over-limit call: #{guard_all.profile.call_count}" +end + +main if __FILE__ == $0 diff --git a/ruby/garde_fou.gemspec b/ruby/garde_fou.gemspec index f2eecc3..6e73fc5 100644 --- a/ruby/garde_fou.gemspec +++ b/ruby/garde_fou.gemspec @@ -1,5 +1,34 @@ -# Gemspec for garde_fou -Gem::Specification.new do |s| - s.name = 'garde_fou' - s.version = '0.1.0' +require_relative 'lib/gardefou/version' + +Gem::Specification.new do |spec| + spec.name = 'garde_fou' + spec.version = Gardefou::VERSION + spec.authors = ['Robin Fiévet'] + spec.email = ['robinfievet@gmail.com'] + + spec.summary = 'Protective wrappers around paid API clients with quotas & duplicate detection' + spec.description = 'A lightweight guard for protecting against accidental over-usage of paid API calls. Provides call counting and duplicate detection to help you avoid unexpected API bills.' + spec.homepage = 'https://github.com/rfievet/garde-fou' + spec.license = 'MIT' + + spec.required_ruby_version = '>= 2.6.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/rfievet/garde-fou' + spec.metadata['changelog_uri'] = 'https://github.com/rfievet/garde-fou/blob/main/ruby/CHANGELOG.md' + + # Specify which files should be added to the gem when it is released. + spec.files = Dir['lib/**/*', 'README.md', 'CHANGELOG.md', 'LICENSE*'] + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + # Dependencies + spec.add_dependency 'json', '~> 2.0' + + # Development dependencies + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rubocop', '~> 1.0' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/ruby/gardefou.config.json b/ruby/gardefou.config.json index 4333f53..e390f53 100644 --- a/ruby/gardefou.config.json +++ b/ruby/gardefou.config.json @@ -1,4 +1,5 @@ { - "quota": 100, - "rate_limit": "10/min" + "max_calls": 100, + "on_violation_max_calls": "warn", + "on_violation_duplicate_call": "warn" } diff --git a/ruby/lib/gardefou.rb b/ruby/lib/gardefou.rb index 9b72339..c463ee7 100644 --- a/ruby/lib/gardefou.rb +++ b/ruby/lib/gardefou.rb @@ -1,4 +1,18 @@ -# Main file requiring submodules -require_relative 'gardefou/engine' +# Main garde-fou library +require_relative 'gardefou/version' +require_relative 'gardefou/profile' +require_relative 'gardefou/garde_fou' require_relative 'gardefou/storage' require_relative 'gardefou/wrapper' + +module Gardefou + # Convenience method to create a new guard + def self.new(**options) + GardeFou.new(**options) + end + + # Create a guard with a profile + def self.with_profile(profile) + GardeFou.new(profile: profile) + end +end diff --git a/ruby/lib/gardefou/engine.rb b/ruby/lib/gardefou/engine.rb deleted file mode 100644 index 1cf66f3..0000000 --- a/ruby/lib/gardefou/engine.rb +++ /dev/null @@ -1,5 +0,0 @@ -# PolicyEngine module stub -module Gardefou - class PolicyEngine - end -end diff --git a/ruby/lib/gardefou/garde_fou.rb b/ruby/lib/gardefou/garde_fou.rb new file mode 100644 index 0000000..2dde1d9 --- /dev/null +++ b/ruby/lib/gardefou/garde_fou.rb @@ -0,0 +1,52 @@ +require_relative 'profile' + +module Gardefou + class GardeFou + attr_reader :profile + + def initialize(profile: nil, **profile_options) + @profile = profile || Profile.new(**profile_options) + end + + # Main callable interface - allows guard.call(method, *args, **kwargs) + def call(method, *args, **kwargs, &block) + # Extract method name for tracking + method_name = extract_method_name(method) + + # Run profile checks + @profile.check(method_name, args, kwargs) + + # Execute the method + if kwargs.empty? + # Ruby 2.6 compatibility - avoid passing empty kwargs + method.call(*args, &block) + else + method.call(*args, **kwargs, &block) + end + end + + # Ruby-style callable interface - allows guard.(method, *args, **kwargs) + # This is Ruby's equivalent to Python's __call__ + alias [] call + + # Alternative syntax for those who prefer it + def protect(method, *args, **kwargs, &block) + call(method, *args, **kwargs, &block) + end + + private + + def extract_method_name(method) + case method + when Method + "#{method.receiver.class}##{method.name}" + when UnboundMethod + "#{method.owner}##{method.name}" + when Proc + method.source_location ? "Proc@#{method.source_location.join(':')}" : 'Proc' + else + method.class.name + end + end + end +end diff --git a/ruby/lib/gardefou/profile.rb b/ruby/lib/gardefou/profile.rb new file mode 100644 index 0000000..fdf58f1 --- /dev/null +++ b/ruby/lib/gardefou/profile.rb @@ -0,0 +1,96 @@ +require 'json' +require 'yaml' + +module Gardefou + class QuotaExceededError < StandardError; end + + class Profile + attr_reader :max_calls, :on_violation, :on_violation_max_calls, :on_violation_duplicate_call, :call_count + + def initialize(config: nil, max_calls: nil, on_violation: nil, + on_violation_max_calls: nil, on_violation_duplicate_call: nil) + # Load base data from file or hash + data = {} + + if config.is_a?(String) + # Load from file + content = File.read(config) + data = if config.end_with?('.yaml', '.yml') + YAML.safe_load(content) + else + JSON.parse(content) + end + elsif config.is_a?(Hash) + data = config.dup + end + + # Override with explicit options + data['max_calls'] = max_calls unless max_calls.nil? + data['on_violation'] = on_violation unless on_violation.nil? + data['on_violation_max_calls'] = on_violation_max_calls unless on_violation_max_calls.nil? + data['on_violation_duplicate_call'] = on_violation_duplicate_call unless on_violation_duplicate_call.nil? + + # Assign settings with defaults + @max_calls = data['max_calls'] || -1 + @on_violation = data['on_violation'] || 'raise' + @on_violation_max_calls = data['on_violation_max_calls'] || @on_violation + @on_violation_duplicate_call = data['on_violation_duplicate_call'] || @on_violation + + @call_count = 0 + @call_signatures = Set.new + + # Track which rules were explicitly configured + @max_calls_enabled = data.key?('max_calls') && @max_calls >= 0 + @dup_enabled = data.key?('on_violation_duplicate_call') + end + + def check(fn_name = nil, args = [], kwargs = {}) + check_max_call if @max_calls_enabled + check_duplicate(fn_name, args, kwargs) if @dup_enabled + end + + private + + def check_max_call + @call_count += 1 + return unless @call_count > @max_calls + + msg = "GardeFou: call quota exceeded (#{@call_count}/#{@max_calls})" + handle_violation(@on_violation_max_calls, msg) + end + + def check_duplicate(fn_name = nil, args = [], kwargs = {}) + signature = create_signature(fn_name, args, kwargs) + + if @call_signatures.include?(signature) + msg = "GardeFou: duplicate call detected for #{fn_name} with args #{args.inspect} and kwargs #{kwargs.inspect}" + handle_violation(@on_violation_duplicate_call, msg) + else + @call_signatures.add(signature) + end + end + + def create_signature(fn_name, args, kwargs) + # Create a deterministic signature for duplicate detection + sorted_kwargs = kwargs.sort.to_h + { + fn_name: fn_name, + args: args, + kwargs: sorted_kwargs + }.to_json + end + + def handle_violation(handler, message) + case handler + when 'warn' + warn(message) + when 'raise' + raise QuotaExceededError, message + when Proc + handler.call(self) + else + raise ArgumentError, "Invalid violation handler: #{handler}" + end + end + end +end diff --git a/ruby/lib/gardefou/storage.rb b/ruby/lib/gardefou/storage.rb index 5d3cb12..ffed69d 100644 --- a/ruby/lib/gardefou/storage.rb +++ b/ruby/lib/gardefou/storage.rb @@ -1,5 +1,58 @@ -# StorageAdapter stub +require 'json' +require 'set' + module Gardefou + # Utility class for creating call signatures for duplicate detection + class CallSignature + attr_reader :fn_name, :args, :kwargs + + def initialize(fn_name, args, kwargs) + @fn_name = fn_name + @args = args + @kwargs = kwargs + end + + def to_s + # Create deterministic string representation + sorted_kwargs = @kwargs.sort.to_h + { + fn_name: @fn_name, + args: @args, + kwargs: sorted_kwargs + }.to_json + end + + def ==(other) + other.is_a?(CallSignature) && to_s == other.to_s + end + + def hash + to_s.hash + end + + alias eql? == + end + + # Storage adapter for tracking calls (future extension point) class StorageAdapter + def initialize + @signatures = Set.new + end + + def store_signature(signature) + @signatures.add(signature) + end + + def signature_exists?(signature) + @signatures.include?(signature) + end + + def clear + @signatures.clear + end + + def count + @signatures.size + end end end diff --git a/ruby/lib/gardefou/version.rb b/ruby/lib/gardefou/version.rb new file mode 100644 index 0000000..b994430 --- /dev/null +++ b/ruby/lib/gardefou/version.rb @@ -0,0 +1,3 @@ +module Gardefou + VERSION = '0.1.0' +end diff --git a/ruby/lib/gardefou/wrapper.rb b/ruby/lib/gardefou/wrapper.rb index 88cad82..539dfcc 100644 --- a/ruby/lib/gardefou/wrapper.rb +++ b/ruby/lib/gardefou/wrapper.rb @@ -1,5 +1,34 @@ -# GuardedClient mixin stub +require_relative 'garde_fou' + module Gardefou + # Mixin module to add garde-fou protection to any class module GuardedClient + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Class-level method to set up protection for specific methods + def guard_method(method_name, **options) + original_method = instance_method(method_name) + guard = GardeFou.new(**options) + + define_method(method_name) do |*args, **kwargs, &block| + guard.call(original_method.bind(self), *args, **kwargs, &block) + end + end + + # Protect all methods matching a pattern + def guard_methods(pattern, **options) + instance_methods.grep(pattern).each do |method_name| + guard_method(method_name, **options) + end + end + end + + # Instance-level guard creation + def create_guard(**options) + GardeFou.new(**options) + end end end diff --git a/ruby/scripts/release.rb b/ruby/scripts/release.rb new file mode 100755 index 0000000..38ef880 --- /dev/null +++ b/ruby/scripts/release.rb @@ -0,0 +1,102 @@ +#!/usr/bin/env ruby + +require 'fileutils' +require 'json' + +class RubyReleaser + def initialize(bump_type = 'patch', dry_run = false) + @bump_type = bump_type + @dry_run = dry_run + end + + def release + puts "🚀 Starting Ruby gem release process (#{@bump_type} bump)" + puts '🧪 DRY RUN MODE - No changes will be made' if @dry_run + + # Step 1: Run tests and checks + puts "\n🧹 Step 1: Running tests and checks..." + run_command('bundle exec rake spec') unless @dry_run + run_command('bundle exec rubocop') unless @dry_run + + # Step 2: Bump version + puts "\n📈 Step 2: Bumping version..." + new_version = bump_version unless @dry_run + puts "Version will be: #{new_version || current_version}" + + # Step 3: Build gem + puts "\n🔨 Step 3: Building gem..." + run_command('gem build garde_fou.gemspec') unless @dry_run + + # Step 4: Git operations + puts "\n📝 Step 4: Git operations..." + unless @dry_run + run_command('git add .') + run_command("git commit -m 'Release version #{new_version}'") + run_command("git tag v#{new_version}") + run_command('git push origin main --tags') + end + + # Step 5: Publish gem + puts "\n🚀 Step 5: Publishing to RubyGems..." + unless @dry_run + gem_file = Dir['garde_fou-*.gem'].first + run_command("gem push #{gem_file}") + end + + puts "\n🎉 Release #{new_version || current_version} complete!" + puts '🌐 Check it out: https://rubygems.org/gems/garde_fou' + end + + private + + def run_command(cmd) + puts "🔧 Running: #{cmd}" + system(cmd) || (puts "❌ Command failed: #{cmd}" && exit(1)) + end + + def current_version + File.read('lib/gardefou/version.rb').match(/VERSION = '([^']+)'/)[1] + end + + def bump_version + current = current_version + parts = current.split('.').map(&:to_i) + + case @bump_type + when 'major' + parts[0] += 1 + parts[1] = 0 + parts[2] = 0 + when 'minor' + parts[1] += 1 + parts[2] = 0 + when 'patch' + parts[2] += 1 + else + raise "Invalid bump type: #{@bump_type}" + end + + new_version = parts.join('.') + + # Update version file + version_file = 'lib/gardefou/version.rb' + content = File.read(version_file) + content.gsub!(/VERSION = '[^']+'/, "VERSION = '#{new_version}'") + File.write(version_file, content) + + puts "✅ Updated version from #{current} to #{new_version}" + new_version + end +end + +if __FILE__ == $0 + bump_type = ARGV[0] || 'patch' + dry_run = ARGV.include?('--dry-run') + + unless %w[major minor patch].include?(bump_type) + puts 'Usage: ruby scripts/release.rb [major|minor|patch] [--dry-run]' + exit 1 + end + + RubyReleaser.new(bump_type, dry_run).release +end diff --git a/ruby/spec/engine_spec.rb b/ruby/spec/engine_spec.rb deleted file mode 100644 index 2112c3b..0000000 --- a/ruby/spec/engine_spec.rb +++ /dev/null @@ -1,3 +0,0 @@ -# RSpec tests placeholder -RSpec.describe Gardefou::PolicyEngine do -end diff --git a/ruby/spec/examples.txt b/ruby/spec/examples.txt new file mode 100644 index 0000000..f8e18de --- /dev/null +++ b/ruby/spec/examples.txt @@ -0,0 +1,32 @@ +example_id | status | run_time | +------------------------------ | ------ | --------------- | +./spec/gardefou_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:1:2] | passed | 0.00004 seconds | +./spec/gardefou_spec.rb[1:1:3] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:2:1] | passed | 0.00006 seconds | +./spec/gardefou_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/gardefou_spec.rb[1:2:3] | passed | 0.00007 seconds | +./spec/gardefou_spec.rb[1:3:1] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:3:2] | passed | 0.00002 seconds | +./spec/gardefou_spec.rb[1:3:3] | passed | 0.00002 seconds | +./spec/gardefou_spec.rb[1:3:4] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:4:1] | passed | 0.00005 seconds | +./spec/gardefou_spec.rb[1:4:2] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:4:3] | passed | 0.00004 seconds | +./spec/gardefou_spec.rb[1:5:1] | passed | 0.0002 seconds | +./spec/gardefou_spec.rb[1:5:2] | passed | 0.00003 seconds | +./spec/gardefou_spec.rb[1:6:1] | passed | 0.00614 seconds | +./spec/profile_spec.rb[1:1:1] | passed | 0.00004 seconds | +./spec/profile_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/profile_spec.rb[1:1:3] | passed | 0.00049 seconds | +./spec/profile_spec.rb[1:1:4] | passed | 0.00003 seconds | +./spec/profile_spec.rb[1:2:1] | passed | 0.00003 seconds | +./spec/profile_spec.rb[1:2:2] | passed | 0.00004 seconds | +./spec/profile_spec.rb[1:3:1] | passed | 0.00004 seconds | +./spec/profile_spec.rb[1:3:2] | passed | 0.00006 seconds | +./spec/profile_spec.rb[1:4:1] | passed | 0.00069 seconds | +./spec/profile_spec.rb[1:4:2] | passed | 0.00069 seconds | +./spec/profile_spec.rb[1:4:3] | passed | 0.00054 seconds | +./spec/wrapper_spec.rb[1:1:1] | passed | 0.00033 seconds | +./spec/wrapper_spec.rb[1:1:2] | passed | 0.00034 seconds | +./spec/wrapper_spec.rb[1:2:1] | passed | 0.00032 seconds | diff --git a/ruby/spec/gardefou_spec.rb b/ruby/spec/gardefou_spec.rb new file mode 100644 index 0000000..1957392 --- /dev/null +++ b/ruby/spec/gardefou_spec.rb @@ -0,0 +1,162 @@ +require 'spec_helper' + +RSpec.describe Gardefou::GardeFou do + # Test functions + let(:add_proc) { proc { |a, b| a + b } } + let(:multiply_proc) { proc { |a, b| a * b } } + let(:api_call) { proc { |query| "API response for: #{query}" } } + + describe 'basic functionality' do + it 'allows unlimited calls by default' do + guard = Gardefou::GardeFou.new + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect(guard.call(add_proc, 3, 4)).to eq(7) + expect(guard.call(add_proc, 5, 6)).to eq(11) + end + + it 'enforces max_calls limit with raise' do + guard = Gardefou::GardeFou.new(max_calls: 2, on_violation_max_calls: 'raise') + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect(guard.call(add_proc, 3, 4)).to eq(7) + + expect { guard.call(add_proc, 5, 6) }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'enforces max_calls limit with warn' do + guard = Gardefou::GardeFou.new(max_calls: 1, on_violation_max_calls: 'warn') + + expect(guard.call(add_proc, 1, 2)).to eq(3) + + expect { guard.call(add_proc, 3, 4) }.to output(/call quota exceeded/).to_stderr + end + end + + describe 'duplicate detection' do + it 'detects duplicate calls with raise' do + guard = Gardefou::GardeFou.new(on_violation_duplicate_call: 'raise') + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect { guard.call(add_proc, 1, 2) }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'detects duplicate calls with warn' do + guard = Gardefou::GardeFou.new(on_violation_duplicate_call: 'warn') + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect { guard.call(add_proc, 1, 2) }.to output(/duplicate call detected/).to_stderr + end + + it 'allows different calls' do + guard = Gardefou::GardeFou.new(on_violation_duplicate_call: 'raise') + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect(guard.call(add_proc, 2, 3)).to eq(5) + expect(guard.call(add_proc, 3, 4)).to eq(7) + end + end + + describe 'calling patterns' do + it 'supports call method' do + guard = Gardefou::GardeFou.new + expect(guard.call(add_proc, 1, 2)).to eq(3) + end + + it 'supports [] syntax (Ruby callable)' do + guard = Gardefou::GardeFou.new + expect(guard[add_proc, 1, 2]).to eq(3) + end + + it 'supports protect method' do + guard = Gardefou::GardeFou.new + expect(guard.protect(add_proc, 1, 2)).to eq(3) + end + + it 'all calling patterns share the same quota' do + guard = Gardefou::GardeFou.new(max_calls: 3, on_violation_max_calls: 'raise') + + guard.call(add_proc, 1, 1) # Call 1 + guard[add_proc, 2, 2] # Call 2 + guard.protect(add_proc, 3, 3) # Call 3 + + expect { guard.call(add_proc, 4, 4) }.to raise_error(Gardefou::QuotaExceededError) + end + end + + describe 'profile integration' do + it 'accepts Profile object' do + profile = Gardefou::Profile.new(max_calls: 1, on_violation_max_calls: 'raise') + guard = Gardefou::GardeFou.new(profile: profile) + + expect(guard.call(add_proc, 1, 2)).to eq(3) + expect { guard.call(add_proc, 3, 4) }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'uses custom callback handler' do + callback_called = false + custom_handler = proc { |_profile| callback_called = true } + + guard = Gardefou::GardeFou.new( + max_calls: 1, + on_violation_max_calls: custom_handler + ) + + guard.call(add_proc, 1, 2) + guard.call(add_proc, 3, 4) # Should trigger callback + + expect(callback_called).to be true + end + + it 'exposes profile for inspection' do + guard = Gardefou::GardeFou.new(max_calls: 5) + + expect(guard.profile.max_calls).to eq(5) + expect(guard.profile.call_count).to eq(0) + + guard.call(add_proc, 1, 2) + expect(guard.profile.call_count).to eq(1) + end + end + + describe 'method handling' do + let(:test_object) do + Class.new do + def test_method(x) + "result: #{x}" + end + end.new + end + + it 'works with Method objects' do + guard = Gardefou::GardeFou.new + method_obj = test_object.method(:test_method) + + expect(guard.call(method_obj, 'hello')).to eq('result: hello') + end + + it 'works with blocks' do + guard = Gardefou::GardeFou.new + + result = guard.call(proc { |x| x * 2 }, 5) + expect(result).to eq(10) + end + end + + describe 'real-world usage patterns' do + it 'works with API-style calls' do + mock_api = double('API') + allow(mock_api).to receive(:call) { |query| "Response: #{query}" } + + guard = Gardefou::GardeFou.new(max_calls: 2, on_violation_max_calls: 'warn') + + result1 = guard.call(mock_api.method(:call), 'query1') + result2 = guard.call(mock_api.method(:call), 'query2') + + expect(result1).to eq('Response: query1') + expect(result2).to eq('Response: query2') + + expect { guard.call(mock_api.method(:call), 'query3') }.to output(/call quota exceeded/).to_stderr + end + end +end diff --git a/ruby/spec/profile_spec.rb b/ruby/spec/profile_spec.rb new file mode 100644 index 0000000..777a864 --- /dev/null +++ b/ruby/spec/profile_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +RSpec.describe Gardefou::Profile do + describe 'initialization' do + it 'sets default values' do + profile = Gardefou::Profile.new + + expect(profile.max_calls).to eq(-1) + expect(profile.on_violation).to eq('raise') + expect(profile.call_count).to eq(0) + end + + it 'accepts configuration options' do + profile = Gardefou::Profile.new( + max_calls: 5, + on_violation: 'warn', + on_violation_max_calls: 'raise' + ) + + expect(profile.max_calls).to eq(5) + expect(profile.on_violation).to eq('warn') + expect(profile.on_violation_max_calls).to eq('raise') + end + + it 'loads from hash config' do + config = { + 'max_calls' => 10, + 'on_violation' => 'warn' + } + + profile = Gardefou::Profile.new(config: config) + + expect(profile.max_calls).to eq(10) + expect(profile.on_violation).to eq('warn') + end + + it 'overrides config with explicit options' do + config = { 'max_calls' => 5 } + profile = Gardefou::Profile.new( + config: config, + max_calls: 10, + on_violation: 'warn' + ) + + expect(profile.max_calls).to eq(10) + expect(profile.on_violation).to eq('warn') + end + end + + describe 'call tracking' do + it 'increments call count' do + profile = Gardefou::Profile.new(max_calls: 5) + + expect(profile.call_count).to eq(0) + profile.check('test_method', [1, 2], {}) + expect(profile.call_count).to eq(1) + end + + it 'enforces max calls limit' do + profile = Gardefou::Profile.new(max_calls: 1, on_violation_max_calls: 'raise') + + profile.check('test_method', [1], {}) + expect { profile.check('test_method', [2], {}) }.to raise_error(Gardefou::QuotaExceededError) + end + end + + describe 'duplicate detection' do + it 'detects identical calls' do + profile = Gardefou::Profile.new(on_violation_duplicate_call: 'raise') + + profile.check('test_method', [1, 2], { key: 'value' }) + expect { profile.check('test_method', [1, 2], { key: 'value' }) }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'allows different calls' do + profile = Gardefou::Profile.new(on_violation_duplicate_call: 'raise') + + profile.check('test_method', [1, 2], { key: 'value' }) + expect { profile.check('test_method', [1, 3], { key: 'value' }) }.not_to raise_error + expect { profile.check('test_method', [1, 2], { key: 'other' }) }.not_to raise_error + end + end + + describe 'violation handlers' do + it 'handles warn violations' do + profile = Gardefou::Profile.new(max_calls: 1, on_violation_max_calls: 'warn') + + profile.check('test', [], {}) + expect { profile.check('test', [], {}) }.to output(/call quota exceeded/).to_stderr + end + + it 'handles raise violations' do + profile = Gardefou::Profile.new(max_calls: 1, on_violation_max_calls: 'raise') + + profile.check('test', [], {}) + expect { profile.check('test', [], {}) }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'handles custom callback violations' do + callback_called = false + custom_handler = proc { |_profile| callback_called = true } + + profile = Gardefou::Profile.new(max_calls: 1, on_violation_max_calls: custom_handler) + + profile.check('test', [], {}) + profile.check('test', [], {}) + + expect(callback_called).to be true + end + end +end diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 0000000..a809b3e --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,24 @@ +require 'rspec' +require_relative '../lib/gardefou' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = 'spec/examples.txt' + config.disable_monkey_patching! + config.warnings = true + + config.default_formatter = 'doc' if config.files_to_run.one? + + config.profile_examples = 10 + config.order = :random + Kernel.srand config.seed +end diff --git a/ruby/spec/wrapper_spec.rb b/ruby/spec/wrapper_spec.rb new file mode 100644 index 0000000..b48e8f3 --- /dev/null +++ b/ruby/spec/wrapper_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +RSpec.describe Gardefou::GuardedClient do + let(:test_class) do + Class.new do + include Gardefou::GuardedClient + + def expensive_api_call(query) + "API response for: #{query}" + end + + def another_api_call(data) + "Processed: #{data}" + end + end + end + + describe 'class methods' do + it 'guards specific methods' do + test_class.guard_method(:expensive_api_call, max_calls: 2, on_violation_max_calls: 'raise') + + instance = test_class.new + + expect(instance.expensive_api_call('test1')).to eq('API response for: test1') + expect(instance.expensive_api_call('test2')).to eq('API response for: test2') + expect { instance.expensive_api_call('test3') }.to raise_error(Gardefou::QuotaExceededError) + end + + it 'guards methods matching pattern' do + test_class.guard_methods(/api_call$/, max_calls: 1, on_violation_max_calls: 'warn') + + instance = test_class.new + + instance.expensive_api_call('test1') + expect { instance.expensive_api_call('test2') }.to output(/call quota exceeded/).to_stderr + + instance.another_api_call('data1') + expect { instance.another_api_call('data2') }.to output(/call quota exceeded/).to_stderr + end + end + + describe 'instance methods' do + it 'creates instance-level guards' do + instance = test_class.new + guard = instance.create_guard(max_calls: 2) + + expect(guard.call(instance.method(:expensive_api_call), 'test1')).to eq('API response for: test1') + expect(guard.call(instance.method(:expensive_api_call), 'test2')).to eq('API response for: test2') + expect { guard.call(instance.method(:expensive_api_call), 'test3') }.to raise_error(Gardefou::QuotaExceededError) + end + end +end