mocktopus 0.0.4
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 +7 -0
- data/.coveralls.yml +1 -0
- data/.gitignore +7 -0
- data/.travis.yml +9 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +19 -0
- data/README.md +309 -0
- data/Rakefile +11 -0
- data/ascii.rb +44 -0
- data/bin/mocktopus +14 -0
- data/config.ru +3 -0
- data/lib/mocktopus.rb +13 -0
- data/lib/mocktopus/app.rb +142 -0
- data/lib/mocktopus/cli.rb +31 -0
- data/lib/mocktopus/input.rb +107 -0
- data/lib/mocktopus/input_container.rb +90 -0
- data/lib/mocktopus/mock_api_call.rb +42 -0
- data/lib/mocktopus/mock_api_call_container.rb +20 -0
- data/lib/mocktopus/response.rb +64 -0
- data/mocktopus.gemspec +29 -0
- data/test/app_test.rb +276 -0
- data/test/cli_test.rb +25 -0
- data/test/input_container_test.rb +229 -0
- data/test/input_test.rb +166 -0
- data/test/mock_api_call_container_test.rb +26 -0
- data/test/mock_api_call_test.rb +35 -0
- data/test/response_test.rb +64 -0
- data/test/test_helper.rb +33 -0
- metadata +292 -0
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
require_relative '../../ascii.rb'
|
3
|
+
|
4
|
+
LOGGER.info 'initialized mocktopus app'
|
5
|
+
$input_container = Mocktopus::InputContainer.new
|
6
|
+
$mock_api_call_container = Mocktopus::MockApiCallContainer.new
|
7
|
+
|
8
|
+
error_example = {
|
9
|
+
"uri" => "/domain/domain.com/users",
|
10
|
+
"headers" => {
|
11
|
+
"whitelisting_key_here" => "value"
|
12
|
+
},
|
13
|
+
"body" => {
|
14
|
+
"name" => "the mocktopus",
|
15
|
+
"email" => "the_mocktopus@the_mocktopus.com"
|
16
|
+
},
|
17
|
+
"verb" => "POST",
|
18
|
+
"response" => {
|
19
|
+
"code" => "202",
|
20
|
+
"delay"=> 5000,
|
21
|
+
"headers" => {},
|
22
|
+
"body" => "Thanks!"
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
post '/mocktopus/inputs/:name' do
|
27
|
+
LOGGER.info("received new input named #{params[:name]}")
|
28
|
+
begin
|
29
|
+
body = JSON.parse(request.body.read().to_s)
|
30
|
+
rescue
|
31
|
+
status 400
|
32
|
+
content_type :json
|
33
|
+
error_hash = { 'message' => 'inputs must be created with a valid json descripiton', 'example' => error_example }
|
34
|
+
body error_hash.to_json
|
35
|
+
return
|
36
|
+
end
|
37
|
+
LOGGER.debug("body: #{body.inspect()}")
|
38
|
+
|
39
|
+
LOGGER.debug("creating response object from #{body['response']}")
|
40
|
+
response = Mocktopus::Response.new(body['response'])
|
41
|
+
|
42
|
+
LOGGER.debug("creating input object from #{body.inspect}, #{response.inspect}")
|
43
|
+
input = Mocktopus::Input.new(body, response)
|
44
|
+
|
45
|
+
LOGGER.info("added input #{params[:name]} successfully")
|
46
|
+
$input_container.add(params[:name], input)
|
47
|
+
end
|
48
|
+
|
49
|
+
get '/mocktopus/inputs' do
|
50
|
+
LOGGER.info("all inputs requested")
|
51
|
+
all_inputs = $input_container.all()
|
52
|
+
LOGGER.debug("found #{all_inputs.size()} inputs")
|
53
|
+
return_inputs = {}
|
54
|
+
all_inputs.each do |k,v|
|
55
|
+
return_inputs[k] = v.to_hash
|
56
|
+
end
|
57
|
+
content_type :json
|
58
|
+
return_inputs.to_json
|
59
|
+
end
|
60
|
+
|
61
|
+
get '/mocktopus/inputs/:name' do
|
62
|
+
LOGGER.info("retrieving input by name #{params[:name]}")
|
63
|
+
input = $input_container.get_by(params[:name])
|
64
|
+
if (input != nil)
|
65
|
+
input.to_hash.to_json
|
66
|
+
else
|
67
|
+
status 405
|
68
|
+
body "input not found"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
get '/mocktopus/mock_api_calls' do
|
73
|
+
$mock_api_call_container.all.to_json
|
74
|
+
end
|
75
|
+
|
76
|
+
delete '/mocktopus/mock_api_calls' do
|
77
|
+
$mock_api_call_container.delete_all
|
78
|
+
status 200
|
79
|
+
body "success"
|
80
|
+
end
|
81
|
+
|
82
|
+
delete '/mocktopus/inputs' do
|
83
|
+
LOGGER.info("deleting all inputs")
|
84
|
+
$input_container.delete_all()
|
85
|
+
end
|
86
|
+
|
87
|
+
delete '/mocktopus/inputs/:name' do
|
88
|
+
LOGGER.info("deleting input by name #{params[:name]}")
|
89
|
+
$input_container.delete_by(params[:name])
|
90
|
+
end
|
91
|
+
|
92
|
+
# # # catch all for 404s
|
93
|
+
not_found do
|
94
|
+
path = request.path
|
95
|
+
verb = request.request_method
|
96
|
+
request_headers = env.inject({}){|acc, (k,v)| acc[$1.downcase] = v if k =~ /^http_(.*)/i; acc}
|
97
|
+
unless(verb.downcase == "get" || verb.downcase == "delete")
|
98
|
+
request_headers.merge!({ "content_type" => request.content_type})
|
99
|
+
end
|
100
|
+
body = request.body.read.to_s
|
101
|
+
log_path = path
|
102
|
+
unless (request.env['rack.request.query_hash'].nil? || request.env['rack.request.query_hash'].empty?)
|
103
|
+
log_path += '?'
|
104
|
+
request.env['rack.request.query_hash'].keys.each do |k|
|
105
|
+
log_path += k
|
106
|
+
log_path += '='
|
107
|
+
log_path += request.env['rack.request.query_hash'][k]
|
108
|
+
log_path += '&' unless k == request.env['rack.request.query_hash'].keys.last
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
$mock_api_call_container.add(Mocktopus::MockApiCall.new(log_path, verb, request_headers, body))
|
113
|
+
|
114
|
+
LOGGER.info("not_found catch all invoked")
|
115
|
+
start_time = Time.now.to_f
|
116
|
+
LOGGER.debug("looking for a match with path #{request.fullpath}, verb #{verb}, headers #{request_headers}, body #{body}")
|
117
|
+
match = $input_container.match(path, verb, request_headers, body, request.env['rack.request.query_hash'])
|
118
|
+
LOGGER.debug("match lookup complete")
|
119
|
+
if (match.nil?)
|
120
|
+
LOGGER.info("match not found. sending 428")
|
121
|
+
status 428
|
122
|
+
content_type :json
|
123
|
+
body_detail = {
|
124
|
+
'message' => 'match not found',
|
125
|
+
'call' => {
|
126
|
+
'path' => request.fullpath,
|
127
|
+
'verb' => verb,
|
128
|
+
'headers' => request_headers,
|
129
|
+
'body' => body
|
130
|
+
}
|
131
|
+
}
|
132
|
+
body body_detail.to_json
|
133
|
+
else
|
134
|
+
LOGGER.info("match found #{match.inspect}")
|
135
|
+
sleep(match.response.delay/1000)
|
136
|
+
status match.response.code
|
137
|
+
headers match.response.headers
|
138
|
+
body match.response.body
|
139
|
+
end
|
140
|
+
end_time = Time.now.to_f
|
141
|
+
LOGGER.info("not_found catch all completed in #{((end_time - start_time) * 1000)} milliseconds")
|
142
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'thor'
|
2
|
+
|
3
|
+
module Mocktopus
|
4
|
+
class CLI < Thor
|
5
|
+
include Thor::Actions
|
6
|
+
|
7
|
+
desc "start", "starts the mocktopus"
|
8
|
+
method_option :port, :desc => "specifies the port to run the mocktopus"
|
9
|
+
def start(*args)
|
10
|
+
port_option = args.include?('-p') ? '' : ' -p 8081'
|
11
|
+
args = args.join(' ')
|
12
|
+
command = "bundle exec thin -R #{ENV['CONFIG_RU'] || 'config.ru'} start#{port_option} #{args}"
|
13
|
+
run_command(command)
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "stop", "stops the mocktopus"
|
17
|
+
def stop
|
18
|
+
command = "bundle exec thin stop"
|
19
|
+
run_command(command)
|
20
|
+
end
|
21
|
+
|
22
|
+
map 's' => :start
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def run_command(command)
|
27
|
+
system(command)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'rack'
|
5
|
+
|
6
|
+
module Mocktopus
|
7
|
+
|
8
|
+
class Input
|
9
|
+
|
10
|
+
class Error < Exception
|
11
|
+
end
|
12
|
+
|
13
|
+
class ValidationError < Error
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_accessor :uri,
|
17
|
+
:headers,
|
18
|
+
:body,
|
19
|
+
:url_parameters,
|
20
|
+
:verb,
|
21
|
+
:response
|
22
|
+
|
23
|
+
def initialize(hash, response)
|
24
|
+
|
25
|
+
uri_object = nil
|
26
|
+
begin
|
27
|
+
uri_object = URI.parse(hash['uri'])
|
28
|
+
rescue URI::InvalidURIError
|
29
|
+
begin
|
30
|
+
uri_object = URI.parse(URI::encode(hash['uri']))
|
31
|
+
rescue URI::InvalidURIError
|
32
|
+
raise ValidationError, "Input uri \"#{hash['uri']}\" is not a valid uri"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
@uri = uri_object.path
|
37
|
+
@url_parameters = Rack::Utils.parse_nested_query(uri_object.query)
|
38
|
+
@headers = hash['headers']
|
39
|
+
#need to transform body from inferred json/hash to string (if applicable)
|
40
|
+
body = ''
|
41
|
+
begin
|
42
|
+
body = JSON.pretty_generate(hash['body'])
|
43
|
+
rescue
|
44
|
+
body = hash['body']
|
45
|
+
end
|
46
|
+
@body = body
|
47
|
+
@verb = hash['verb'].upcase
|
48
|
+
@response = response
|
49
|
+
|
50
|
+
validate_instance_variables
|
51
|
+
|
52
|
+
LOGGER.debug("initialized input object from hash #{hash.inspect()}")
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_hash
|
56
|
+
uri_with_parameters = ''
|
57
|
+
if(@url_parameters != nil && @url_parameters.length > 0)
|
58
|
+
@url_parameters.each do |k,v|
|
59
|
+
if (uri_with_parameters.empty?)
|
60
|
+
uri_with_parameters ="#{@uri}?"
|
61
|
+
else
|
62
|
+
uri_with_parameters = "#{uri_with_parameters}&"
|
63
|
+
end
|
64
|
+
uri_with_parameters = "#{uri_with_parameters}#{k}=#{v}"
|
65
|
+
end
|
66
|
+
else
|
67
|
+
uri_with_parameters = @uri
|
68
|
+
end
|
69
|
+
|
70
|
+
{
|
71
|
+
'uri' => uri_with_parameters,
|
72
|
+
'verb' => @verb,
|
73
|
+
'headers' => @headers,
|
74
|
+
'body' => @body,
|
75
|
+
'response' => @response.to_hash
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
def to_s
|
80
|
+
self.to_hash.to_json
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
def validate_instance_variables
|
85
|
+
|
86
|
+
unless @headers.nil?
|
87
|
+
@headers.each do |key, value|
|
88
|
+
unless String.eql? key.class and String.eql? value.class
|
89
|
+
raise ValidationError, "\"#{key}\" => \"#{value}\" is not a valid header"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
unless @response.nil?
|
95
|
+
unless Mocktopus::Response.eql? @response.class
|
96
|
+
raise ValidationError, "\"#{@response}\" is not a response object"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
unless %w{ OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT PATCH}.include? @verb
|
101
|
+
raise ValidationError, "\"#{@verb}\" is not a valid verb"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Mocktopus
|
4
|
+
|
5
|
+
class InputContainer
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@inputs = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def add(name, input)
|
12
|
+
@inputs[name] = input
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_by(name)
|
16
|
+
@inputs[name]
|
17
|
+
end
|
18
|
+
|
19
|
+
def all
|
20
|
+
@inputs
|
21
|
+
end
|
22
|
+
|
23
|
+
def delete_all
|
24
|
+
@inputs = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
def delete_by(name)
|
28
|
+
@inputs.delete(name)
|
29
|
+
end
|
30
|
+
|
31
|
+
def delete_by_input(input)
|
32
|
+
first_match = @inputs.select{|k, v| v == input}.keys.first
|
33
|
+
self.delete_by(first_match)
|
34
|
+
end
|
35
|
+
|
36
|
+
def match(path, verb, headers, body, url_parameters)
|
37
|
+
self.find_match(path, verb, headers, body, url_parameters, @inputs.values)
|
38
|
+
end
|
39
|
+
|
40
|
+
def find_match(path, verb, headers, body, url_parameters, inputs)
|
41
|
+
result = nil
|
42
|
+
matches = inputs.select{|v| URI.decode(v.uri).eql?(URI.decode(path)) &&
|
43
|
+
headers_match?(headers, v.headers) &&
|
44
|
+
bodies_match?(v.body, body) &&
|
45
|
+
v.verb.eql?(verb) &&
|
46
|
+
v.url_parameters.eql?(url_parameters) }
|
47
|
+
case matches.size
|
48
|
+
when 0
|
49
|
+
result = nil
|
50
|
+
when 1
|
51
|
+
result = matches.first
|
52
|
+
else
|
53
|
+
result = pop_first_input(matches)
|
54
|
+
end
|
55
|
+
return result
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
def headers_match?(input_headers, match_headers)
|
60
|
+
match = true
|
61
|
+
if (match_headers != nil)
|
62
|
+
match_headers.each do |k,v|
|
63
|
+
if(input_headers[k.downcase.gsub("-", "_")].nil?)
|
64
|
+
match = false
|
65
|
+
elsif !input_headers[k.downcase.gsub("-", "_")].eql? v
|
66
|
+
match = false
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
return match
|
71
|
+
end
|
72
|
+
|
73
|
+
def bodies_match?(input_body, match_body)
|
74
|
+
match = false
|
75
|
+
if (input_body.eql? match_body)
|
76
|
+
match = true
|
77
|
+
elsif (input_body.to_s.gsub(/\s+/, "").hash.eql?(match_body.to_s.gsub(/\s+/, "").hash))
|
78
|
+
match = true
|
79
|
+
end
|
80
|
+
return match
|
81
|
+
end
|
82
|
+
|
83
|
+
def pop_first_input(matches)
|
84
|
+
result = matches.first
|
85
|
+
self.delete_by_input(result)
|
86
|
+
return result
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Mocktopus
|
5
|
+
|
6
|
+
class MockApiCall
|
7
|
+
|
8
|
+
attr_accessor :timestamp,
|
9
|
+
:path,
|
10
|
+
:verb,
|
11
|
+
:headers,
|
12
|
+
:body
|
13
|
+
|
14
|
+
def initialize(path, verb, headers, body)
|
15
|
+
@timestamp = Time.now.utc.iso8601(10)
|
16
|
+
@path = path
|
17
|
+
@verb = verb
|
18
|
+
@headers = headers
|
19
|
+
begin
|
20
|
+
@body = JSON.parse(body)
|
21
|
+
rescue
|
22
|
+
@body = body
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_hash
|
27
|
+
{
|
28
|
+
'timestamp' => @timestamp,
|
29
|
+
'path' => @path,
|
30
|
+
'verb' => @verb,
|
31
|
+
'headers' => @headers,
|
32
|
+
'body' => @body
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_s
|
37
|
+
self.to_hash.to_json
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Mocktopus
|
2
|
+
|
3
|
+
class MockApiCallContainer
|
4
|
+
def initialize
|
5
|
+
@calls = []
|
6
|
+
end
|
7
|
+
|
8
|
+
def add(mock_api_call)
|
9
|
+
@calls << mock_api_call
|
10
|
+
end
|
11
|
+
|
12
|
+
def all
|
13
|
+
@calls.collect(&:to_hash)
|
14
|
+
end
|
15
|
+
|
16
|
+
def delete_all
|
17
|
+
@calls = []
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Mocktopus
|
4
|
+
|
5
|
+
class Response
|
6
|
+
|
7
|
+
class Error < Exception
|
8
|
+
end
|
9
|
+
|
10
|
+
class ValidationError < Error
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :code,
|
14
|
+
:headers,
|
15
|
+
:body,
|
16
|
+
:delay
|
17
|
+
|
18
|
+
def initialize(hash)
|
19
|
+
LOGGER.debug("initializing response object")
|
20
|
+
@code = hash['code']
|
21
|
+
@headers = hash['headers']
|
22
|
+
@body = hash['body']
|
23
|
+
@delay = hash['delay'].to_f || 0
|
24
|
+
|
25
|
+
begin
|
26
|
+
@body = JSON.pretty_generate(@body)
|
27
|
+
rescue JSON::GeneratorError
|
28
|
+
@body = @body.to_s
|
29
|
+
end
|
30
|
+
|
31
|
+
validate_instance_variables()
|
32
|
+
|
33
|
+
LOGGER.debug("initialized response object from hash #{hash.inspect()}")
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_hash
|
37
|
+
{
|
38
|
+
'code' => @code,
|
39
|
+
'headers' => @headers,
|
40
|
+
'body' => @body,
|
41
|
+
'delay' => @delay
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
def validate_instance_variables
|
47
|
+
|
48
|
+
if(@code.nil? || !(@code.to_i.between?(100,599) ))
|
49
|
+
raise ValidationError, "\"#{@code}\" is not a valid return code."
|
50
|
+
end
|
51
|
+
|
52
|
+
unless @headers.nil?
|
53
|
+
@headers.each do |key, value|
|
54
|
+
unless String.eql? key.class and String.eql? value.class
|
55
|
+
raise ValidationError, "\"#{key}\" => \"#{value}\" is not a valid header"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|