evil-client 0.2.1

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