pact 1.0.9 → 1.0.10
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.md +20 -0
- data/Gemfile.lock +3 -3
- data/example/animal-service/Gemfile +14 -0
- data/example/animal-service/Gemfile.lock +67 -0
- data/example/animal-service/Rakefile +3 -0
- data/example/animal-service/spec/service_consumers/pact_helper.rb +24 -0
- data/example/animal-service/spec/service_consumers/provider_states_for_zoo_app.rb +9 -0
- data/example/zoo-app/Gemfile.lock +2 -15
- data/example/zoo-app/spec/pacts/zoo_app-animal_service.json +21 -6
- data/example/zoo-app/spec/service_providers/animal_service_spec.rb +1 -1
- data/lib/pact/consumer.rb +10 -11
- data/lib/pact/consumer/app_manager.rb +1 -0
- data/lib/pact/consumer/configuration.rb +179 -0
- data/lib/pact/consumer/interaction_builder.rb +1 -2
- data/lib/pact/consumer/mock_service.rb +1 -370
- data/lib/pact/consumer/mock_service/app.rb +70 -0
- data/lib/pact/consumer/mock_service/interaction_delete.rb +28 -0
- data/lib/pact/consumer/mock_service/interaction_list.rb +57 -0
- data/lib/pact/consumer/mock_service/interaction_post.rb +25 -0
- data/lib/pact/consumer/mock_service/interaction_replay.rb +126 -0
- data/lib/pact/consumer/mock_service/missing_interactions_get.rb +26 -0
- data/lib/pact/consumer/mock_service/rack_request_helper.rb +51 -0
- data/lib/pact/consumer/mock_service/startup_poll.rb +22 -0
- data/lib/pact/consumer/mock_service/verification_get.rb +35 -0
- data/lib/pact/consumer/mock_service_client.rb +3 -1
- data/lib/pact/consumer/mock_service_interaction_expectation.rb +33 -0
- data/lib/pact/consumer/request.rb +27 -0
- data/lib/pact/consumer/rspec.rb +1 -4
- data/lib/pact/consumer_contract/consumer_contract.rb +11 -8
- data/lib/pact/consumer_contract/interaction.rb +9 -22
- data/lib/pact/consumer_contract/request.rb +68 -0
- data/lib/pact/consumer_contract/service_consumer.rb +6 -2
- data/lib/pact/consumer_contract/service_provider.rb +6 -2
- data/lib/pact/matchers/index_not_found.rb +20 -0
- data/lib/pact/matchers/matchers.rb +14 -28
- data/lib/pact/matchers/unexpected_index.rb +17 -0
- data/lib/pact/matchers/unexpected_key.rb +17 -0
- data/lib/pact/provider.rb +1 -1
- data/lib/pact/provider/configuration.rb +129 -0
- data/lib/pact/provider/request.rb +59 -0
- data/lib/pact/provider/rspec.rb +4 -5
- data/lib/pact/provider/test_methods.rb +14 -52
- data/lib/pact/shared/dsl.rb +20 -0
- data/lib/pact/shared/key_not_found.rb +24 -0
- data/lib/pact/shared/null_expectation.rb +31 -0
- data/lib/pact/shared/request.rb +68 -0
- data/lib/pact/something_like.rb +7 -1
- data/lib/pact/symbolize_keys.rb +12 -0
- data/lib/pact/tasks.rb +1 -1
- data/lib/pact/tasks/task_helper.rb +23 -0
- data/lib/pact/{verification_task.rb → tasks/verification_task.rb} +4 -4
- data/lib/pact/version.rb +1 -1
- data/lib/tasks/pact.rake +5 -4
- data/pact.gemspec +1 -1
- data/spec/features/consumption_spec.rb +1 -73
- data/spec/features/production_spec.rb +3 -0
- data/spec/integration/consumer_spec.rb +170 -0
- data/spec/integration/pact/consumer_configuration_spec.rb +1 -1
- data/spec/lib/pact/consumer/{dsl_spec.rb → configuration_spec.rb} +10 -9
- data/spec/lib/pact/consumer/consumer_contract_builder_spec.rb +2 -2
- data/spec/lib/pact/consumer/interaction_builder_spec.rb +1 -1
- data/spec/lib/pact/consumer/mock_service/interaction_list_spec.rb +66 -0
- data/spec/lib/pact/consumer/mock_service/rack_request_helper_spec.rb +82 -0
- data/spec/lib/pact/consumer/mock_service_interaction_expectation_spec.rb +54 -0
- data/spec/lib/pact/consumer/request_spec.rb +24 -0
- data/spec/lib/pact/consumer_contract/consumer_contract_spec.rb +1 -1
- data/spec/lib/pact/consumer_contract/interaction_spec.rb +0 -34
- data/spec/lib/pact/{request_spec.rb → consumer_contract/request_spec.rb} +45 -121
- data/spec/lib/pact/matchers/matchers_spec.rb +29 -13
- data/spec/lib/pact/provider/configuration_spec.rb +163 -0
- data/spec/lib/pact/provider/request_spec.rb +78 -0
- data/spec/lib/pact/provider/test_methods_spec.rb +0 -30
- data/spec/lib/pact/verification_task_spec.rb +1 -1
- data/spec/spec_helper.rb +3 -0
- data/spec/support/factories.rb +5 -1
- data/spec/support/pact_helper.rb +6 -0
- data/spec/support/shared_examples_for_request.rb +83 -0
- data/spec/support/test_app_fail.json +3 -0
- data/spec/support/test_app_pass.json +18 -1
- metadata +68 -31
- data/lib/pact/consumer/dsl.rb +0 -157
- data/lib/pact/pact_task_helper.rb +0 -21
- data/lib/pact/provider/dsl.rb +0 -115
- data/lib/pact/request.rb +0 -167
- data/spec/lib/pact/consumer/mock_service_spec.rb +0 -143
- data/spec/lib/pact/provider/dsl_spec.rb +0 -179
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'pact/consumer/mock_service/rack_request_helper'
|
2
|
+
|
3
|
+
module Pact
|
4
|
+
module Consumer
|
5
|
+
|
6
|
+
class InteractionDelete
|
7
|
+
|
8
|
+
include RackRequestHelper
|
9
|
+
|
10
|
+
def initialize name, logger, interaction_list
|
11
|
+
@name = name
|
12
|
+
@logger = logger
|
13
|
+
@interaction_list = interaction_list
|
14
|
+
end
|
15
|
+
|
16
|
+
def match? env
|
17
|
+
env['REQUEST_PATH'].start_with?('/interactions') &&
|
18
|
+
env['REQUEST_METHOD'] == 'DELETE'
|
19
|
+
end
|
20
|
+
|
21
|
+
def respond env
|
22
|
+
@interaction_list.clear
|
23
|
+
@logger.info "Cleared interactions before example \"#{params_hash(env)['example_description']}\""
|
24
|
+
[200, {}, ['Deleted interactions']]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
class InteractionList
|
4
|
+
|
5
|
+
attr_reader :interactions
|
6
|
+
attr_reader :unexpected_requests
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
clear
|
10
|
+
end
|
11
|
+
|
12
|
+
# For testing, sigh
|
13
|
+
def clear
|
14
|
+
@interactions = []
|
15
|
+
@matched_interactions = []
|
16
|
+
@unexpected_requests = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def add interactions
|
20
|
+
@interactions << interactions
|
21
|
+
end
|
22
|
+
|
23
|
+
def register_matched interaction
|
24
|
+
@matched_interactions << interaction
|
25
|
+
end
|
26
|
+
|
27
|
+
def register_unexpected_request request
|
28
|
+
@unexpected_requests << request
|
29
|
+
end
|
30
|
+
|
31
|
+
def all_matched?
|
32
|
+
interaction_diffs.empty?
|
33
|
+
end
|
34
|
+
|
35
|
+
def missing_interactions
|
36
|
+
@interactions - @matched_interactions
|
37
|
+
end
|
38
|
+
|
39
|
+
def interaction_diffs
|
40
|
+
{
|
41
|
+
:missing_interactions => missing_interactions.collect(&:as_json),
|
42
|
+
:unexpected_requests => unexpected_requests.collect(&:as_json)
|
43
|
+
}.inject({}) do | hash, pair |
|
44
|
+
hash[pair.first] = pair.last if pair.last.any?
|
45
|
+
hash
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_candidate_interactions actual_request
|
50
|
+
interactions.select do | interaction |
|
51
|
+
interaction.request.matches_route? actual_request
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
class InteractionPost
|
4
|
+
|
5
|
+
def initialize name, logger, interaction_list
|
6
|
+
@name = name
|
7
|
+
@logger = logger
|
8
|
+
@interaction_list = interaction_list
|
9
|
+
end
|
10
|
+
|
11
|
+
def match? env
|
12
|
+
env['REQUEST_PATH'] == '/interactions' &&
|
13
|
+
env['REQUEST_METHOD'] == 'POST'
|
14
|
+
end
|
15
|
+
|
16
|
+
def respond env
|
17
|
+
interaction = Interaction.from_hash(JSON.load(env['rack.input'].string))
|
18
|
+
@interaction_list.add interaction
|
19
|
+
@logger.info "Registered expected interaction #{interaction.request.method_and_path} for #{@name}"
|
20
|
+
@logger.ap interaction.as_json
|
21
|
+
[200, {}, ['Added interactions']]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'pact/matchers'
|
2
|
+
require 'pact/consumer/mock_service/rack_request_helper'
|
3
|
+
|
4
|
+
module Pact
|
5
|
+
module Consumer
|
6
|
+
|
7
|
+
class InteractionReplay
|
8
|
+
include Pact::Matchers
|
9
|
+
include RackRequestHelper
|
10
|
+
|
11
|
+
def initialize name, logger, interaction_list
|
12
|
+
@name = name
|
13
|
+
@logger = logger
|
14
|
+
@interaction_list = interaction_list
|
15
|
+
end
|
16
|
+
|
17
|
+
def match? env
|
18
|
+
true # default handler
|
19
|
+
end
|
20
|
+
|
21
|
+
def respond env
|
22
|
+
find_response request_as_hash_from(env)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def find_response request_hash
|
28
|
+
actual_request = Request::Actual.from_hash(request_hash)
|
29
|
+
@logger.info "#{@name} received request #{actual_request.method_and_path}"
|
30
|
+
@logger.ap actual_request.as_json
|
31
|
+
candidate_interactions = @interaction_list.find_candidate_interactions actual_request
|
32
|
+
matching_interactions = find_matching_interactions actual_request, from: candidate_interactions
|
33
|
+
|
34
|
+
case matching_interactions.size
|
35
|
+
when 0 then handle_unrecognised_request actual_request, candidate_interactions
|
36
|
+
when 1 then handle_matched_interaction matching_interactions.first
|
37
|
+
else
|
38
|
+
handle_more_than_one_matching_interaction actual_request, matching_interactions
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def find_matching_interactions actual_request, opts
|
43
|
+
candidate_interactions = opts.fetch(:from)
|
44
|
+
candidate_interactions.select do | candidate_interaction |
|
45
|
+
candidate_interaction.request.matches? actual_request
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def handle_matched_interaction interaction
|
50
|
+
@interaction_list.register_matched interaction
|
51
|
+
response = response_from(interaction.response)
|
52
|
+
@logger.info "Found matching response on #{@name}:"
|
53
|
+
@logger.ap interaction.response
|
54
|
+
response
|
55
|
+
end
|
56
|
+
|
57
|
+
def multiple_interactions_found_response actual_request, matching_interactions
|
58
|
+
response = {
|
59
|
+
message: "Multiple interaction found for #{actual_request.method_and_path}",
|
60
|
+
matching_interactions: matching_interactions.collect{ | interaction | request_summary_for(interaction) }
|
61
|
+
}
|
62
|
+
[500, {'Content-Type' => 'application/json'}, [response.to_json]]
|
63
|
+
end
|
64
|
+
|
65
|
+
def handle_more_than_one_matching_interaction actual_request, matching_interactions
|
66
|
+
@logger.error "Multiple interactions found on #{@name}:"
|
67
|
+
@logger.ap matching_interactions.collect(&:as_json)
|
68
|
+
multiple_interactions_found_response actual_request, matching_interactions
|
69
|
+
end
|
70
|
+
|
71
|
+
def interaction_diffs actual_request, candidate_interactions
|
72
|
+
candidate_interactions.collect do | candidate_interaction |
|
73
|
+
diff = candidate_interaction.request.difference(actual_request)
|
74
|
+
diff_summary_for candidate_interaction, diff
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def diff_summary_for interaction, diff
|
79
|
+
summary = {:description => interaction.description}
|
80
|
+
summary[:provider_state] = interaction.provider_state if interaction.provider_state
|
81
|
+
summary.merge(diff)
|
82
|
+
end
|
83
|
+
|
84
|
+
def request_summary_for interaction
|
85
|
+
summary = {:description => interaction.description}
|
86
|
+
summary[:provider_state] if interaction.provider_state
|
87
|
+
summary[:request] = interaction.request
|
88
|
+
summary
|
89
|
+
end
|
90
|
+
|
91
|
+
def unrecognised_request_response actual_request, interaction_diffs
|
92
|
+
response = {
|
93
|
+
message: "No interaction found for #{actual_request.method_and_path}",
|
94
|
+
interaction_diffs: interaction_diffs
|
95
|
+
}
|
96
|
+
[500, {'Content-Type' => 'application/json'}, [response.to_json]]
|
97
|
+
end
|
98
|
+
|
99
|
+
def log_unrecognised_request_and_interaction_diff actual_request, interaction_diffs, candidate_interactions
|
100
|
+
@logger.error "No interaction found on #{@name} for #{actual_request.method_and_path}"
|
101
|
+
@logger.error 'Interaction diffs for that route:'
|
102
|
+
@logger.ap(interaction_diffs, :error)
|
103
|
+
end
|
104
|
+
|
105
|
+
def handle_unrecognised_request actual_request, candidate_interactions
|
106
|
+
@interaction_list.register_unexpected_request actual_request
|
107
|
+
interaction_diffs = interaction_diffs(actual_request, candidate_interactions)
|
108
|
+
log_unrecognised_request_and_interaction_diff actual_request, interaction_diffs, candidate_interactions
|
109
|
+
unrecognised_request_response actual_request, interaction_diffs
|
110
|
+
end
|
111
|
+
|
112
|
+
def response_from response
|
113
|
+
[response['status'], (response['headers'] || {}).to_hash, [render_body(response['body'])]]
|
114
|
+
end
|
115
|
+
|
116
|
+
def render_body body
|
117
|
+
return '' unless body
|
118
|
+
body.kind_of?(String) ? body.force_encoding('utf-8') : body.to_json
|
119
|
+
end
|
120
|
+
|
121
|
+
def logger_info_ap msg
|
122
|
+
@logger.info msg
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
|
4
|
+
class MissingInteractionsGet
|
5
|
+
include RackRequestHelper
|
6
|
+
|
7
|
+
def initialize name, logger, interaction_list
|
8
|
+
@name = name
|
9
|
+
@logger = logger
|
10
|
+
@interaction_list = interaction_list
|
11
|
+
end
|
12
|
+
|
13
|
+
def match? env
|
14
|
+
env['REQUEST_PATH'].start_with?('/number_of_missing_interactions') &&
|
15
|
+
env['REQUEST_METHOD'] == 'GET'
|
16
|
+
end
|
17
|
+
|
18
|
+
def respond env
|
19
|
+
number_of_missing_interactions = @interaction_list.missing_interactions.size
|
20
|
+
@logger.info "Number of missing interactions for mock \"#{@name}\" = #{number_of_missing_interactions}"
|
21
|
+
[200, {}, ["#{number_of_missing_interactions}"]]
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
|
4
|
+
module RackRequestHelper
|
5
|
+
REQUEST_KEYS = {
|
6
|
+
'REQUEST_METHOD' => :method,
|
7
|
+
'REQUEST_PATH' => :path,
|
8
|
+
'QUERY_STRING' => :query,
|
9
|
+
'rack.input' => :body
|
10
|
+
}
|
11
|
+
|
12
|
+
def params_hash env
|
13
|
+
env["QUERY_STRING"].split("&").collect{| param| param.split("=")}.inject({}){|params, param| params[param.first] = URI.decode(param.last); params }
|
14
|
+
end
|
15
|
+
|
16
|
+
def request_as_hash_from env
|
17
|
+
request = env.inject({}) do |memo, (k, v)|
|
18
|
+
request_key = REQUEST_KEYS[k]
|
19
|
+
memo[request_key] = v if request_key
|
20
|
+
memo
|
21
|
+
end
|
22
|
+
|
23
|
+
request[:headers] = headers_from env
|
24
|
+
body_string = request[:body].read
|
25
|
+
|
26
|
+
if body_string.empty?
|
27
|
+
request.delete :body
|
28
|
+
else
|
29
|
+
body_is_json = request[:headers]['Content-Type'] =~ /json/
|
30
|
+
request[:body] = body_is_json ? JSON.parse(body_string) : body_string
|
31
|
+
end
|
32
|
+
request[:method] = request[:method].downcase
|
33
|
+
request
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def headers_from env
|
39
|
+
headers = env.reject{ |key, value| !(key.start_with?("HTTP") || key == 'CONTENT_TYPE')}
|
40
|
+
headers.inject({}) do | hash, header |
|
41
|
+
hash[standardise_header(header.first)] = header.last
|
42
|
+
hash
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def standardise_header header
|
47
|
+
header.gsub(/^HTTP_/, '').split("_").collect{|word| word[0] + word[1..-1].downcase}.join("-")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
|
4
|
+
class StartupPoll
|
5
|
+
|
6
|
+
def initialize name, logger
|
7
|
+
@name = name
|
8
|
+
@logger = logger
|
9
|
+
end
|
10
|
+
|
11
|
+
def match? env
|
12
|
+
env['REQUEST_PATH'] == '/index.html' &&
|
13
|
+
env['REQUEST_METHOD'] == 'GET'
|
14
|
+
end
|
15
|
+
|
16
|
+
def respond env
|
17
|
+
@logger.info "#{@name} started up"
|
18
|
+
[200, {}, ['Started up fine']]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Pact
|
2
|
+
module Consumer
|
3
|
+
class VerificationGet
|
4
|
+
|
5
|
+
include RackRequestHelper
|
6
|
+
|
7
|
+
def initialize name, logger, log_description, interaction_list
|
8
|
+
@name = name
|
9
|
+
@logger = logger
|
10
|
+
@log_description = log_description
|
11
|
+
@interaction_list = interaction_list
|
12
|
+
end
|
13
|
+
|
14
|
+
def match? env
|
15
|
+
env['REQUEST_PATH'].start_with?('/verify') &&
|
16
|
+
env['REQUEST_METHOD'] == 'GET'
|
17
|
+
end
|
18
|
+
|
19
|
+
def respond env
|
20
|
+
if @interaction_list.all_matched?
|
21
|
+
@logger.info "Verifying - interactions matched for example \"#{example_description(env)}\""
|
22
|
+
[200, {}, ['Interactions matched']]
|
23
|
+
else
|
24
|
+
@logger.warn "Verifying - actual interactions do not match expected interactions for example \"#{example_description(env)}\". Interaction diffs:"
|
25
|
+
@logger.ap @interaction_list.interaction_diffs, :warn
|
26
|
+
[500, {}, ["Actual interactions do not match expected interactions for mock #{@name}. See #{@log_description} for details."]]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def example_description env
|
31
|
+
params_hash(env)['example_description']
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'pact/consumer/mock_service_interaction_expectation'
|
2
|
+
|
1
3
|
module Pact
|
2
4
|
module Consumer
|
3
5
|
class MockServiceClient
|
@@ -22,7 +24,7 @@ module Pact
|
|
22
24
|
end
|
23
25
|
|
24
26
|
def add_expected_interaction interaction
|
25
|
-
http.request_post('/interactions', interaction.
|
27
|
+
http.request_post('/interactions', MockServiceInteractionExpectation.new(interaction).to_json)
|
26
28
|
end
|
27
29
|
|
28
30
|
def self.clear_interactions port, example_description
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'pact/reification'
|
2
|
+
|
3
|
+
# Represents the Interaction in the form required by the MockService
|
4
|
+
# The json generated will be posted to the MockService to register the expectation
|
5
|
+
module Pact
|
6
|
+
module Consumer
|
7
|
+
class MockServiceInteractionExpectation
|
8
|
+
|
9
|
+
|
10
|
+
def initialize interaction
|
11
|
+
@interaction = interaction
|
12
|
+
end
|
13
|
+
|
14
|
+
def as_json
|
15
|
+
hash = {:description => interaction.description}
|
16
|
+
hash[:provider_state] = interaction.provider_state if interaction.provider_state
|
17
|
+
options = interaction.request.options.empty? ? {} : { options: interaction.request.options}
|
18
|
+
hash[:request] = interaction.request.as_json.merge(options)
|
19
|
+
hash[:response] = Reification.from_term(interaction.response)
|
20
|
+
hash
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_json opts = {}
|
24
|
+
as_json.to_json(opts)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :interaction
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'pact/shared/request'
|
2
|
+
require 'pact/shared/key_not_found'
|
3
|
+
|
4
|
+
module Pact
|
5
|
+
module Consumer
|
6
|
+
module Request
|
7
|
+
class Actual < Pact::Request::Base
|
8
|
+
|
9
|
+
def self.from_hash(hash)
|
10
|
+
sym_hash = symbolize_keys hash
|
11
|
+
method = sym_hash.fetch(:method)
|
12
|
+
path = sym_hash.fetch(:path)
|
13
|
+
query = sym_hash.fetch(:query)
|
14
|
+
headers = sym_hash.fetch(:headers)
|
15
|
+
body = sym_hash.fetch(:body, nil)
|
16
|
+
new(method, path, headers, body, query)
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
def self.key_not_found
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|