agile-proxy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (103) hide show
  1. checksums.yaml +7 -0
  2. data/.bowerrc +3 -0
  3. data/.gitignore +8 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +36 -0
  6. data/.travis.yml +8 -0
  7. data/Gemfile +4 -0
  8. data/Gemfile.lock +267 -0
  9. data/Guardfile +20 -0
  10. data/LICENSE +22 -0
  11. data/README.md +93 -0
  12. data/Rakefile +13 -0
  13. data/agile-proxy.gemspec +50 -0
  14. data/assets/index.html +39 -0
  15. data/assets/ui/app/HttpFlexibleProxyApi.js +31 -0
  16. data/assets/ui/app/app.js +1 -0
  17. data/assets/ui/app/controller/Stubs.js +64 -0
  18. data/assets/ui/app/controller/main.js +12 -0
  19. data/assets/ui/app/directive/AppEnhancedFormElement.js +21 -0
  20. data/assets/ui/app/directive/AppFor.js +16 -0
  21. data/assets/ui/app/directive/AppResponseEditor.js +54 -0
  22. data/assets/ui/app/model/RequestSpec.js +6 -0
  23. data/assets/ui/app/routes.js +10 -0
  24. data/assets/ui/app/service/Dialog.js +49 -0
  25. data/assets/ui/app/service/DomId.js +10 -0
  26. data/assets/ui/app/service/Error.js +7 -0
  27. data/assets/ui/app/service/Stub.js +36 -0
  28. data/assets/ui/app/view/404.html +2 -0
  29. data/assets/ui/app/view/dialog/error.html +10 -0
  30. data/assets/ui/app/view/dialog/yesNo.html +8 -0
  31. data/assets/ui/app/view/responses/editForm.html +78 -0
  32. data/assets/ui/app/view/status.html +1 -0
  33. data/assets/ui/app/view/stubs.html +19 -0
  34. data/assets/ui/app/view/stubs/edit.html +58 -0
  35. data/assets/ui/css/main.css +3 -0
  36. data/bin/agile_proxy +113 -0
  37. data/bower.json +27 -0
  38. data/config.yml +6 -0
  39. data/db.yml +10 -0
  40. data/db/migrations/20140818110800_create_users.rb +9 -0
  41. data/db/migrations/20140818134700_create_applications.rb +10 -0
  42. data/db/migrations/20140818135200_create_request_specs.rb +13 -0
  43. data/db/migrations/20140821115300_create_responses.rb +14 -0
  44. data/db/migrations/20140823082900_add_method_to_request_specs.rb +7 -0
  45. data/db/migrations/20140823083900_rename_request_spec_columns.rb +8 -0
  46. data/db/migrations/20141031072100_add_url_type_to_request_specs.rb +8 -0
  47. data/db/migrations/20141105125600_add_conditions_to_request_specs.rb +7 -0
  48. data/db/migrations/20141106083100_add_username_and_password_to_applications.rb +8 -0
  49. data/db/migrations/20141119143800_add_record_to_applications.rb +7 -0
  50. data/db/migrations/20141119174300_create_recordings.rb +18 -0
  51. data/db/schema.rb +78 -0
  52. data/examples/README.md +1 -0
  53. data/examples/facebook_api.html +59 -0
  54. data/examples/tumblr_api.html +22 -0
  55. data/lib/agile_proxy.rb +8 -0
  56. data/lib/agile_proxy/api/applications.rb +77 -0
  57. data/lib/agile_proxy/api/recordings.rb +52 -0
  58. data/lib/agile_proxy/api/request_specs.rb +85 -0
  59. data/lib/agile_proxy/api/root.rb +41 -0
  60. data/lib/agile_proxy/config.rb +63 -0
  61. data/lib/agile_proxy/handlers/handler.rb +43 -0
  62. data/lib/agile_proxy/handlers/proxy_handler.rb +110 -0
  63. data/lib/agile_proxy/handlers/request_handler.rb +57 -0
  64. data/lib/agile_proxy/handlers/stub_handler.rb +113 -0
  65. data/lib/agile_proxy/mitm.crt +22 -0
  66. data/lib/agile_proxy/mitm.key +27 -0
  67. data/lib/agile_proxy/model/application.rb +20 -0
  68. data/lib/agile_proxy/model/recording.rb +16 -0
  69. data/lib/agile_proxy/model/request_spec.rb +47 -0
  70. data/lib/agile_proxy/model/response.rb +56 -0
  71. data/lib/agile_proxy/model/user.rb +17 -0
  72. data/lib/agile_proxy/proxy_connection.rb +113 -0
  73. data/lib/agile_proxy/route.rb +106 -0
  74. data/lib/agile_proxy/router.rb +99 -0
  75. data/lib/agile_proxy/server.rb +85 -0
  76. data/lib/agile_proxy/servers/api.rb +41 -0
  77. data/lib/agile_proxy/servers/request_spec.rb +30 -0
  78. data/lib/agile_proxy/version.rb +6 -0
  79. data/load_proxy.js +39 -0
  80. data/log/.gitkeep +0 -0
  81. data/spec/common_helper.rb +32 -0
  82. data/spec/fixtures/test-server.crt +15 -0
  83. data/spec/fixtures/test-server.key +15 -0
  84. data/spec/integration/helpers/request_spec_helper.rb +60 -0
  85. data/spec/integration/specs/lib/server_spec.rb +407 -0
  86. data/spec/integration_spec_helper.rb +18 -0
  87. data/spec/spec_helper.rb +39 -0
  88. data/spec/support/test_server.rb +75 -0
  89. data/spec/unit/agile_proxy/api/applications_spec.rb +102 -0
  90. data/spec/unit/agile_proxy/api/common_helper.rb +31 -0
  91. data/spec/unit/agile_proxy/api/recordings_spec.rb +115 -0
  92. data/spec/unit/agile_proxy/api/request_specs_spec.rb +159 -0
  93. data/spec/unit/agile_proxy/handlers/handler_spec.rb +8 -0
  94. data/spec/unit/agile_proxy/handlers/proxy_handler_spec.rb +138 -0
  95. data/spec/unit/agile_proxy/handlers/request_handler_spec.rb +55 -0
  96. data/spec/unit/agile_proxy/handlers/stub_handler_spec.rb +154 -0
  97. data/spec/unit/agile_proxy/model/recording_spec.rb +0 -0
  98. data/spec/unit/agile_proxy/model/request_spec_spec.rb +45 -0
  99. data/spec/unit/agile_proxy/model/response_spec.rb +38 -0
  100. data/spec/unit/agile_proxy/server_spec.rb +88 -0
  101. data/spec/unit/agile_proxy/servers/api_spec.rb +31 -0
  102. data/spec/unit/agile_proxy/servers/request_spec_spec.rb +32 -0
  103. metadata +618 -0
@@ -0,0 +1,16 @@
1
+ require_relative 'application'
2
+ module AgileProxy
3
+ #
4
+ # = The recording model
5
+ #
6
+ # When an application is set to allow recording, every HTTP(s) request/response cycle coming through the proxy
7
+ # will create an instance of this model and persist it to the database.
8
+ #
9
+ # An API is then available to access this data via REST for the UI or test suite etc...
10
+ #
11
+ class Recording < ActiveRecord::Base
12
+ belongs_to :application
13
+ serialize :request_headers, JSON
14
+ serialize :response_headers, JSON
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ require_relative 'application'
2
+ require_relative 'user'
3
+ require_relative 'response'
4
+ module AgileProxy
5
+ #
6
+ # = The Request Spec model
7
+ # The request spec is an input/output specification that incoming HTTP(s) requests are matched against.
8
+ #
9
+ # It uses the action dispatch router to do this matching,
10
+ # which is fed data from the database as it's input - i.e. its routing
11
+ # table is generated on the fly.
12
+ #
13
+ # This model is responsible not only for retrieving and persisting these
14
+ # request specifications, but also for creating the response
15
+ #
16
+ class RequestSpec < ActiveRecord::Base
17
+ belongs_to :application
18
+ belongs_to :user
19
+ belongs_to :response
20
+ accepts_nested_attributes_for :response
21
+ validates_inclusion_of :url_type, in: %w(url regex)
22
+ def initialize(attrs = {})
23
+ attrs[:http_method] = attrs.delete(:method) if attrs.key?(:method)
24
+ super
25
+ end
26
+ # The conditions are at present, stored as a JSON string. This is editable as a string in the UI, and therefore
27
+ # accessible using 'conditions' as normal.
28
+ # This method returns a JSON decoded version of this as a HASH
29
+ # @return [Hash] decoded conditions
30
+ def conditions_json
31
+ ActiveSupport::JSON.decode(conditions)
32
+ end
33
+ # This method's output is a 'rack' response, but its input is not.
34
+ # When the router has determined that this request spec is the one that is going to be sent to the client,
35
+ # it will call this method with the request's parameters, headers and body.
36
+ #
37
+ # if no response has been specified, an empty body will be returned,
38
+ # otherwise a 'rack' version of the response is returned
39
+ # @param params [Hash] the request parameters
40
+ # @param headers [Hash] The request headers
41
+ # @param body [String] The request body
42
+ # @return [Array] The rack response
43
+ def call(params, headers, body)
44
+ response.nil? ? [204, { 'Content-Type' => 'text/plain' }, ''] : response.to_rack(params, headers, body)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ require 'flavour_saver'
2
+ require 'flavour_saver/runtime'
3
+ module AgileProxy
4
+ #
5
+ # = The associated response for the RequestSpec
6
+ #
7
+ # An instance of this class is expected to be stored alongside every RequestSpec.
8
+ #
9
+ # It is responsible for the following :-
10
+ #
11
+ # 1. Retrieving response
12
+ # 2. Persisting responses
13
+ # 3. Providing a 'rack' ouput of the response
14
+ # 4. Convenient setters for code, body and content_type
15
+ # 5. Template parsing
16
+ class Response < ActiveRecord::Base
17
+ PROTECTED_HEADERS = ['Content-Type']
18
+ has_many :request_specs
19
+ serialize :headers, JSON
20
+ # A convenient setter for the content_type within the header
21
+ # @param val [String] The content type
22
+ def content_type=(val)
23
+ write_attribute(:content_type, val)
24
+ headers.merge!('Content-Type' => val)
25
+ val
26
+ end
27
+ # Provides the response as a 'rack' response
28
+ #
29
+ # If the response is a template (by specifying is_template as true), the output will
30
+ # have its template values parsed and replaced with
31
+ # data from the input_params, input_headers and input_body
32
+ # Otherwise, the body of the output is sent as is.
33
+ # @param input_params [Hash] The input parameters as a hash
34
+ # @param _input_headers [Hash] The input headers as a hash
35
+ # @param _input_body [String] The input body
36
+ # @return [Array] A 'rack' response array (status, headers, body)
37
+ def to_rack(input_params, _input_headers, _input_body)
38
+ output_headers = headers.clone
39
+ output_content = content
40
+ output_status_code = status_code
41
+ if is_template
42
+ data = OpenStruct.new input_params
43
+ begin
44
+ template = Tilt['handlebars'].new { output_content }
45
+ output_content = template.render data
46
+ rescue ::FlavourSaver::UnknownHelperException => ex
47
+ method = ex.message.match(/Template context doesn't respond to method "(.*)"/)[1]
48
+ output_content = "Missing var or method '#{method}' in data."
49
+ output_status_code = 500
50
+ end
51
+ end
52
+ EventMachine::Synchrony.sleep(delay) if delay > 0
53
+ [output_status_code, output_headers, output_content]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'application'
2
+ require 'active_record'
3
+ module AgileProxy
4
+ #
5
+ # = An API User
6
+ #
7
+ # The API access to the system is multi user in that each user
8
+ # can have many applications and each application can have many stubs etc...
9
+ #
10
+ # This class is responsible for :-
11
+ # 1. Retrieving users from storage
12
+ # 2. Persisting users to storage
13
+ # 3. Providing a list of applications that the user owns
14
+ class User < ActiveRecord::Base
15
+ has_many :applications
16
+ end
17
+ end
@@ -0,0 +1,113 @@
1
+ require 'uri'
2
+ require 'eventmachine'
3
+ require 'http/parser'
4
+ require 'em-http'
5
+ require 'evma_httpserver'
6
+ require 'em-synchrony'
7
+ require 'stringio'
8
+ require 'rack'
9
+
10
+ module AgileProxy
11
+ #
12
+ # = The Proxy Connection
13
+ #
14
+ # This class is the event machine connection used by the proxy. Every request creates a new instance of this
15
+ class ProxyConnection < EventMachine::Connection
16
+ attr_accessor :handler
17
+ def post_init
18
+ @parser = Http::Parser.new(self)
19
+ end
20
+
21
+ def receive_data(data)
22
+ @parser << data
23
+ end
24
+
25
+ def on_message_begin
26
+ @headers = nil
27
+ @body = ''
28
+ end
29
+
30
+ def on_headers_complete(headers)
31
+ @headers = headers
32
+ end
33
+
34
+ def on_body(chunk)
35
+ @body << chunk
36
+ end
37
+
38
+ def on_message_complete
39
+ if @parser.http_method == 'CONNECT'
40
+ restart_with_ssl(@parser.request_url)
41
+ else
42
+ if @ssl
43
+ uri = URI.parse(@parser.request_url)
44
+ @url = "https://#{@ssl}#{[uri.path, uri.query].compact.join('?')}"
45
+ else
46
+ @url = @parser.request_url
47
+ end
48
+ handle_request
49
+ end
50
+ end
51
+
52
+ protected
53
+
54
+ def restart_with_ssl(url)
55
+ @ssl = url
56
+ @parser = Http::Parser.new(self)
57
+ @original_headers = @headers.clone
58
+ send_data("HTTP/1.0 200 Connection established\r\nProxy-agent: Http-Flexible-Proxy/0.0.0\r\n\r\n")
59
+ start_tls(
60
+ private_key_file: File.expand_path('../mitm.key', __FILE__),
61
+ cert_chain_file: File.expand_path('../mitm.crt', __FILE__)
62
+ )
63
+ end
64
+
65
+ def handle_request
66
+ EM.synchrony do
67
+ request = ActionDispatch::Request.new(env)
68
+ request.params # This will populate action_dispatch.request.parameters
69
+ handler.call(env).tap do |response|
70
+ send_response(response)
71
+ end
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def env
78
+ return @__env if @__env
79
+ fake_input_buffer = StringIO.new(@body)
80
+ fake_error_buffer = StringIO.new
81
+ url_parsed = URI.parse(@url)
82
+ @__env = {
83
+ 'rack.input' => Rack::Lint::InputWrapper.new(fake_input_buffer),
84
+ 'rack.errors' => Rack::Lint::ErrorWrapper.new(fake_error_buffer),
85
+ 'REQUEST_METHOD' => @parser.http_method,
86
+ 'REQUEST_PATH' => url_parsed.path,
87
+ 'PATH_INFO' => url_parsed.path,
88
+ 'QUERY_STRING' => url_parsed.query || '',
89
+ 'REQUEST_URI' => url_parsed.path + (url_parsed.query || ''),
90
+ 'rack.url_scheme' => url_parsed.scheme,
91
+ 'CONTENT_LENGTH' => @body.length,
92
+ 'SERVER_NAME' => url_parsed.host,
93
+ 'SERVER_PORT' => url_parsed.port
94
+
95
+ }
96
+ @headers.merge(@original_headers || {}).each do |name, value|
97
+ converted_name = "HTTP_#{name.gsub(/-/, '_').upcase}"
98
+ @__env[converted_name] = value
99
+ end
100
+ @__env['CONTENT_TYPE'] = @__env.delete('HTTP_CONTENT_TYPE') if @__env.key?('HTTP_CONTENT_TYPE')
101
+ @__env['CONTENT_LENGTH'] = @__env.delete('HTTP_CONTENT_LENGTH') if @__env.key?('HTTP_CONTENT_LENGTH')
102
+ @__env
103
+ end
104
+
105
+ def send_response(response)
106
+ res = EM::DelegatedHttpResponse.new(self)
107
+ res.status = response[0]
108
+ res.headers = response[1]
109
+ res.content = response[2]
110
+ res.send_response
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,106 @@
1
+ module AgileProxy
2
+ #
3
+ # An instance of this class represents a route within the system.
4
+ #
5
+ class Route
6
+ attr_accessor :request_method, :pattern, :app, :constraints, :name
7
+
8
+ PATH_INFO = 'PATH_INFO'.freeze
9
+ ROUTE_PARAMS = 'rack.route_params'.freeze
10
+ QUERY_STRING = 'QUERY_STRING'.freeze
11
+ FORM_HASH = 'rack.request.form_hash'.freeze
12
+ REQUEST_METHOD = 'REQUEST_METHOD'.freeze
13
+ HEAD = 'HEAD'.freeze
14
+ GET = 'GET'.freeze
15
+ POST = 'POST'.freeze
16
+ PUT = 'PUT'.freeze
17
+ DELETE = 'DELETE'.freeze
18
+
19
+ DEFAULT_WILDCARD_NAME = :paths
20
+ WILDCARD_PATTERN = /\/\*(.*)/.freeze
21
+ NAMED_SEGMENTS_PATTERN = /\/:([^$\/]+)/.freeze
22
+ NAMED_SEGMENTS_REPLACEMENT_PATTERN = /\/:([^$\/]+)/.freeze
23
+ DOT = '.'.freeze
24
+
25
+ def initialize(request_method, pattern, app, options = {})
26
+ fail ArgumentError, 'pattern cannot be blank' if pattern.to_s.strip.empty?
27
+ fail ArgumentError, 'app must be callable' unless app.respond_to?(:call)
28
+ @request_method = request_method
29
+ @pattern = pattern
30
+ @app = app
31
+ @constraints = options && options[:constraints]
32
+ @name = options && options[:as]
33
+ end
34
+
35
+ def regexp
36
+ @regexp ||= compile
37
+ end
38
+
39
+ def compile
40
+ pattern_match = pattern.match(WILDCARD_PATTERN)
41
+ src = if pattern_match
42
+ @wildcard_name = if pattern_match[1].to_s.strip.empty?
43
+ DEFAULT_WILDCARD_NAME
44
+ else
45
+ pattern_match[1].to_sym
46
+ end
47
+ pattern.gsub(WILDCARD_PATTERN, '(?:/(.*)|)')
48
+ else
49
+ pattern_match = pattern.match(NAMED_SEGMENTS_PATTERN)
50
+ p = if pattern_match
51
+ pattern.gsub(NAMED_SEGMENTS_REPLACEMENT_PATTERN, '/(?<\1>[^.$/]+)')
52
+ else
53
+ pattern
54
+ end
55
+ p + '(?:\.(?<format>.*))?'
56
+ end
57
+ Regexp.new("\\A#{src}\\Z")
58
+ end
59
+
60
+ def match(env)
61
+ request_method = env[REQUEST_METHOD]
62
+ request_method = GET if request_method == HEAD
63
+ path = env[PATH_INFO]
64
+ qs = env[QUERY_STRING]
65
+ return nil unless request_method == self.request_method
66
+ fail ArgumentError, 'path is required' if path.to_s.strip.empty?
67
+ path_match = path.match(regexp)
68
+ return unless path_match
69
+ params = if @wildcard_name
70
+ { @wildcard_name => path_match[1].to_s.split('/') }
71
+ else
72
+ Hash[path_match.names.map(&:to_sym).zip(path_match.captures)]
73
+ end
74
+ params.merge!(::Rack::Utils.parse_nested_query(qs).symbolize_keys) unless qs.nil? || qs.empty?
75
+ params.merge! env[FORM_HASH] if env.key? FORM_HASH
76
+ params.delete(:format) if params.key?(:format) && params[:format].nil?
77
+
78
+ params if meets_constraints(params)
79
+ end
80
+
81
+ def meets_constraints(params)
82
+ if constraints
83
+ constraints.each do |param, constraint|
84
+ unless params.symbolize_keys[param.to_sym].to_s.match(constraint)
85
+ return false
86
+ end
87
+ end
88
+ end
89
+ true
90
+ end
91
+
92
+ def eql?(other)
93
+ other.is_a?(self.class) &&
94
+ other.request_method == request_method &&
95
+ other.pattern == pattern &&
96
+ other.app == app &&
97
+ other.constraints == constraints
98
+ end
99
+
100
+ alias_method :==, :eql?
101
+
102
+ def hash
103
+ request_method.hash ^ pattern.hash ^ app.hash ^ constraints.hash
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,99 @@
1
+ require 'agile_proxy/route'
2
+
3
+ module AgileProxy
4
+ #
5
+ # A rack router used for selecting a 'request spec' from a shortlist
6
+ #
7
+ class Router
8
+ VERSION = '0.4.0'
9
+
10
+ HEAD = 'HEAD'.freeze
11
+ GET = 'GET'.freeze
12
+ POST = 'POST'.freeze
13
+ PUT = 'PUT'.freeze
14
+ DELETE = 'DELETE'.freeze
15
+ REQUEST_METHOD = 'REQUEST_METHOD'.freeze
16
+ PATH_INFO = 'PATH_INFO'.freeze
17
+ ROUTE_PARAMS = 'rack.route_params'.freeze
18
+ QUERY_STRING = 'QUERY_STRING'.freeze
19
+ FORM_HASH = 'rack.request.form_hash'.freeze
20
+
21
+ def initialize(&block)
22
+ @named_routes = {}
23
+ routes(&block)
24
+ end
25
+
26
+ def [](route_name)
27
+ @named_routes[route_name]
28
+ end
29
+
30
+ def routes(&block)
31
+ instance_eval(&block) if block
32
+ @routes
33
+ end
34
+
35
+ def get(route_spec)
36
+ route(GET, route_spec)
37
+ end
38
+
39
+ def post(route_spec)
40
+ route(POST, route_spec)
41
+ end
42
+
43
+ def put(route_spec)
44
+ route(PUT, route_spec)
45
+ end
46
+
47
+ def delete(route_spec)
48
+ route(DELETE, route_spec)
49
+ end
50
+
51
+ def route(method, route_spec)
52
+ route = Route.new(
53
+ method,
54
+ route_spec.first.first,
55
+ route_spec.first.last,
56
+ route_spec.reject { |k, _| k == route_spec.first.first }
57
+ )
58
+ @routes ||= []
59
+ @routes << route
60
+ if route_spec && route_spec[:as]
61
+ # Using ||= so the first route with that name will be returned
62
+ @named_routes[route_spec[:as].to_sym] ||= route_spec.first.first
63
+ end
64
+ route
65
+ end
66
+
67
+ def call(env)
68
+ app = match(env)
69
+ if app
70
+ app.call(env)
71
+ else
72
+ not_found(env)
73
+ end
74
+ end
75
+
76
+ def match(env)
77
+ routes.each do |route|
78
+ params = route.match(env)
79
+ if params
80
+ env[ROUTE_PARAMS] = params
81
+ return route.app
82
+ end
83
+ end
84
+ nil
85
+ end
86
+
87
+ def not_found(env)
88
+ body = "<h1>Not Found</h1><p>No route matches #{env[REQUEST_METHOD]} #{env[PATH_INFO]}</p>"
89
+ [
90
+ 404,
91
+ {
92
+ 'Content-Type' => 'text/html',
93
+ 'Content-Length' => body.length.to_s
94
+ },
95
+ [body]
96
+ ]
97
+ end
98
+ end
99
+ end