exaonruby 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 120800cd9da222f8a7cd47f1e608d4208a71e4a5b4d0a035b8ebfd5ffd17c115
4
- data.tar.gz: 559bae5acadd43bc85854c2e62fde0852a40d683b2328fd0eea27f99aeef9470
3
+ metadata.gz: f7fa267a3b26d29aff9a6e5886cd43568ad0820aad17e54c8760141bbe208aa8
4
+ data.tar.gz: 2a1823d83853927fee270c1070b4ead7801d1d2df2d6e5c8769d644cefb31459
5
5
  SHA512:
6
- metadata.gz: 788f13f5bc62a00227dd41b3803bb9d2bb88e5dc5f160ac6d684b6b3df487ebd66d050dc9f2da56355b7f4752ba10d1551ace7c8d49a1c1be6863b333c10b070
7
- data.tar.gz: 90cdc099f08b5815329ed77551d40004ace0e869138888679a268c73c92dedb96539864f1f5b8480d67fe6b26a9baf489c47e212d60cfc09d9b5939d221023fd
6
+ metadata.gz: d128dce03cfee863a805039fcb4e9d4b12b93bff213ae0e03be21702040422a284864910df6916183c6aca5b59a91811d4cc3589c348733c31b441eee46db518
7
+ data.tar.gz: 87fb73c8641ef0a78af2f426d0a9499a934d4149ac6c482acab4a688575b87449a8d4c1feb23d46cf9f0b9c7853c787cf052acda30367c9c6fa8f38946559c47
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2025-12-18
9
+
10
+ ### Added
11
+
12
+ - **Request Logging Middleware** - Pretty HTTP request/response logging
13
+ - Configurable log levels and body logging
14
+ - Sensitive data filtering (API keys, passwords)
15
+ - Request timing with status indicators (✓ ⚠ ✗)
16
+
17
+ - **Rate Limiter Middleware** - Client-side rate limiting
18
+ - Token bucket algorithm for smooth rate control
19
+ - Configurable requests per second and burst size
20
+ - Thread-safe implementation
21
+
22
+ - **Response Cache Middleware** - Reduce API costs
23
+ - `Exa::Cache::MemoryStore` for single-process apps
24
+ - `Exa::Cache::RedisStore` for distributed caching
25
+ - Configurable TTL and cacheable endpoints
26
+
27
+ - **OpenTelemetry Instrumentation** - Distributed tracing
28
+ - Automatic span creation for all requests
29
+ - HTTP attributes and status codes
30
+ - Error recording
31
+
32
+ - **Parallel Requests Utility** - Batch operations
33
+ - `Exa::Utils::Parallel.map` for concurrent requests
34
+ - Configurable concurrency limits
35
+ - Multiple error handling strategies
36
+
37
+ - **Rails Integration** - First-class Rails support
38
+ - Railtie for automatic configuration
39
+ - `rails g exa:install` generator
40
+ - ActiveJob adapter for async research
41
+ - ActionCable integration for streaming
42
+ - Rake tasks (`rails exa:verify`, `rails exa:config`)
43
+
44
+ ### Changed
45
+
46
+ - Added `concurrent-ruby` as a core dependency for parallel requests
47
+
8
48
  ## [1.1.0] - 2025-12-18
9
49
 
10
50
  ### Added
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Exa Ruby
2
2
 
3
- A production-ready Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, providing intelligent web search, content extraction, and structured data collection capabilities.
3
+ A Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, providing intelligent web search, content extraction, and structured data collection capabilities.
4
+
5
+ Want to say thanks? Click the ⭐ at the top of the page.
4
6
 
5
7
  ## Features
6
8
 
@@ -14,11 +16,16 @@ A production-ready Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, provid
14
16
  - **Imports**: Upload CSV data into Websets
15
17
  - **Webhooks & Events**: Real-time notifications for Websets activity
16
18
  - **SSE Streaming**: Real-time token streaming for Answer and Research APIs
17
- - **Sorbet Types**: Optional T::Struct type definitions for static type checking
19
+ - **Request Logging**: Pretty HTTP logging with timing and status indicators
20
+ - **Rate Limiting**: Client-side token bucket rate limiting
21
+ - **Response Caching**: Memory and Redis caching to reduce API costs
22
+ - **Instrumentation**: OpenTelemetry distributed tracing
23
+ - **Parallel Requests**: Concurrent batch operations
24
+ - **Rails Integration**: Railtie, generator, ActiveJob, ActionCable
25
+ - **Sorbet Types**: Optional T::Struct type definitions
18
26
  - **Beautiful CLI**: Colorful command-line interface
19
27
  - **n8n/Zapier Integration**: Webhook signature verification utilities
20
28
  - **Automatic Retries**: Built-in retry logic for transient failures
21
- - **Rate Limit Handling**: Proper error handling with retry information
22
29
  - **Type Documentation**: Comprehensive YARD documentation
23
30
 
24
31
  ## Installation
@@ -670,7 +677,10 @@ params = Exa::Types::SearchParams.new(
670
677
  - faraday >= 2.0
671
678
  - faraday-retry >= 2.0
672
679
  - thor >= 1.0
680
+ - concurrent-ruby >= 1.2
673
681
  - sorbet-runtime >= 0.5 (optional, for type definitions)
682
+ - opentelemetry-sdk (optional, for instrumentation)
683
+ - redis (optional, for distributed caching)
674
684
 
675
685
  ## Development
676
686
 
@@ -683,4 +693,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
683
693
  ## Contributing
684
694
 
685
695
  Bug reports and pull requests are welcome on GitHub at https://github.com/exa-labs/exa-ruby.
686
-
data/exaonruby.gemspec CHANGED
@@ -8,11 +8,12 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["tigel-agm"]
9
9
  spec.email = []
10
10
 
11
- spec.summary = "Complete Ruby client for the Exa.ai API with CLI and SSE streaming"
11
+ spec.summary = "Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration"
12
12
  spec.description = "A production-ready Ruby gem wrapper for the Exa.ai Search and Websets APIs. " \
13
13
  "Features neural search, LLM-powered answers, async research tasks, " \
14
14
  "Websets management (monitors, imports, webhooks), SSE streaming, " \
15
- "Sorbet type definitions, and a beautiful CLI. " \
15
+ "request logging, rate limiting, response caching, OpenTelemetry instrumentation, " \
16
+ "parallel requests, Rails integration, Sorbet types, and a beautiful CLI. " \
16
17
  "Includes n8n/Zapier webhook signature verification utilities."
17
18
  spec.homepage = "https://github.com/tigel-agm/exaonruby"
18
19
  spec.license = "MIT"
@@ -36,7 +37,9 @@ Gem::Specification.new do |spec|
36
37
  spec.add_dependency "faraday", ">= 2.0", "< 3.0"
37
38
  spec.add_dependency "faraday-retry", ">= 2.0", "< 3.0"
38
39
  spec.add_dependency "thor", ">= 1.0", "< 3.0"
40
+ spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
39
41
 
40
42
  # Optional: Sorbet types (install sorbet-runtime for type checking)
41
- # gem install sorbet-runtime
43
+ # Optional: OpenTelemetry (install opentelemetry-sdk for instrumentation)
44
+ # Optional: Redis (install redis gem for distributed caching)
42
45
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Middleware
7
+ # OpenTelemetry instrumentation middleware for distributed tracing
8
+ #
9
+ # Integrates with OpenTelemetry for comprehensive request tracing.
10
+ # Requires opentelemetry-sdk gem to be installed.
11
+ #
12
+ # @example Enable instrumentation
13
+ # require 'opentelemetry/sdk'
14
+ # OpenTelemetry::SDK.configure
15
+ #
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.instrumentation = true
18
+ # end
19
+ class Instrumentation < Faraday::Middleware
20
+ # @param app [Faraday::Middleware] Next middleware
21
+ # @param tracer_name [String] Name for the tracer
22
+ def initialize(app, tracer_name: "exa.api")
23
+ super(app)
24
+ @tracer_name = tracer_name
25
+ @tracer = nil
26
+ end
27
+
28
+ def call(env)
29
+ return @app.call(env) unless otel_available?
30
+
31
+ tracer.in_span(span_name(env), attributes: span_attributes(env), kind: :client) do |span|
32
+ begin
33
+ response = @app.call(env)
34
+
35
+ response.on_complete do |response_env|
36
+ span.set_attribute("http.status_code", response_env[:status])
37
+ span.status = otel_status(response_env[:status])
38
+ end
39
+
40
+ response
41
+ rescue StandardError => e
42
+ span.record_exception(e)
43
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
44
+ raise
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def otel_available?
52
+ defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ def tracer
58
+ @tracer ||= OpenTelemetry.tracer_provider.tracer(@tracer_name)
59
+ end
60
+
61
+ def span_name(env)
62
+ method = env[:method].to_s.upcase
63
+ path = env[:url].path.split("/").reject(&:empty?).first || "request"
64
+ "Exa #{method} /#{path}"
65
+ end
66
+
67
+ def span_attributes(env)
68
+ {
69
+ "http.method" => env[:method].to_s.upcase,
70
+ "http.url" => sanitize_url(env[:url].to_s),
71
+ "http.target" => env[:url].path,
72
+ "http.host" => env[:url].host,
73
+ "http.scheme" => env[:url].scheme,
74
+ "net.peer.name" => env[:url].host,
75
+ "net.peer.port" => env[:url].port
76
+ }
77
+ end
78
+
79
+ def sanitize_url(url)
80
+ url.gsub(/api_key=[^&]+/, "api_key=[REDACTED]")
81
+ end
82
+
83
+ def otel_status(status_code)
84
+ case status_code
85
+ when 200..299
86
+ OpenTelemetry::Trace::Status.ok
87
+ when 400..599
88
+ OpenTelemetry::Trace::Status.error("HTTP #{status_code}")
89
+ else
90
+ OpenTelemetry::Trace::Status.unset
91
+ end
92
+ end
93
+ end
94
+
95
+ Faraday::Middleware.register_middleware(exa_instrumentation: Instrumentation)
96
+ end
97
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Middleware
7
+ # Client-side rate limiter using token bucket algorithm
8
+ #
9
+ # Prevents 429 errors by limiting requests before they're sent.
10
+ # Uses a thread-safe token bucket with configurable rates.
11
+ #
12
+ # @example Enable rate limiting
13
+ # client = Exa::Client.new(api_key: key) do |config|
14
+ # config.rate_limit = 10 # requests per second
15
+ # config.rate_limit_burst = 20 # max burst size
16
+ # end
17
+ #
18
+ # @example Lower limits for safety
19
+ # client = Exa::Client.new(api_key: key) do |config|
20
+ # config.rate_limit = 5 # conservative 5 req/sec
21
+ # end
22
+ class RateLimiter < Faraday::Middleware
23
+ # @param app [Faraday::Middleware] Next middleware
24
+ # @param rate [Float] Tokens per second (requests per second)
25
+ # @param burst [Integer] Maximum tokens (burst capacity)
26
+ def initialize(app, rate: 10.0, burst: 20)
27
+ super(app)
28
+ @rate = rate.to_f
29
+ @burst = burst
30
+ @tokens = burst.to_f
31
+ @last_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ def call(env)
36
+ wait_for_token
37
+ @app.call(env)
38
+ end
39
+
40
+ private
41
+
42
+ # Wait until a token is available
43
+ def wait_for_token
44
+ @mutex.synchronize do
45
+ refill_tokens
46
+
47
+ if @tokens < 1.0
48
+ # Calculate wait time needed
49
+ wait_time = (1.0 - @tokens) / @rate
50
+ sleep(wait_time) if wait_time > 0
51
+ refill_tokens
52
+ end
53
+
54
+ @tokens -= 1.0
55
+ end
56
+ end
57
+
58
+ # Refill tokens based on time elapsed
59
+ def refill_tokens
60
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ elapsed = now - @last_time
62
+ @last_time = now
63
+
64
+ # Add tokens based on elapsed time
65
+ @tokens = [@tokens + (elapsed * @rate), @burst.to_f].min
66
+ end
67
+ end
68
+
69
+ # Faraday middleware registration
70
+ Faraday::Middleware.register_middleware(exa_rate_limiter: RateLimiter)
71
+ end
72
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "logger"
6
+ require "json"
7
+
8
+ module Exa
9
+ module Middleware
10
+ # Pretty request/response logging middleware for Faraday
11
+ #
12
+ # Logs all HTTP requests and responses with timing, status codes, and
13
+ # optional body logging. Useful for debugging and monitoring API usage.
14
+ #
15
+ # @example Enable logging on client
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.logger = Logger.new($stdout)
18
+ # config.log_level = :info
19
+ # config.log_bodies = true
20
+ # end
21
+ #
22
+ # @example Custom logger
23
+ # client = Exa::Client.new(api_key: key) do |config|
24
+ # config.logger = Rails.logger
25
+ # config.log_level = :debug
26
+ # end
27
+ class RequestLogger < Faraday::Middleware
28
+ DEFAULT_OPTIONS = {
29
+ log_level: :info,
30
+ log_headers: false,
31
+ log_bodies: false,
32
+ filter_keys: %w[api_key x-api-key authorization password secret token].freeze,
33
+ max_body_size: 2000
34
+ }.freeze
35
+
36
+ # @param app [Faraday::Middleware] Next middleware in chain
37
+ # @param logger [Logger] Logger instance
38
+ # @param options [Hash] Configuration options
39
+ def initialize(app, logger: nil, **options)
40
+ super(app)
41
+ @logger = logger || default_logger
42
+ @options = DEFAULT_OPTIONS.merge(options)
43
+ end
44
+
45
+ def call(env)
46
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
47
+ request_id = generate_request_id
48
+
49
+ log_request(env, request_id)
50
+
51
+ @app.call(env).on_complete do |response_env|
52
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
53
+ log_response(response_env, request_id, elapsed)
54
+ end
55
+ rescue StandardError => e
56
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
57
+ log_error(env, request_id, elapsed, e)
58
+ raise
59
+ end
60
+
61
+ private
62
+
63
+ def log_request(env, request_id)
64
+ method = env[:method].to_s.upcase
65
+ url = env[:url].to_s.gsub(/api_key=[^&]+/, "api_key=[FILTERED]")
66
+
67
+ message = "[Exa] #{request_id} → #{method} #{url}"
68
+
69
+ if @options[:log_headers]
70
+ headers = filter_sensitive(env[:request_headers] || {})
71
+ message += "\n Headers: #{headers.to_json}"
72
+ end
73
+
74
+ if @options[:log_bodies] && env[:body]
75
+ body = truncate_body(filter_body(env[:body]))
76
+ message += "\n Body: #{body}"
77
+ end
78
+
79
+ log(message)
80
+ end
81
+
82
+ def log_response(env, request_id, elapsed)
83
+ status = env[:status]
84
+ status_emoji = status_indicator(status)
85
+
86
+ message = "[Exa] #{request_id} ← #{status_emoji} #{status} (#{elapsed}ms)"
87
+
88
+ if @options[:log_bodies] && env[:body]
89
+ body = truncate_body(env[:body].to_s)
90
+ message += "\n Body: #{body}"
91
+ end
92
+
93
+ log(message)
94
+ end
95
+
96
+ def log_error(env, request_id, elapsed, error)
97
+ message = "[Exa] #{request_id} ✗ ERROR after #{elapsed}ms: #{error.class}: #{error.message}"
98
+ log(message, :error)
99
+ end
100
+
101
+ def log(message, level = nil)
102
+ level ||= @options[:log_level]
103
+
104
+ case level
105
+ when :debug
106
+ @logger.debug(message)
107
+ when :info
108
+ @logger.info(message)
109
+ when :warn
110
+ @logger.warn(message)
111
+ when :error
112
+ @logger.error(message)
113
+ else
114
+ @logger.info(message)
115
+ end
116
+ end
117
+
118
+ def status_indicator(status)
119
+ case status
120
+ when 200..299 then "✓"
121
+ when 300..399 then "↻"
122
+ when 400..499 then "⚠"
123
+ when 500..599 then "✗"
124
+ else "?"
125
+ end
126
+ end
127
+
128
+ def generate_request_id
129
+ format("%06x", rand(0xFFFFFF))
130
+ end
131
+
132
+ def filter_sensitive(hash)
133
+ hash.each_with_object({}) do |(key, value), result|
134
+ if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
135
+ result[key] = "[FILTERED]"
136
+ else
137
+ result[key] = value
138
+ end
139
+ end
140
+ end
141
+
142
+ def filter_body(body)
143
+ return body unless body.is_a?(Hash)
144
+
145
+ body.each_with_object({}) do |(key, value), result|
146
+ if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
147
+ result[key] = "[FILTERED]"
148
+ else
149
+ result[key] = value
150
+ end
151
+ end.to_json
152
+ end
153
+
154
+ def truncate_body(body)
155
+ return body if body.length <= @options[:max_body_size]
156
+
157
+ "#{body[0, @options[:max_body_size]]}... (truncated)"
158
+ end
159
+
160
+ def default_logger
161
+ logger = Logger.new($stdout)
162
+ logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
163
+ logger
164
+ end
165
+ end
166
+
167
+ # Register middleware with Faraday
168
+ Faraday::Middleware.register_middleware(exa_logger: RequestLogger)
169
+ end
170
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "digest"
6
+ require "json"
7
+
8
+ module Exa
9
+ module Middleware
10
+ # Response caching middleware for Exa API calls
11
+ #
12
+ # Caches GET requests and idempotent POST requests (like search)
13
+ # to reduce API costs and improve response times.
14
+ #
15
+ # @example Enable caching with memory store
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.cache = Exa::Cache::MemoryStore.new
18
+ # config.cache_ttl = 300 # 5 minutes
19
+ # end
20
+ #
21
+ # @example With Redis
22
+ # require 'redis'
23
+ # client = Exa::Client.new(api_key: key) do |config|
24
+ # config.cache = Exa::Cache::RedisStore.new(Redis.new)
25
+ # config.cache_ttl = 3600 # 1 hour
26
+ # end
27
+ class ResponseCache < Faraday::Middleware
28
+ # Cacheable endpoints (idempotent operations)
29
+ CACHEABLE_PATHS = %w[
30
+ /search
31
+ /contents
32
+ /findSimilar
33
+ ].freeze
34
+
35
+ # @param app [Faraday::Middleware] Next middleware
36
+ # @param cache [Object] Cache store (must respond to get/set)
37
+ # @param ttl [Integer] Time to live in seconds
38
+ # @param cacheable_paths [Array<String>] Paths to cache
39
+ def initialize(app, cache:, ttl: 300, cacheable_paths: CACHEABLE_PATHS)
40
+ super(app)
41
+ @cache = cache
42
+ @ttl = ttl
43
+ @cacheable_paths = cacheable_paths
44
+ end
45
+
46
+ def call(env)
47
+ return @app.call(env) unless cacheable?(env)
48
+
49
+ cache_key = build_cache_key(env)
50
+
51
+ # Try to get from cache
52
+ cached = @cache.get(cache_key)
53
+ if cached
54
+ return build_cached_response(env, cached)
55
+ end
56
+
57
+ # Make request and cache response
58
+ @app.call(env).on_complete do |response_env|
59
+ if response_env[:status] == 200
60
+ cache_response(cache_key, response_env)
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def cacheable?(env)
68
+ path = env[:url].path
69
+ @cacheable_paths.any? { |p| path.include?(p) }
70
+ end
71
+
72
+ def build_cache_key(env)
73
+ # Include method, path, and body in cache key
74
+ components = [
75
+ env[:method].to_s,
76
+ env[:url].path,
77
+ env[:body].to_s
78
+ ]
79
+
80
+ digest = Digest::SHA256.hexdigest(components.join("|"))
81
+ "exa:cache:#{digest}"
82
+ end
83
+
84
+ def cache_response(key, env)
85
+ data = {
86
+ status: env[:status],
87
+ headers: env[:response_headers].to_h,
88
+ body: env[:body]
89
+ }
90
+
91
+ @cache.set(key, data.to_json, ttl: @ttl)
92
+ end
93
+
94
+ def build_cached_response(env, cached_data)
95
+ data = JSON.parse(cached_data, symbolize_names: true)
96
+
97
+ env[:status] = data[:status]
98
+ env[:response_headers] = Faraday::Utils::Headers.new(data[:headers])
99
+ env[:body] = data[:body]
100
+
101
+ # Add cache hit header
102
+ env[:response_headers]["X-Exa-Cache"] = "HIT"
103
+
104
+ Faraday::Response.new(env)
105
+ end
106
+ end
107
+
108
+ Faraday::Middleware.register_middleware(exa_cache: ResponseCache)
109
+ end
110
+
111
+ module Cache
112
+ # In-memory cache store with TTL support
113
+ #
114
+ # Thread-safe, suitable for single-process applications.
115
+ # For multi-process or distributed apps, use RedisStore.
116
+ class MemoryStore
117
+ def initialize
118
+ @store = {}
119
+ @expirations = {}
120
+ @mutex = Mutex.new
121
+ end
122
+
123
+ # Get value from cache
124
+ # @param key [String] Cache key
125
+ # @return [String, nil] Cached value or nil
126
+ def get(key)
127
+ @mutex.synchronize do
128
+ cleanup_expired
129
+ @store[key]
130
+ end
131
+ end
132
+
133
+ # Set value in cache
134
+ # @param key [String] Cache key
135
+ # @param value [String] Value to cache
136
+ # @param ttl [Integer] Time to live in seconds
137
+ def set(key, value, ttl: 300)
138
+ @mutex.synchronize do
139
+ @store[key] = value
140
+ @expirations[key] = Time.now + ttl
141
+ end
142
+ end
143
+
144
+ # Delete from cache
145
+ # @param key [String] Cache key
146
+ def delete(key)
147
+ @mutex.synchronize do
148
+ @store.delete(key)
149
+ @expirations.delete(key)
150
+ end
151
+ end
152
+
153
+ # Clear entire cache
154
+ def clear
155
+ @mutex.synchronize do
156
+ @store.clear
157
+ @expirations.clear
158
+ end
159
+ end
160
+
161
+ # Get cache statistics
162
+ # @return [Hash] Stats including size and hit count
163
+ def stats
164
+ @mutex.synchronize do
165
+ cleanup_expired
166
+ { size: @store.size }
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def cleanup_expired
173
+ now = Time.now
174
+ expired_keys = @expirations.select { |_, exp| exp < now }.keys
175
+ expired_keys.each do |key|
176
+ @store.delete(key)
177
+ @expirations.delete(key)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Redis cache store for distributed caching
183
+ #
184
+ # Requires redis gem to be installed.
185
+ #
186
+ # @example
187
+ # require 'redis'
188
+ # cache = Exa::Cache::RedisStore.new(Redis.new)
189
+ class RedisStore
190
+ # @param redis [Redis] Redis client instance
191
+ # @param prefix [String] Key prefix
192
+ def initialize(redis, prefix: "exa")
193
+ @redis = redis
194
+ @prefix = prefix
195
+ end
196
+
197
+ def get(key)
198
+ @redis.get(prefixed(key))
199
+ end
200
+
201
+ def set(key, value, ttl: 300)
202
+ @redis.setex(prefixed(key), ttl, value)
203
+ end
204
+
205
+ def delete(key)
206
+ @redis.del(prefixed(key))
207
+ end
208
+
209
+ def clear
210
+ keys = @redis.keys("#{@prefix}:*")
211
+ @redis.del(*keys) if keys.any?
212
+ end
213
+
214
+ def stats
215
+ keys = @redis.keys("#{@prefix}:*")
216
+ { size: keys.size }
217
+ end
218
+
219
+ private
220
+
221
+ def prefixed(key)
222
+ key.start_with?(@prefix) ? key : "#{@prefix}:#{key}"
223
+ end
224
+ end
225
+ end
226
+ end
data/lib/exa/rails.rb ADDED
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ # Rails integration for the Exa gem
7
+ #
8
+ # Provides generators, Active Job adapters, and Rails-specific configuration.
9
+ #
10
+ # Install with: rails generate exa:install
11
+ module Rails
12
+ class Railtie < ::Rails::Railtie
13
+ # Initialize Exa with Rails configuration
14
+ initializer "exa.configure" do |app|
15
+ # Load config from Rails credentials or environment
16
+ Exa.configure do |config|
17
+ config.api_key = rails_api_key(app)
18
+ config.logger = ::Rails.logger if defined?(::Rails.logger)
19
+ config.timeout = ENV.fetch("EXA_TIMEOUT", 60).to_i
20
+ end
21
+ end
22
+
23
+ # Add Exa rake tasks
24
+ rake_tasks do
25
+ namespace :exa do
26
+ desc "Verify Exa API connection"
27
+ task verify: :environment do
28
+ begin
29
+ client = Exa::Client.new
30
+ result = client.search("test", num_results: 1)
31
+ puts "✓ Exa API connection successful"
32
+ puts " Request ID: #{result.request_id}"
33
+ rescue Exa::Error => e
34
+ puts "✗ Exa API error: #{e.message}"
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ desc "Show Exa configuration"
40
+ task config: :environment do
41
+ puts "Exa Configuration:"
42
+ puts " API Key: #{Exa.configuration&.api_key&.slice(0, 8)}..."
43
+ puts " Base URL: #{Exa.configuration&.base_url}"
44
+ puts " Timeout: #{Exa.configuration&.timeout}s"
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def rails_api_key(app)
52
+ # Try Rails credentials first
53
+ if app.credentials.respond_to?(:exa)
54
+ app.credentials.exa[:api_key]
55
+ elsif app.credentials.respond_to?(:dig)
56
+ app.credentials.dig(:exa, :api_key)
57
+ end || ENV["EXA_API_KEY"]
58
+ end
59
+ end
60
+
61
+ # Active Job adapter for async research tasks
62
+ #
63
+ # @example Create a research job
64
+ # class ExaResearchJob < ApplicationJob
65
+ # include Exa::Rails::ResearchJob
66
+ #
67
+ # def perform(instructions)
68
+ # research(instructions) do |task|
69
+ # # Called when research completes
70
+ # save_results(task.output)
71
+ # end
72
+ # end
73
+ # end
74
+ module ResearchJob
75
+ extend ActiveSupport::Concern
76
+
77
+ included do
78
+ queue_as :exa_research
79
+ retry_on Exa::RateLimitError, wait: :exponentially_longer, attempts: 5
80
+ discard_on Exa::AuthenticationError
81
+ end
82
+
83
+ # Start a research task and poll until complete
84
+ #
85
+ # @param instructions [String] Research instructions
86
+ # @param model [String] Research model
87
+ # @param poll_interval [Integer] Seconds between status checks
88
+ #
89
+ # @yield [Exa::Resources::ResearchTask] Called when complete
90
+ def research(instructions, model: "exa-research", poll_interval: 5)
91
+ client = Exa::Client.new
92
+ task = client.create_research(instructions: instructions, model: model)
93
+
94
+ loop do
95
+ task = client.get_research(task.research_id)
96
+
97
+ case task.status
98
+ when "completed"
99
+ yield task if block_given?
100
+ return task
101
+ when "failed", "canceled"
102
+ raise Exa::Error, "Research task #{task.status}: #{task.error_message}"
103
+ else
104
+ sleep poll_interval
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Action Cable channel for real-time updates
111
+ #
112
+ # @example Create a channel
113
+ # class ExaSearchChannel < ApplicationCable::Channel
114
+ # include Exa::Rails::StreamingChannel
115
+ #
116
+ # def search(data)
117
+ # stream_answer(data["query"])
118
+ # end
119
+ # end
120
+ module StreamingChannel
121
+ extend ActiveSupport::Concern
122
+
123
+ # Stream answer tokens to the client
124
+ #
125
+ # @param query [String] Question to answer
126
+ def stream_answer(query)
127
+ Exa::Utils::SSEClient.stream_answer(
128
+ api_key: ENV["EXA_API_KEY"],
129
+ query: query
130
+ ) do |event|
131
+ case event[:type]
132
+ when :token
133
+ transmit({ type: "token", data: event[:data] })
134
+ when :citation
135
+ transmit({ type: "citation", data: event[:data] })
136
+ when :done
137
+ transmit({ type: "done" })
138
+ when :error
139
+ transmit({ type: "error", data: event[:data] })
140
+ end
141
+ end
142
+ end
143
+
144
+ # Stream research progress to the client
145
+ #
146
+ # @param instructions [String] Research instructions
147
+ def stream_research(instructions)
148
+ Exa::Utils::SSEClient.stream_research(
149
+ api_key: ENV["EXA_API_KEY"],
150
+ instructions: instructions
151
+ ) do |event|
152
+ transmit({ type: event[:type].to_s, data: event[:data] })
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "concurrent"
6
+
7
+ module Exa
8
+ module Utils
9
+ # Parallel request executor for batch operations
10
+ #
11
+ # Execute multiple Exa API calls concurrently with configurable
12
+ # concurrency limits and automatic error handling.
13
+ #
14
+ # @example Parallel searches
15
+ # queries = ["AI research", "ML trends", "NLP papers"]
16
+ #
17
+ # results = Exa::Utils::Parallel.map(queries, client: client) do |query|
18
+ # client.search(query, num_results: 10)
19
+ # end
20
+ #
21
+ # @example With concurrency limit
22
+ # results = Exa::Utils::Parallel.map(urls, concurrency: 5) do |url|
23
+ # client.get_contents([url])
24
+ # end
25
+ #
26
+ # @example Error handling
27
+ # results = Exa::Utils::Parallel.map(queries, on_error: :skip) do |query|
28
+ # client.search(query)
29
+ # end
30
+ class Parallel
31
+ # Default concurrency limit
32
+ DEFAULT_CONCURRENCY = 10
33
+
34
+ class << self
35
+ # Execute block for each item in parallel
36
+ #
37
+ # @param items [Array] Items to process
38
+ # @param concurrency [Integer] Max concurrent requests
39
+ # @param on_error [Symbol] Error handling: :raise, :skip, :return_nil
40
+ # @param timeout [Integer, nil] Timeout per request in seconds
41
+ #
42
+ # @yield [item] Block to execute for each item
43
+ # @return [Array] Results in same order as input
44
+ #
45
+ # @example
46
+ # Exa::Utils::Parallel.map(queries) { |q| client.search(q) }
47
+ def map(items, concurrency: DEFAULT_CONCURRENCY, on_error: :raise, timeout: nil)
48
+ return [] if items.empty?
49
+
50
+ pool = Concurrent::FixedThreadPool.new(concurrency)
51
+ futures = []
52
+
53
+ items.each_with_index do |item, idx|
54
+ future = Concurrent::Future.execute(executor: pool) do
55
+ if timeout
56
+ Concurrent::Promises.future { yield(item) }.value!(timeout)
57
+ else
58
+ yield(item)
59
+ end
60
+ end
61
+ futures << { index: idx, future: future }
62
+ end
63
+
64
+ # Collect results in order
65
+ results = Array.new(items.length)
66
+
67
+ futures.each do |entry|
68
+ begin
69
+ results[entry[:index]] = entry[:future].value!
70
+ rescue StandardError => e
71
+ case on_error
72
+ when :raise
73
+ pool.shutdown
74
+ raise
75
+ when :skip
76
+ next
77
+ when :return_nil
78
+ results[entry[:index]] = nil
79
+ else
80
+ raise
81
+ end
82
+ end
83
+ end
84
+
85
+ pool.shutdown
86
+ pool.wait_for_termination
87
+
88
+ results
89
+ end
90
+
91
+ # Execute block for each item in parallel, returning only successful results
92
+ #
93
+ # @param items [Array] Items to process
94
+ # @param concurrency [Integer] Max concurrent requests
95
+ #
96
+ # @yield [item] Block to execute for each item
97
+ # @return [Array] Successful results only
98
+ def map_compact(items, concurrency: DEFAULT_CONCURRENCY, &block)
99
+ map(items, concurrency: concurrency, on_error: :return_nil, &block).compact
100
+ end
101
+
102
+ # Execute block for each item in parallel, ignoring return values
103
+ #
104
+ # @param items [Array] Items to process
105
+ # @param concurrency [Integer] Max concurrent requests
106
+ #
107
+ # @yield [item] Block to execute for each item
108
+ # @return [void]
109
+ def each(items, concurrency: DEFAULT_CONCURRENCY, &block)
110
+ map(items, concurrency: concurrency, on_error: :skip, &block)
111
+ nil
112
+ end
113
+
114
+ # Execute multiple different operations in parallel
115
+ #
116
+ # @param operations [Array<Proc>] Procs to execute
117
+ # @param concurrency [Integer] Max concurrent requests
118
+ #
119
+ # @return [Array] Results from each proc
120
+ #
121
+ # @example
122
+ # results = Exa::Utils::Parallel.all(
123
+ # -> { client.search("AI") },
124
+ # -> { client.search("ML") },
125
+ # -> { client.get_contents([url]) }
126
+ # )
127
+ def all(*operations, concurrency: DEFAULT_CONCURRENCY)
128
+ operations = operations.first if operations.first.is_a?(Array)
129
+
130
+ map(operations, concurrency: concurrency) { |op| op.call }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
data/lib/exa/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # typed: strict
4
4
 
5
5
  module Exa
6
- VERSION = "1.1.0"
6
+ VERSION = "1.2.0"
7
7
  end
data/lib/exa.rb CHANGED
@@ -9,6 +9,13 @@ require_relative "exa/configuration"
9
9
  require_relative "exa/utils/parameter_converter"
10
10
  require_relative "exa/utils/webhook_handler"
11
11
  require_relative "exa/utils/sse_client"
12
+ require_relative "exa/utils/parallel"
13
+
14
+ # Middleware (loaded after Faraday is available in Client)
15
+ require_relative "exa/middleware/request_logger"
16
+ require_relative "exa/middleware/rate_limiter"
17
+ require_relative "exa/middleware/response_cache"
18
+ require_relative "exa/middleware/instrumentation"
12
19
 
13
20
  # Optional: Sorbet types (only loaded if sorbet-runtime is available)
14
21
  begin
@@ -18,6 +25,15 @@ rescue LoadError
18
25
  # sorbet-runtime not installed, types module not available
19
26
  end
20
27
 
28
+ # Optional: Rails integration (only loaded if Rails is available)
29
+ begin
30
+ if defined?(::Rails::Railtie)
31
+ require_relative "exa/rails"
32
+ end
33
+ rescue LoadError
34
+ # Rails not available
35
+ end
36
+
21
37
  require_relative "exa/resources/base"
22
38
  require_relative "exa/resources/search_result"
23
39
  require_relative "exa/resources/search_response"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Exa
6
+ module Generators
7
+ # Rails generator for Exa gem installation
8
+ #
9
+ # Usage:
10
+ # rails generate exa:install
11
+ #
12
+ # This creates:
13
+ # - config/initializers/exa.rb
14
+ # - Adds EXA_API_KEY to .env if using dotenv
15
+ class InstallGenerator < ::Rails::Generators::Base
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Creates an Exa initializer and credentials setup"
19
+
20
+ def create_initializer
21
+ create_file "config/initializers/exa.rb", <<~RUBY
22
+ # frozen_string_literal: true
23
+
24
+ # Exa API Configuration
25
+ # Documentation: https://docs.exa.ai
26
+ # Gem: https://github.com/tigel-agm/exaonruby
27
+
28
+ Exa.configure do |config|
29
+ # API Key (from Rails credentials or environment)
30
+ # Run: rails credentials:edit
31
+ # Add: exa: { api_key: "your-key" }
32
+ config.api_key = Rails.application.credentials.dig(:exa, :api_key) || ENV["EXA_API_KEY"]
33
+
34
+ # Request timeout in seconds (default: 60)
35
+ config.timeout = 60
36
+
37
+ # Maximum retry attempts for failed requests
38
+ config.max_retries = 3
39
+
40
+ # Optional: Enable request logging in development
41
+ if Rails.env.development?
42
+ config.logger = Rails.logger
43
+ end
44
+
45
+ # Optional: Enable caching to reduce API costs
46
+ # config.cache = Exa::Cache::MemoryStore.new
47
+ # config.cache_ttl = 300 # 5 minutes
48
+
49
+ # Optional: Rate limiting (requests per second)
50
+ # config.rate_limit = 10
51
+ # config.rate_limit_burst = 20
52
+ end
53
+ RUBY
54
+ say "Created config/initializers/exa.rb", :green
55
+ end
56
+
57
+ def add_to_env
58
+ if File.exist?(".env")
59
+ append_to_file ".env" do
60
+ "\n# Exa API Key\nEXA_API_KEY=your-api-key-here\n"
61
+ end
62
+ say "Added EXA_API_KEY to .env", :green
63
+ elsif File.exist?(".env.example")
64
+ append_to_file ".env.example" do
65
+ "\n# Exa API Key\nEXA_API_KEY=\n"
66
+ end
67
+ say "Added EXA_API_KEY to .env.example", :green
68
+ end
69
+ end
70
+
71
+ def show_instructions
72
+ say ""
73
+ say "Exa gem installed successfully!", :green
74
+ say ""
75
+ say "Next steps:"
76
+ say " 1. Add your API key to Rails credentials:"
77
+ say " rails credentials:edit"
78
+ say ""
79
+ say " exa:"
80
+ say " api_key: your-api-key-here"
81
+ say ""
82
+ say " 2. Or set the EXA_API_KEY environment variable"
83
+ say ""
84
+ say " 3. Test the connection:"
85
+ say " rails exa:verify"
86
+ say ""
87
+ say "Usage examples:"
88
+ say " client = Exa::Client.new"
89
+ say " results = client.search('AI research', num_results: 10)"
90
+ say ""
91
+ end
92
+ end
93
+ end
94
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exaonruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tigel-agm
@@ -69,10 +69,32 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '3.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: concurrent-ruby
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '1.2'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.2'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '2.0'
72
92
  description: A production-ready Ruby gem wrapper for the Exa.ai Search and Websets
73
93
  APIs. Features neural search, LLM-powered answers, async research tasks, Websets
74
- management (monitors, imports, webhooks), SSE streaming, Sorbet type definitions,
75
- and a beautiful CLI. Includes n8n/Zapier webhook signature verification utilities.
94
+ management (monitors, imports, webhooks), SSE streaming, request logging, rate limiting,
95
+ response caching, OpenTelemetry instrumentation, parallel requests, Rails integration,
96
+ Sorbet types, and a beautiful CLI. Includes n8n/Zapier webhook signature verification
97
+ utilities.
76
98
  email: []
77
99
  executables:
78
100
  - exa
@@ -102,6 +124,11 @@ files:
102
124
  - lib/exa/endpoints/webset_searches.rb
103
125
  - lib/exa/endpoints/websets.rb
104
126
  - lib/exa/errors.rb
127
+ - lib/exa/middleware/instrumentation.rb
128
+ - lib/exa/middleware/rate_limiter.rb
129
+ - lib/exa/middleware/request_logger.rb
130
+ - lib/exa/middleware/response_cache.rb
131
+ - lib/exa/rails.rb
105
132
  - lib/exa/resources/answer_response.rb
106
133
  - lib/exa/resources/base.rb
107
134
  - lib/exa/resources/contents_response.rb
@@ -116,10 +143,12 @@ files:
116
143
  - lib/exa/resources/webset.rb
117
144
  - lib/exa/resources/webset_item.rb
118
145
  - lib/exa/types.rb
146
+ - lib/exa/utils/parallel.rb
119
147
  - lib/exa/utils/parameter_converter.rb
120
148
  - lib/exa/utils/sse_client.rb
121
149
  - lib/exa/utils/webhook_handler.rb
122
150
  - lib/exa/version.rb
151
+ - lib/generators/exa/install_generator.rb
123
152
  homepage: https://github.com/tigel-agm/exaonruby
124
153
  licenses:
125
154
  - MIT
@@ -145,5 +174,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
145
174
  requirements: []
146
175
  rubygems_version: 4.0.2
147
176
  specification_version: 4
148
- summary: Complete Ruby client for the Exa.ai API with CLI and SSE streaming
177
+ summary: Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration
149
178
  test_files: []