agile-proxy 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 (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