diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f6ae9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.bundle +vendor \ No newline at end of file diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..72310a1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +source "https://rubygems.org" + +gem "json" +gem "securerandom" +gem "sinatra" +gem "pqueue" +gem "puma" +gem "redis" +group :development, :test do + gem 'rspec' + gem 'rack-test' + end \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..8017b9a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,60 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + connection_pool (2.5.4) + diff-lcs (1.6.2) + json (2.7.6) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + nio4r (2.7.4) + pqueue (2.1.0) + puma (7.1.0) + nio4r (~> 2.0) + rack (2.2.19) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-test (2.2.0) + rack (>= 1.3) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.26.1) + connection_pool + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + ruby2_keywords (0.0.5) + securerandom (0.3.2) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) + tilt (2.6.1) + +PLATFORMS + ruby + +DEPENDENCIES + json + pqueue + puma + rack-test + redis + rspec + securerandom + sinatra + +BUNDLED WITH + 2.7.2 diff --git a/README.md b/README.md index 025f33d..b3e6641 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -# keyserver-onboarding \ No newline at end of file +Write a server which can generate random api keys, assign them for usage and release them after sometime. Following endpoints should be available on the server to interact with it. +1. There should be one endpoint to generate keys. Once a key is generated it has an expiry of 5 minutes, after which the key is no longer available. +2. There should be an endpoint to get an available key. On hitting this endpoint server should serve a random key which is not already being used. This key should be blocked and should not be served again by E2 while it is in this state. If no eligible key is available then it should serve 404. +3. There should be an endpoint to unblock a key. Unblocked keys can be served via E2 again. Once a key becomes unblocked we can take it as having a new expiry 5 mins from when it was unblocked. +4. There should be an endpoint to delete a key. Deleted keys should be purged. +5. All keys are to be kept alive by clients calling this endpoint every 5 minutes. If a particular key has not received a keep alive in last five minutes then it should be deleted and never used again. + +Apart from these endpoints, following rules should be enforced: +1. All blocked keys should get released automatically within 60 secs if E3 is not called. +2. No endpoint call should result in an iteration of whole set of keys i.e. no endpoint request should be O(n). They should either be O(lg n) or O(1). + +RSpecs (Unit tests) expected for the code. + +You should build this without full fledged Rails framework. + +#### Technical Requirements: +Ruby verison: 2.6.6 +rspec version: 3.10.x + +To run: +bundle install +bundle exec rackup + +To test: +bundle exec rspec \ No newline at end of file diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..0094118 --- /dev/null +++ b/app.rb @@ -0,0 +1,54 @@ +require './key_server.rb' +require 'sinatra' +require 'json' +require 'redis' + +redis = Redis.new +redis.flushall +keyserver = KeyServer.new +# Thread.new do +# loop do +# sleep 1 +# keyserver.free_up +# end +# end + +get '/' do + 'Hi, Welcome to my app.' +end +#E1 +post '/generateKeys' do + content_type :json + keys=keyserver.generate(5) + {status: "success", keys_gen: keys}.to_json +end +#E2 +get '/key' do + content_type :json + available_key = keyserver.get_key + if available_key == nil + halt 404, "No keys available." + end + {key: available_key}.to_json +end +#E3 +put '/releasekey/:id' do + content_type :json + key = params["id"] + halt 400 if keyserver.unblock_key(key)==false + {status: "Successfully unblocked the key."}.to_json +end +#E4 +delete '/deletekey/:id' do + content_type :json + key = params["id"] + halt 400 if keyserver.delete_key(key)==false + {status: "successfully deleted"}.to_json +end +#E5 +put '/keepalive/:id' do + content_type :json + key = params["id"] + halt 400 if keyserver.keep_alive(key)==false + {status: "Key Refreshed."}.to_json +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..19c9bc5 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +require 'rubygems' +require 'bundler' +Bundler.require +require './app.rb' + +run Sinatra::Application \ No newline at end of file diff --git a/constants.rb b/constants.rb new file mode 100644 index 0000000..f085b66 --- /dev/null +++ b/constants.rb @@ -0,0 +1,7 @@ +module KeyServerConstants + T_USED = 60 + T_FREE = 300 + UNBLOCKED_SET = 'UNBLOCKED' + BLOCKED_ZSET = 'BLOCKED' # score = expire_at (float) + FREE_ZSET = 'FREE' # score = expire_at (float) +end diff --git a/key_server.rb b/key_server.rb new file mode 100644 index 0000000..bfe59c9 --- /dev/null +++ b/key_server.rb @@ -0,0 +1,141 @@ +require 'redis' +require 'securerandom' +require 'json' + +class KeyServer + AVAILABLE_SET = "available_keys" + KEEPALIVE_TTL = 300 + BLOCK_TTL = 60 + + def initialize + @redis = Redis.new + end + + def generate(n = 1) + created = [] + + n.times do + id = SecureRandom.hex + lua_script = <<-LUA + local key_id = KEYS[1] + local ttl = ARGV[1] + local available_set = ARGV[2] + local expire_at = tonumber(redis.call('TIME')[1]) + tonumber(ttl) + + redis.call('SETEX', 'key:' .. key_id, ttl, key_id) + redis.call('ZADD', available_set, expire_at, key_id) + + return 1 + LUA + + @redis.eval(lua_script, keys: [id], argv: [KEEPALIVE_TTL, AVAILABLE_SET]) + created << id + end + + { created: created, count: created.size } + end + + def get_key + lua_script = <<-LUA + local now = tonumber(ARGV[1]) + local block_ttl = ARGV[2] + local available_set = ARGV[3] + + redis.call('ZREMRANGEBYSCORE', available_set, 0, now) + + local result = redis.call('ZPOPMIN', available_set, 1) + + if result and result[1] then + local id = result[1] + + redis.call('SETEX', 'blocked:' .. id, block_ttl, "1") + + return {id, now} + else + return nil + end + LUA + + result = @redis.eval(lua_script, keys: [], argv: [Time.now.to_i, BLOCK_TTL, AVAILABLE_SET]) + + return nil unless result && result[0] + + { id: result[0], assigned_at: result[1].to_i } + end + + def unblock_key(id) + lua_script = <<-LUA + local id = KEYS[1] + local ttl = ARGV[1] + local available_set = ARGV[2] + + if redis.call('EXISTS', 'blocked:' .. id) == 0 then + return 0 + end + + local expire_at = tonumber(redis.call('TIME')[1]) + tonumber(ttl) + + redis.call('DEL', 'blocked:' .. id) + redis.call('ZADD', available_set, expire_at, id) + redis.call('EXPIRE', 'key:' .. id, ttl) + + return 1 + LUA + + result = @redis.eval(lua_script, keys: [id], argv: [KEEPALIVE_TTL, AVAILABLE_SET]) + + return false if result == 0 + { message: "Key #{id} unblocked" } + end + + def delete_key(id) + lua_script = <<-LUA + local id = KEYS[1] + local available_set = ARGV[1] + + if redis.call('EXISTS', 'key:' .. id) == 0 then + return 0 + end + + redis.call('DEL', 'key:' .. id) + redis.call('DEL', 'blocked:' .. id) + redis.call('ZREM', available_set, id) + + return 1 + LUA + + result = @redis.eval(lua_script, keys: [id], argv: [AVAILABLE_SET]) + + return false if result == 0 + { message: "Key #{id} deleted" } + end + + def keep_alive(id) + lua_script = <<-LUA + local id = KEYS[1] + local ttl = ARGV[1] + local available_set = ARGV[2] + + local current_ttl = redis.call('TTL', 'key:' .. id) + if current_ttl < 0 then + return 0 + end + + local expire_at = tonumber(redis.call('TIME')[1]) + tonumber(ttl) + + redis.call('ZADD', available_set, expire_at, id) + redis.call('EXPIRE', 'key:' .. id, current_ttl + tonumber(ttl)) + + return 1 + LUA + + result = @redis.eval(lua_script, keys: [id], argv: [KEEPALIVE_TTL, AVAILABLE_SET]) + + return false if result == 0 + { message: "Keepalive refreshed for #{id}" } + end + + def ttl(id) + @redis.ttl("key:#{id}") + end +end \ No newline at end of file diff --git a/spec/keyserver_spec.rb b/spec/keyserver_spec.rb new file mode 100644 index 0000000..41968f6 --- /dev/null +++ b/spec/keyserver_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' +require 'redis' + +describe KeyServer do + let(:redis) { Redis.new } + let(:keyserver) { KeyServer.new } + + before(:each) do + redis.flushall + end + + describe '#generate' do + it 'creates keys and adds them to AVAILABLE_SET with TTL' do + result = keyserver.generate(5) + result[:created].each do |key| + expect(redis.exists("key:#{key}")).to eq(1) + expect(redis.zscore(KeyServer::AVAILABLE_SET, key)).not_to be_nil + end + expect(result[:count]).to eq(5) + end + end + + describe '#get_key' do + it 'returns a key from AVAILABLE_SET and blocks it' do + keyserver.generate(5) + assigned = keyserver.get_key + key = assigned[:id] + + expect(assigned[:id]).not_to be_nil + expect(redis.zscore(KeyServer::AVAILABLE_SET, key)).to be_nil + expect(redis.exists("blocked:#{key}")).to eq(1) + end + + it 'returns nil if no key is available' do + expect(keyserver.get_key).to be_nil + end + end + + describe '#unblock_key' do + it 'moves a blocked key back to AVAILABLE_SET and refreshes TTL' do + keyserver.generate(5) + key = keyserver.get_key[:id] + + expect(redis.exists("blocked:#{key}")).to eq(1) + + result = keyserver.unblock_key(key) + expect(result[:message]).to match(/unblocked/) + expect(redis.exists("blocked:#{key}")).to eq(0) + expect(redis.zscore(KeyServer::AVAILABLE_SET, key)).not_to be_nil + expect(redis.ttl("key:#{key}")).to be > 0 + end + + it 'returns false if key does not exist' do + expect(keyserver.unblock_key('not_there')).to be false + end + end + + describe '#delete_key' do + it 'removes key from AVAILABLE_SET and deletes it' do + key = keyserver.generate(5)[:created].first + keyserver.get_key + expect(keyserver.delete_key(key)[:message]).to match(/deleted/) + expect(redis.exists("key:#{key}")).to eq(0) + expect(redis.exists("blocked:#{key}")).to eq(0) + expect(redis.zscore(KeyServer::AVAILABLE_SET, key)).to be_nil + end + + it 'returns false if key does not exist' do + expect(keyserver.delete_key('not_there')).to be false + end + end + + describe '#keepalive' do + it 'refreshes TTL for available key' do + key = keyserver.generate[:created].first + ttl_before = redis.ttl("key:#{key}") + keyserver.keep_alive(key) + ttl_after = redis.ttl("key:#{key}") + expect(ttl_after).to be > ttl_before + end + + it 'refreshes TTL for blocked key' do + key = keyserver.generate[:created].first + keyserver.get_key + ttl_before = redis.ttl("key:#{key}") + keyserver.keep_alive(key) + ttl_after = redis.ttl("key:#{key}") + expect(ttl_after).to be > ttl_before + end + + it 'returns false if key does not exist' do + expect(keyserver.keep_alive('not_there')).to be false + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..69c8fb4 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,3 @@ +require_relative '../app.rb' +require_relative '../key_server.rb' +require_relative '../constants.rb' \ No newline at end of file