evil-client 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +7 -0
  3. data/.gitignore +9 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +98 -0
  6. data/.travis.yml +17 -0
  7. data/Gemfile +9 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +144 -0
  10. data/Rakefile +6 -0
  11. data/docs/base_url.md +38 -0
  12. data/docs/documentation.md +9 -0
  13. data/docs/headers.md +59 -0
  14. data/docs/http_method.md +31 -0
  15. data/docs/index.md +127 -0
  16. data/docs/license.md +19 -0
  17. data/docs/model.md +173 -0
  18. data/docs/operation.md +0 -0
  19. data/docs/overview.md +0 -0
  20. data/docs/path.md +48 -0
  21. data/docs/query.md +99 -0
  22. data/docs/responses.md +66 -0
  23. data/docs/security.md +102 -0
  24. data/docs/settings.md +32 -0
  25. data/evil-client.gemspec +25 -0
  26. data/lib/evil/client.rb +97 -0
  27. data/lib/evil/client/connection.rb +35 -0
  28. data/lib/evil/client/connection/net_http.rb +57 -0
  29. data/lib/evil/client/dsl.rb +110 -0
  30. data/lib/evil/client/dsl/files.rb +37 -0
  31. data/lib/evil/client/dsl/operation.rb +102 -0
  32. data/lib/evil/client/dsl/operations.rb +41 -0
  33. data/lib/evil/client/dsl/scope.rb +34 -0
  34. data/lib/evil/client/dsl/security.rb +57 -0
  35. data/lib/evil/client/middleware.rb +81 -0
  36. data/lib/evil/client/middleware/base.rb +15 -0
  37. data/lib/evil/client/middleware/merge_security.rb +16 -0
  38. data/lib/evil/client/middleware/normalize_headers.rb +13 -0
  39. data/lib/evil/client/middleware/stringify_form.rb +36 -0
  40. data/lib/evil/client/middleware/stringify_json.rb +15 -0
  41. data/lib/evil/client/middleware/stringify_multipart.rb +32 -0
  42. data/lib/evil/client/middleware/stringify_multipart/part.rb +36 -0
  43. data/lib/evil/client/middleware/stringify_query.rb +31 -0
  44. data/lib/evil/client/model.rb +65 -0
  45. data/lib/evil/client/operation.rb +34 -0
  46. data/lib/evil/client/operation/request.rb +42 -0
  47. data/lib/evil/client/operation/response.rb +40 -0
  48. data/lib/evil/client/operation/response_error.rb +12 -0
  49. data/lib/evil/client/operation/unexpected_response_error.rb +16 -0
  50. data/mkdocs.yml +21 -0
  51. data/spec/features/instantiation_spec.rb +68 -0
  52. data/spec/features/middleware_spec.rb +75 -0
  53. data/spec/features/operation_with_documentation_spec.rb +41 -0
  54. data/spec/features/operation_with_files_spec.rb +40 -0
  55. data/spec/features/operation_with_form_body_spec.rb +158 -0
  56. data/spec/features/operation_with_headers_spec.rb +99 -0
  57. data/spec/features/operation_with_http_method_spec.rb +45 -0
  58. data/spec/features/operation_with_json_body_spec.rb +156 -0
  59. data/spec/features/operation_with_path_spec.rb +47 -0
  60. data/spec/features/operation_with_query_spec.rb +84 -0
  61. data/spec/features/operation_with_response_spec.rb +109 -0
  62. data/spec/features/operation_with_security_spec.rb +228 -0
  63. data/spec/features/scoping_spec.rb +48 -0
  64. data/spec/spec_helper.rb +23 -0
  65. data/spec/support/test_client.rb +15 -0
  66. data/spec/unit/evil/client/connection/net_http_spec.rb +38 -0
  67. data/spec/unit/evil/client/dsl/files_spec.rb +37 -0
  68. data/spec/unit/evil/client/dsl/operation_spec.rb +233 -0
  69. data/spec/unit/evil/client/dsl/operations_spec.rb +27 -0
  70. data/spec/unit/evil/client/dsl/scope_spec.rb +30 -0
  71. data/spec/unit/evil/client/dsl/security_spec.rb +135 -0
  72. data/spec/unit/evil/client/dsl_spec.rb +57 -0
  73. data/spec/unit/evil/client/middleware/merge_security_spec.rb +32 -0
  74. data/spec/unit/evil/client/middleware/normalize_headers_spec.rb +17 -0
  75. data/spec/unit/evil/client/middleware/stringify_form_spec.rb +63 -0
  76. data/spec/unit/evil/client/middleware/stringify_json_spec.rb +61 -0
  77. data/spec/unit/evil/client/middleware/stringify_multipart/part_spec.rb +59 -0
  78. data/spec/unit/evil/client/middleware/stringify_multipart_spec.rb +62 -0
  79. data/spec/unit/evil/client/middleware/stringify_query_spec.rb +40 -0
  80. data/spec/unit/evil/client/middleware_spec.rb +46 -0
  81. data/spec/unit/evil/client/model_spec.rb +100 -0
  82. data/spec/unit/evil/client/operation/request_spec.rb +49 -0
  83. data/spec/unit/evil/client/operation/response_spec.rb +61 -0
  84. metadata +271 -0
@@ -0,0 +1,15 @@
1
+ class Evil::Client::Middleware
2
+ class StringifyJson < Base
3
+ private
4
+
5
+ def build(env)
6
+ return env unless env[:format] == "json"
7
+
8
+ env.dup.tap do |hash|
9
+ hash[:headers] ||= {}
10
+ hash[:headers]["content-type"] = "application/json"
11
+ hash[:body_string] = JSON.generate(env[:body].to_h)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ class Evil::Client::Middleware
2
+ class StringifyMultipart < Base
3
+ require_relative "stringify_multipart/part"
4
+
5
+ private
6
+
7
+ def build(env)
8
+ return env unless env[:format] == "multipart"
9
+
10
+ env.dup.tap do |hash|
11
+ bound = SecureRandom.hex(10)
12
+ hash[:headers] ||= {}
13
+ hash[:headers]["content-type"] = \
14
+ "multipart/form-data; boundary=#{bound}"
15
+ hash[:body_string] = body_string(hash[:files], bound)
16
+ end
17
+ end
18
+
19
+ def body_string(list, bound)
20
+ return if list.empty?
21
+ [nil, nil, parts(list, bound), "--#{bound}--", nil].join("\r\n")
22
+ end
23
+
24
+ def parts(list, bound)
25
+ list.map.with_index { |item, index| part(bound, index + 1, item) }
26
+ end
27
+
28
+ def part(bound, index, data)
29
+ "--#{bound}\r\n#{Part.new(name: "AttachedFile#{index}", **data)}"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ class Evil::Client::Middleware::StringifyMultipart
2
+ # Takes a file with its options and builds a part of multipart body
3
+ class Part
4
+ extend Dry::Initializer::Mixin
5
+ option :file
6
+ option :type, default: proc { MIME::Types["text/plain"].first }
7
+ option :charset, default: proc { "utf-8" }
8
+ option :name, default: proc { "AttachedFile" }
9
+ option :filename, default: proc { default_filename }
10
+
11
+ def to_s
12
+ [content_disposition, content_type, nil, content].join("\r\n")
13
+ end
14
+
15
+ private
16
+
17
+ def default_filename
18
+ return Pathname.new(file.path).basename if file.respond_to? :path
19
+ "#{SecureRandom.hex(10)}.#{type.preferred_extension}"
20
+ end
21
+
22
+ def content_disposition
23
+ "Content-Disposition: form-data;" \
24
+ " name=\"#{name}\";" \
25
+ " filename=\"#{filename}\""
26
+ end
27
+
28
+ def content_type
29
+ "Content-Type: #{type}; charset=#{charset}"
30
+ end
31
+
32
+ def content
33
+ file.respond_to?(:read) ? file.read : file
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ class Evil::Client::Middleware
2
+ class StringifyQuery < Base
3
+ private
4
+
5
+ def build(env)
6
+ return env if env&.fetch(:query, nil).to_h.empty?
7
+ string = env[:query].flat_map { |key, val| normalize(val, key) }
8
+ .flat_map { |hash| stringify(hash) }
9
+ .join("&")
10
+
11
+ env.merge(query_string: string)
12
+ end
13
+
14
+ def stringify(hash)
15
+ hash.map do |keys, val|
16
+ "#{keys.first}#{keys[1..-1].map { |key| "[#{key}]" }.join}=#{val}"
17
+ end
18
+ end
19
+
20
+ def normalize(value, *keys)
21
+ case value
22
+ when Hash then
23
+ value.flat_map { |key, val| normalize(val, *keys, key) }
24
+ when Array then
25
+ value.flat_map { |val| normalize(val, *keys, nil) }
26
+ else
27
+ [{ keys.map { |key| CGI.escape(key.to_s) } => CGI.escape(value.to_s) }]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,65 @@
1
+ # Base structure for models describing parts of requests and responses
2
+ #
3
+ # The initializer accepts a hash with symbol/string keys,
4
+ # from which it takes and validates necessary options.
5
+ #
6
+ # The method [#to_h] converts nested data to hash
7
+ # with symbolic keys at any level of nesting.
8
+ #
9
+ class Evil::Client
10
+ class Model
11
+ class << self
12
+ include Dry::Initializer::Mixin
13
+ alias_method :attribute, :option
14
+ alias_method :param, :option
15
+
16
+ def new(value)
17
+ return value if value.is_a? self
18
+ value = value.to_h.each_with_object({}) do |(key, val), obj|
19
+ obj[key.to_sym] = val
20
+ end
21
+ super value
22
+ end
23
+
24
+ def call(value)
25
+ new(value).to_h
26
+ end
27
+ alias_method :[], :call
28
+ end
29
+
30
+ tolerant_to_unknown_options
31
+
32
+ def ==(other)
33
+ return false unless other.respond_to? :to_h
34
+ to_h == other.to_h
35
+ end
36
+
37
+ def to_h
38
+ attributes = method(:initialize)
39
+ .parameters
40
+ .map { |item| item[1] unless item[0] == :keyrest }
41
+ .compact
42
+
43
+ attributes.each_with_object({}) do |key, hash|
44
+ val = send(key)
45
+ hash[key] = hashify(val) unless val == Dry::Initializer::UNDEFINED
46
+ end
47
+ end
48
+ alias_method :[], :send
49
+
50
+ private
51
+
52
+ def hashify(value)
53
+ if value.is_a? Evil::Client::Model
54
+ value.to_h
55
+ elsif value.respond_to? :to_hash
56
+ value.to_hash
57
+ .each_with_object({}) { |(key, val), obj| obj[key] = hashify(val) }
58
+ elsif value.is_a? Enumerable
59
+ value.map { |val| hashify(val) }
60
+ else
61
+ value
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,34 @@
1
+ class Evil::Client
2
+ # Carries a final schema for a single operation along with shared connection,
3
+ # and uses it to send requests to the server
4
+ class Operation
5
+ require_relative "operation/request"
6
+ require_relative "operation/response"
7
+
8
+ extend Dry::Initializer::Mixin
9
+ param :schema
10
+ param :connection
11
+
12
+ # Builds and sends a request and returns a response proccessed by schema
13
+ #
14
+ # @param [IO, nil] file
15
+ # @param [Hash<Symbol, Object>] options
16
+ # @return [Object]
17
+ #
18
+ def call(**options)
19
+ req = request.build(options)
20
+ array = connection.call(req)
21
+ response.handle(array)
22
+ end
23
+
24
+ private
25
+
26
+ def request
27
+ @request ||= Request.new(schema)
28
+ end
29
+
30
+ def response
31
+ @response ||= Response.new(schema)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ class Evil::Client::Operation
2
+ # Builds a request env from user options by applying schema validations
3
+ class Request
4
+ extend Dry::Initializer::Mixin
5
+ param :schema
6
+
7
+ # Builds an env
8
+ #
9
+ # @param [IO, nil] file (nil)
10
+ # @param [Hash<Symbol, Object>] options
11
+ # @return [Hash]
12
+ #
13
+ def build(options)
14
+ {
15
+ format: schema[:format],
16
+ http_method: http_method,
17
+ path: path.call(options),
18
+ security: schema[:security]&.call(options),
19
+ files: schema[:files]&.call(options),
20
+ query: schema[:query]&.new(options).to_h,
21
+ body: schema[:body]&.new(options).to_h,
22
+ headers: schema[:headers]&.new(options).to_h
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def key
29
+ @key ||= schema[:key]
30
+ end
31
+
32
+ def http_method
33
+ return schema[:method] if schema[:method]
34
+ fail NotImplementedError.new "No method defined for operation '#{key}'"
35
+ end
36
+
37
+ def path
38
+ return schema[:path] if schema[:path]
39
+ fail NotImplementedError.new "Path not defined for operation '#{key}'"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ class Evil::Client::Operation
2
+ require_relative "response_error"
3
+ require_relative "unexpected_response_error"
4
+
5
+ # Processes rack responses using an operation's schema
6
+ class Response
7
+ extend Dry::Initializer::Mixin
8
+ param :schema
9
+
10
+ # Processes rack responses returned by [Dry::Cluent::Connection]
11
+ #
12
+ # @param [Array] array Rack-compatible array of response data
13
+ # @return [Object]
14
+ # @raise [Evil::Client::ResponseError] if it is required by the schema
15
+ #
16
+ def handle(array)
17
+ status, header, body = array
18
+ response = Rack::Response.new(body, status, header)
19
+
20
+ handler = response_schema(response)
21
+ data = handler[:coercer].call response: response,
22
+ body: response.body,
23
+ header: response.header
24
+
25
+ handler[:raise] ? fail(ResponseError.new(schema, status, data)) : data
26
+ end
27
+
28
+ private
29
+
30
+ def name
31
+ @name ||= schema[:name]
32
+ end
33
+
34
+ def response_schema(response)
35
+ schema[:responses].fetch response.status do
36
+ fail UnexpectedResponseError.new(schema, response)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,12 @@
1
+ class Evil::Client::Operation
2
+ class ResponseError < RuntimeError
3
+ attr_reader :response
4
+
5
+ private
6
+
7
+ def initialize(schema, status, response)
8
+ @response = response
9
+ super "Response to operation '#{schema[:key]}' has http status #{status}"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ class Evil::Client::Operation
2
+ class UnexpectedResponseError < RuntimeError
3
+ attr_reader :response
4
+
5
+ private
6
+
7
+ def initialize(schema, response)
8
+ @response = response
9
+
10
+ message = "Response to operation '#{schema[:key]}'" \
11
+ " has unexpected http status #{response.status}."
12
+ message << " See #{schema[:doc]} for details." if schema[:doc]
13
+ super message
14
+ end
15
+ end
16
+ end
data/mkdocs.yml ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ site_name: Evil::Client
3
+ site_description: Human-friendly DSL for building HTTP(s) clients in Ruby
4
+ repo_url: https://github.com/nepalez/evil-client
5
+ site_author: Andrew Kozin
6
+ theme: readthedocs
7
+ pages:
8
+ - 'Synopsis': 'index.md'
9
+ - 'Details':
10
+ - 'Overview': 'overview.md'
11
+ - 'Model': 'model.md'
12
+ - 'Settings': 'settings.md'
13
+ - 'Base URL': 'base_url.md'
14
+ - 'Operations':
15
+ - 'HTTP method': 'http_method.md'
16
+ - 'Path': 'path.md'
17
+ - 'Security Definitions': 'security.md'
18
+ - 'Headers': 'headers.md'
19
+ - 'Query': 'query.md'
20
+ - 'Responses': 'responses.md'
21
+ - 'Documentation': 'documentation.md'
@@ -0,0 +1,68 @@
1
+ RSpec.describe "instantiation" do
2
+ # see Test::Client definition in `/spec/support/test_client.rb`
3
+ let(:client) { Test::Client.new subdomain, options }
4
+ let(:subdomain) { "foo" }
5
+ let(:options) { { version: 3, user: "bar", password: "baz", token: "qux" } }
6
+
7
+ context "with valid settings:" do
8
+ it "is accepted" do
9
+ expect(client).to be_kind_of Test::Client
10
+ end
11
+ end
12
+
13
+ context "with settings that still conforms to contract:" do
14
+ let(:options) { { user: "bar" } }
15
+
16
+ it "is accepted" do
17
+ expect(client).to be_kind_of Test::Client
18
+ end
19
+ end
20
+
21
+ context "with unexpected param settings:" do
22
+ let(:client) { Test::Client.new(subdomain, subdomain, **options) }
23
+
24
+ it "is rejected" do
25
+ expect { client }.to raise_error(ArgumentError)
26
+ end
27
+ end
28
+
29
+ context "with missing param settings:" do
30
+ let(:client) { Test::Client.new(**options) }
31
+
32
+ it "is rejected" do
33
+ expect { client }.to raise_error(ArgumentError)
34
+ end
35
+ end
36
+
37
+ context "with a broken contract for param:" do
38
+ let(:subdomain) { 1 }
39
+
40
+ it "is rejected" do
41
+ expect { client }.to raise_error(TypeError)
42
+ end
43
+ end
44
+
45
+ context "with unexpected option settings:" do
46
+ before { options[:foo] = "bar" }
47
+
48
+ it "is rejected" do
49
+ expect { client }.to raise_error(ArgumentError)
50
+ end
51
+ end
52
+
53
+ context "with missing option settings:" do
54
+ before { options.delete :user }
55
+
56
+ it "is rejected" do
57
+ expect { client }.to raise_error(ArgumentError)
58
+ end
59
+ end
60
+
61
+ context "with a broken contract for option:" do
62
+ before { options[:user] = 1 }
63
+
64
+ it "is rejected" do
65
+ expect { client }.to raise_error(TypeError)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ RSpec.describe "middleware" do
2
+ before do
3
+ class Test::UpdateRequest
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(_env)
9
+ @app.call path: "data/1",
10
+ http_method: "get",
11
+ format: "form",
12
+ headers: { "baz" => "BAZ" },
13
+ query: { "bar" => "baz" },
14
+ body: { "qux" => 2 }
15
+ end
16
+ end
17
+
18
+ class Test::UpdateResponse
19
+ def initialize(app)
20
+ @app = app
21
+ end
22
+
23
+ def call(env)
24
+ @app.call(env).tap { |rack_response| rack_response[2] = ["Hi!"] }
25
+ end
26
+ end
27
+
28
+ class Test::Client < Evil::Client
29
+ connection do |settings|
30
+ run Test::UpdateRequest
31
+ run Test::UpdateResponse if settings.version > 2
32
+ end
33
+
34
+ operation :find do
35
+ path { "some" }
36
+ http_method :post
37
+ response 200 do |body:, **|
38
+ body.first
39
+ end
40
+ end
41
+ end
42
+
43
+ stub_request(:any, //)
44
+ end
45
+
46
+ it "updates requests" do
47
+ request = a_request(:get, "https://foo.example.com/api/v3/data/1?bar=baz")
48
+ .with do |req|
49
+ expect(req.body).to eq "qux=2"
50
+ expect(req.headers).to include "Baz" => "BAZ"
51
+ end
52
+
53
+ Test::Client.new("foo", version: 3, user: "bar").operations[:find].call
54
+
55
+ expect(request).to have_been_made
56
+ end
57
+
58
+ it "updates responses" do
59
+ response = \
60
+ Test::Client.new("foo", version: 3, user: "bar")
61
+ .operations[:find]
62
+ .call
63
+
64
+ expect(response).to eq "Hi!"
65
+ end
66
+
67
+ it "depends on settings" do
68
+ response = \
69
+ Test::Client.new("foo", version: 1, user: "bar")
70
+ .operations[:find]
71
+ .call
72
+
73
+ expect(response).to be_nil
74
+ end
75
+ end