cache_stache 0.1.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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +231 -0
  3. data/app/assets/stylesheets/cache_stache/application.css +5 -0
  4. data/app/assets/stylesheets/cache_stache/pico.css +4 -0
  5. data/app/controllers/cache_stache/application_controller.rb +11 -0
  6. data/app/controllers/cache_stache/dashboard_controller.rb +32 -0
  7. data/app/helpers/cache_stache/application_helper.rb +37 -0
  8. data/app/views/cache_stache/dashboard/index.html.erb +154 -0
  9. data/app/views/cache_stache/dashboard/keyspace.html.erb +83 -0
  10. data/app/views/layouts/cache_stache/application.html.erb +14 -0
  11. data/config/routes.rb +6 -0
  12. data/lib/cache_stache/cache_client.rb +202 -0
  13. data/lib/cache_stache/configuration.rb +87 -0
  14. data/lib/cache_stache/engine.rb +17 -0
  15. data/lib/cache_stache/instrumentation.rb +142 -0
  16. data/lib/cache_stache/keyspace.rb +28 -0
  17. data/lib/cache_stache/rack_after_reply_middleware.rb +22 -0
  18. data/lib/cache_stache/railtie.rb +30 -0
  19. data/lib/cache_stache/stats_query.rb +89 -0
  20. data/lib/cache_stache/version.rb +5 -0
  21. data/lib/cache_stache/web.rb +69 -0
  22. data/lib/cache_stache/window_options.rb +34 -0
  23. data/lib/cache_stache.rb +37 -0
  24. data/lib/generators/cache_stache/install_generator.rb +21 -0
  25. data/lib/generators/cache_stache/templates/README +35 -0
  26. data/lib/generators/cache_stache/templates/cache_stache.rb +43 -0
  27. data/spec/cache_stache_helper.rb +148 -0
  28. data/spec/dummy_app/Rakefile +5 -0
  29. data/spec/dummy_app/app/assets/config/manifest.js +1 -0
  30. data/spec/dummy_app/config/application.rb +31 -0
  31. data/spec/dummy_app/config/boot.rb +3 -0
  32. data/spec/dummy_app/config/environment.rb +5 -0
  33. data/spec/dummy_app/config/routes.rb +7 -0
  34. data/spec/integration/dashboard_controller_spec.rb +94 -0
  35. data/spec/integration/full_cache_flow_spec.rb +202 -0
  36. data/spec/integration/instrumentation_spec.rb +259 -0
  37. data/spec/integration/rack_after_reply_spec.rb +47 -0
  38. data/spec/integration/rake_tasks_spec.rb +17 -0
  39. data/spec/spec_helper.rb +64 -0
  40. data/spec/unit/cache_client_spec.rb +278 -0
  41. data/spec/unit/configuration_spec.rb +209 -0
  42. data/spec/unit/keyspace_spec.rb +93 -0
  43. data/spec/unit/stats_query_spec.rb +367 -0
  44. data/tasks/cache_stache.rake +74 -0
  45. metadata +226 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module CacheStache
6
+ class StatsQuery
7
+ attr_reader :window, :resolution
8
+
9
+ def initialize(window: 1.hour, resolution: nil)
10
+ @window = window.to_i
11
+ @resolution = resolution
12
+ @config = CacheStache.configuration
13
+ @cache_client = CacheClient.new(@config)
14
+ end
15
+
16
+ def execute
17
+ to_ts = Time.current.to_i
18
+ from_ts = to_ts - window
19
+
20
+ buckets = @cache_client.fetch_buckets(from_ts, to_ts)
21
+
22
+ {
23
+ overall: calculate_overall_stats(buckets),
24
+ keyspaces: calculate_keyspace_stats(buckets),
25
+ buckets: buckets.map { |b| format_bucket(b) },
26
+ window_seconds: window,
27
+ bucket_count: buckets.size
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def calculate_overall_stats(buckets)
34
+ total_hits = 0.0
35
+ total_misses = 0.0
36
+
37
+ buckets.each do |bucket|
38
+ total_hits += bucket[:stats]["overall:hits"].to_f
39
+ total_misses += bucket[:stats]["overall:misses"].to_f
40
+ end
41
+
42
+ total_ops = total_hits + total_misses
43
+ hit_rate = total_ops.positive? ? (total_hits / total_ops * 100).round(2) : 0.0
44
+
45
+ {
46
+ hits: total_hits.round,
47
+ misses: total_misses.round,
48
+ total_operations: total_ops.round,
49
+ hit_rate_percent: hit_rate
50
+ }
51
+ end
52
+
53
+ def calculate_keyspace_stats(buckets)
54
+ keyspace_data = {}
55
+
56
+ @config.keyspaces.each do |keyspace|
57
+ total_hits = 0.0
58
+ total_misses = 0.0
59
+
60
+ buckets.each do |bucket|
61
+ total_hits += bucket[:stats]["#{keyspace.name}:hits"].to_f
62
+ total_misses += bucket[:stats]["#{keyspace.name}:misses"].to_f
63
+ end
64
+
65
+ total_ops = total_hits + total_misses
66
+ hit_rate = total_ops.positive? ? (total_hits / total_ops * 100).round(2) : 0.0
67
+
68
+ keyspace_data[keyspace.name] = {
69
+ label: keyspace.label,
70
+ pattern: keyspace.pattern,
71
+ hits: total_hits.round,
72
+ misses: total_misses.round,
73
+ total_operations: total_ops.round,
74
+ hit_rate_percent: hit_rate
75
+ }
76
+ end
77
+
78
+ keyspace_data
79
+ end
80
+
81
+ def format_bucket(bucket)
82
+ {
83
+ timestamp: bucket[:timestamp],
84
+ time: Time.at(bucket[:timestamp]).utc,
85
+ stats: bucket[:stats]
86
+ }
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CacheStache
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "engine"
4
+
5
+ module CacheStache
6
+ class Web
7
+ class << self
8
+ def call(env)
9
+ ensure_routes_loaded!
10
+
11
+ # Build app only once and cache it
12
+ unless @app
13
+ # Capture middlewares outside the Rack::Builder context
14
+ mw = middlewares
15
+
16
+ @app = Rack::Builder.new do
17
+ mw.each { |middleware, args, block| use(middleware, *args, &block) }
18
+ run CacheStache::Engine
19
+ end.to_app
20
+
21
+ Rails.logger.info "[CacheStache::Web] Rack app built successfully"
22
+ end
23
+
24
+ @app.call(env)
25
+ end
26
+
27
+ def use(middleware, *args, &block)
28
+ middlewares << [middleware, args, block]
29
+ end
30
+
31
+ def middlewares
32
+ @middlewares ||= []
33
+ end
34
+
35
+ def reset_middlewares!
36
+ @middlewares = []
37
+ @app = nil
38
+ end
39
+
40
+ def reset_routes!
41
+ @routes_loaded = false
42
+ @app = nil
43
+ end
44
+
45
+ private
46
+
47
+ def ensure_routes_loaded!
48
+ return if @routes_loaded
49
+
50
+ routes_path = File.expand_path("../../config/routes.rb", __dir__)
51
+ load_engine_components
52
+ load routes_path
53
+ @routes_loaded = true
54
+ end
55
+
56
+ def load_engine_components
57
+ base_path = File.expand_path("../../app", __dir__)
58
+
59
+ Dir["#{base_path}/helpers/**/*.rb"].sort.each do |file|
60
+ load file
61
+ end
62
+
63
+ Dir["#{base_path}/controllers/**/*.rb"].sort.each do |file|
64
+ load file
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CacheStache
4
+ module WindowOptions
5
+ WINDOWS = [
6
+ {param: "5m", aliases: ["5_minutes"], label: "5 minutes", duration: 5.minutes},
7
+ {param: "15m", aliases: ["15_minutes"], label: "15 minutes", duration: 15.minutes},
8
+ {param: "1h", aliases: ["1_hour"], label: "1 hour", duration: 1.hour, default: true},
9
+ {param: "6h", aliases: ["6_hours"], label: "6 hours", duration: 6.hours},
10
+ {param: "1d", aliases: ["1_day", "24h"], label: "1 day", duration: 1.day},
11
+ {param: "1w", aliases: ["1_week", "7d"], label: "1 week", duration: 1.week}
12
+ ].freeze
13
+
14
+ DEFAULT_WINDOW = WINDOWS.find { |w| w[:default] }
15
+
16
+ module_function
17
+
18
+ def for_select
19
+ WINDOWS.map { |w| [w[:label], w[:param]] }
20
+ end
21
+
22
+ def find(param)
23
+ WINDOWS.find { |w| w[:param] == param || w[:aliases].include?(param) } || DEFAULT_WINDOW
24
+ end
25
+
26
+ def label_for(param)
27
+ find(param)[:label]
28
+ end
29
+
30
+ def duration_for(param)
31
+ find(param)[:duration]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ require_relative "cache_stache/version"
6
+ require_relative "cache_stache/window_options"
7
+ require_relative "cache_stache/configuration"
8
+ require_relative "cache_stache/keyspace"
9
+ require_relative "cache_stache/cache_client"
10
+ require_relative "cache_stache/instrumentation"
11
+ require_relative "cache_stache/stats_query"
12
+ require_relative "cache_stache/rack_after_reply_middleware"
13
+
14
+ module CacheStache
15
+ class Error < StandardError; end
16
+
17
+ class << self
18
+ attr_writer :configuration
19
+
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(configuration)
26
+ configuration.validate!
27
+ end
28
+
29
+ def reset_configuration!
30
+ @configuration = Configuration.new
31
+ end
32
+ end
33
+ end
34
+
35
+ require_relative "cache_stache/railtie"
36
+ require_relative "cache_stache/engine"
37
+ require_relative "cache_stache/web"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module CacheStache
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a CacheStache initializer and mounts the dashboard"
11
+
12
+ def copy_initializer
13
+ template "cache_stache.rb", "config/initializers/cache_stache.rb"
14
+ end
15
+
16
+ def show_readme
17
+ readme "README" if behavior == :invoke
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ ===============================================================================
2
+
3
+ CacheStache has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Review and customize config/initializers/cache_stache.rb
8
+
9
+ 2. Mount the dashboard in your routes.rb:
10
+
11
+ # config/routes.rb
12
+ require "cache_stache/web"
13
+
14
+ # Optional: Add authentication
15
+ CacheStache::Web.use Rack::Auth::Basic do |user, pass|
16
+ ActiveSupport::SecurityUtils.secure_compare(user, ENV["CACHE_STACHE_USER"]) &&
17
+ ActiveSupport::SecurityUtils.secure_compare(pass, ENV["CACHE_STACHE_PASS"])
18
+ end
19
+
20
+ mount CacheStache::Web, at: "/cache-stache"
21
+
22
+ 3. Set environment variables (if using auth):
23
+ CACHE_STACHE_USER=admin
24
+ CACHE_STACHE_PASS=secret
25
+
26
+ 4. Start your Rails server and visit /cache-stache
27
+
28
+ 5. (Optional) Add keyspaces to track specific cache patterns in the initializer
29
+
30
+ Rake tasks:
31
+ rake cache_stache:config # Show current configuration
32
+ rake cache_stache:stats # Show current stats
33
+ rake cache_stache:prune # Manually prune old data
34
+
35
+ ===============================================================================
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CacheStache Configuration
4
+ # This file configures the CacheStache cache hit rate monitoring system.
5
+
6
+ CacheStache.configure do |config|
7
+ # Redis connection for storing cache metrics
8
+ # Falls back to ENV["REDIS_URL"] if not set
9
+ config.redis_url = ENV.fetch("CACHE_STACHE_REDIS_URL", ENV["REDIS_URL"])
10
+
11
+ # Size of time buckets for aggregation (default: 5 minutes)
12
+ config.bucket_seconds = 5.minutes
13
+
14
+ # How long to retain data (default: 7 days)
15
+ config.retention_seconds = 7.days
16
+
17
+ # Sample rate (0.0 to 1.0). Use < 1.0 for high-traffic apps (default: 1.0)
18
+ config.sample_rate = 1.0
19
+
20
+ # Enable/disable instrumentation (default: true)
21
+ # Set to false in test environment if desired
22
+ config.enabled = !Rails.env.test?
23
+
24
+ # Defer stats increments until after the response is sent, via Rack's
25
+ # `env["rack.after_reply"]` (supported by Puma and others).
26
+ # Default: false
27
+ config.use_rack_after_reply = false
28
+
29
+ # Define keyspaces to track specific cache key patterns
30
+ # Each keyspace uses a regex to match cache keys
31
+ #
32
+ # Example:
33
+ #
34
+ # config.keyspace :profiles do
35
+ # label "Profile Fragments"
36
+ # match /^profile:/
37
+ # end
38
+ #
39
+ # config.keyspace :search do
40
+ # label "Search Results"
41
+ # match %r{/search/}
42
+ # end
43
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+ require "json"
5
+
6
+ # Initialize the Rails app (spec_helper loads it but doesn't initialize)
7
+ CacheStacheDummy::Application.initialize! unless CacheStacheDummy::Application.initialized?
8
+
9
+ # Load RSpec Rails integration (controller specs, etc.)
10
+ require "rspec/rails"
11
+
12
+ # Ensure the engine is loaded after Rails is initialized
13
+ require "cache_stache/engine"
14
+ load CacheStache::Engine.root.join("config/routes.rb")
15
+
16
+ # Test Redis configuration - uses database 15 to isolate from other Redis usage
17
+ CACHE_STACHE_TEST_REDIS_URL = ENV.fetch("CACHE_STACHE_TEST_REDIS_URL", "redis://localhost:6379/15")
18
+
19
+ module CacheStacheTestHelpers
20
+ # Helper to get the test Redis connection directly
21
+ def cache_stache_redis
22
+ @_cache_stache_redis ||= Redis.new(url: CACHE_STACHE_TEST_REDIS_URL)
23
+ end
24
+
25
+ # Helper to get the current bucket key
26
+ def current_bucket_key(bucket_seconds: 300)
27
+ bucket_ts = (Time.current.to_i / bucket_seconds) * bucket_seconds
28
+ "cache_stache:v1:test:#{bucket_ts}"
29
+ end
30
+
31
+ # Helper to get stats from current bucket
32
+ def current_bucket_stats(bucket_seconds: 300)
33
+ cache_stache_redis.hgetall(current_bucket_key(bucket_seconds: bucket_seconds))
34
+ end
35
+
36
+ # Helper to clear all CacheStache notification listeners
37
+ def clear_cache_stache_listeners
38
+ ActiveSupport::Notifications.notifier.listeners_for("cache_read.active_support").each do |listener|
39
+ ActiveSupport::Notifications.unsubscribe(listener)
40
+ end
41
+ end
42
+
43
+ # Helper to flush CacheStache keys from Redis
44
+ def flush_cache_stache_redis
45
+ redis = Redis.new(url: CACHE_STACHE_TEST_REDIS_URL)
46
+ redis.scan_each(match: "cache_stache:*") { |key| redis.del(key) }
47
+ redis.close
48
+ end
49
+
50
+ # Build a test configuration with common defaults
51
+ def build_test_config(keyspaces: {}, **options)
52
+ CacheStache::Configuration.new.tap do |c|
53
+ c.redis_url = CACHE_STACHE_TEST_REDIS_URL
54
+ c.bucket_seconds = options.fetch(:bucket_seconds, 300)
55
+ c.retention_seconds = options.fetch(:retention_seconds, 3600)
56
+ c.sample_rate = options.fetch(:sample_rate, 1.0)
57
+ c.use_rack_after_reply = options.fetch(:use_rack_after_reply, false)
58
+
59
+ keyspaces.each do |name, keyspace_config|
60
+ c.keyspace(name) do
61
+ label keyspace_config[:label] if keyspace_config[:label]
62
+ match keyspace_config[:match]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ RSpec.configure do |config|
70
+ config.include CacheStacheTestHelpers
71
+
72
+ config.before(:suite) do
73
+ # Verify Redis is available before running tests
74
+ redis = Redis.new(url: CACHE_STACHE_TEST_REDIS_URL)
75
+ begin
76
+ redis.ping
77
+ rescue Redis::CannotConnectError => e
78
+ abort "CacheStache tests require a running Redis server. " \
79
+ "Please start Redis and try again.\n" \
80
+ "Connection error: #{e.message}"
81
+ ensure
82
+ redis.close
83
+ end
84
+ end
85
+
86
+ config.before do
87
+ # Configure CacheStache to use test Redis
88
+ CacheStache.configure do |c|
89
+ c.redis_url = CACHE_STACHE_TEST_REDIS_URL
90
+ c.redis_pool_size = 1
91
+ c.enabled = true
92
+ end
93
+
94
+ # Create a fresh cache client for each test
95
+ cache_client = CacheStache::CacheClient.new(CacheStache.configuration)
96
+ Thread.current[:cache_stache_test_client] = cache_client
97
+
98
+ flush_cache_stache_redis
99
+ Rails.cache.clear
100
+ clear_cache_stache_listeners
101
+ CacheStache::Instrumentation.reset!
102
+ end
103
+
104
+ config.after do
105
+ Thread.current[:cache_stache_test_client] = nil
106
+ Rails.cache.clear
107
+
108
+ if defined?(@_cache_stache_redis) && @_cache_stache_redis
109
+ @_cache_stache_redis.close
110
+ @_cache_stache_redis = nil
111
+ end
112
+ end
113
+ end
114
+
115
+ # Shared context for tests that need instrumentation installed
116
+ RSpec.shared_context "with instrumentation" do
117
+ let(:config) do
118
+ build_test_config(
119
+ keyspaces: {
120
+ views: {label: "View Fragments", match: /^views\//},
121
+ models: {label: "Model Cache", match: /community/}
122
+ }
123
+ )
124
+ end
125
+
126
+ before do
127
+ allow(CacheStache).to receive(:configuration).and_return(config)
128
+ CacheStache::Instrumentation.install!
129
+ end
130
+ end
131
+
132
+ # Shared context for tests that need instrumentation with search keyspace
133
+ RSpec.shared_context "with instrumentation and search" do
134
+ let(:config) do
135
+ build_test_config(
136
+ keyspaces: {
137
+ views: {label: "View Fragments", match: /^views\//},
138
+ models: {label: "Model Cache", match: /community/},
139
+ search: {label: "Search Results", match: /search/}
140
+ }
141
+ )
142
+ end
143
+
144
+ before do
145
+ allow(CacheStache).to receive(:configuration).and_return(config)
146
+ CacheStache::Instrumentation.install!
147
+ end
148
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/application"
4
+
5
+ Rails.application.load_tasks
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets .css
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "boot"
4
+
5
+ require "pathname"
6
+ require "logger"
7
+ require "rails"
8
+ require "rails/engine"
9
+ require "active_support/all"
10
+ require "action_dispatch/railtie"
11
+ require "action_controller/railtie"
12
+ require "action_view/railtie"
13
+ require "sprockets/railtie"
14
+
15
+ require "cache_stache"
16
+ require "cache_stache/web"
17
+ require "cache_stache/engine"
18
+ require "cache_stache/railtie"
19
+
20
+ module CacheStacheDummy
21
+ class Application < Rails::Application
22
+ config.load_defaults Rails::VERSION::STRING.to_f
23
+ config.root = Pathname.new(File.expand_path("..", __dir__))
24
+
25
+ config.eager_load = false
26
+ config.cache_store = :memory_store
27
+ config.logger = Logger.new(nil)
28
+ config.active_support.deprecation = :log
29
+ config.secret_key_base = "cache-stache-test"
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ CacheStacheDummy::Application.initialize!
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cache_stache/web"
4
+
5
+ Rails.application.routes.draw do
6
+ mount CacheStache::Web => "/cache-stache"
7
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache_stache_helper"
4
+
5
+ RSpec.describe CacheStache::DashboardController, type: :controller do
6
+ routes { CacheStache::Engine.routes }
7
+
8
+ let(:config) do
9
+ CacheStache::Configuration.new.tap do |c|
10
+ c.bucket_seconds = 300
11
+ c.retention_seconds = 3600
12
+
13
+ c.keyspace(:views) do
14
+ label "View Fragments"
15
+ match(/^views\//)
16
+ end
17
+
18
+ c.keyspace(:models) do
19
+ label "Model Cache"
20
+ match(/community/)
21
+ end
22
+ end
23
+ end
24
+
25
+ before do
26
+ allow(CacheStache).to receive(:configuration).and_return(config)
27
+ Rails.cache.clear
28
+ end
29
+
30
+ describe "GET #index" do
31
+ it "renders successfully" do
32
+ get :index
33
+ expect(response).to be_successful
34
+ end
35
+
36
+ context "with custom window parameter" do
37
+ it "accepts 5 minutes window" do
38
+ get :index, params: {window: "5m"}
39
+ expect(response).to be_successful
40
+ end
41
+
42
+ it "accepts 15 minutes window" do
43
+ get :index, params: {window: "15m"}
44
+ expect(response).to be_successful
45
+ end
46
+
47
+ it "accepts 1 hour window" do
48
+ get :index, params: {window: "1h"}
49
+ expect(response).to be_successful
50
+ end
51
+
52
+ it "accepts 6 hours window" do
53
+ get :index, params: {window: "6h"}
54
+ expect(response).to be_successful
55
+ end
56
+
57
+ it "accepts 1 day window" do
58
+ get :index, params: {window: "1d"}
59
+ expect(response).to be_successful
60
+ end
61
+
62
+ it "accepts 1 week window" do
63
+ get :index, params: {window: "1w"}
64
+ expect(response).to be_successful
65
+ end
66
+
67
+ it "defaults to 1 hour for invalid window" do
68
+ get :index, params: {window: "invalid"}
69
+ expect(response).to be_successful
70
+ end
71
+ end
72
+ end
73
+
74
+ describe "GET #keyspace" do
75
+ it "renders successfully for valid keyspace" do
76
+ get :keyspace, params: {name: "views"}
77
+ expect(response).to be_successful
78
+ end
79
+
80
+ context "with custom window parameter" do
81
+ it "respects window parameter" do
82
+ get :keyspace, params: {name: "views", window: "6h"}
83
+ expect(response).to be_successful
84
+ end
85
+ end
86
+
87
+ context "with invalid keyspace" do
88
+ it "returns 404" do
89
+ get :keyspace, params: {name: "nonexistent"}
90
+ expect(response).to have_http_status(:not_found)
91
+ end
92
+ end
93
+ end
94
+ end