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
data/docs/responses.md ADDED
@@ -0,0 +1,66 @@
1
+ For every operation you have to describe all expected responses and how they should be processed.
2
+
3
+ Use the `response` method with anexpected http response status(es):
4
+
5
+ ```ruby
6
+ operation :find_cat do
7
+ # ...
8
+ response 200, 201
9
+ end
10
+ ```
11
+
12
+ This definition tells a client to accept responses with given statuses, and to return an instance of `Rack::Response`.
13
+
14
+ ```ruby
15
+ client.operations[:find_cat].call
16
+ # => #<Rack::Response @code=200 ...>
17
+ ```
18
+
19
+ ## Data Coersion
20
+
21
+ Instead of returning a raw rack response, you can coerce it using a block. The block will take 3 options, namely the response, its body and headers:
22
+
23
+ ```ruby
24
+ operation :find_cat do |settings| # remember that you have access to settings
25
+ # ...
26
+ response 200 do |response:, body:, headers:|
27
+ JSON.parse(body) if settings.format == "json"
28
+ end
29
+ end
30
+
31
+ # later at a runtime
32
+ client.operations[:find_cat].call
33
+ # => { name: "Bastet", age: 10 }
34
+ ```
35
+
36
+
37
+
38
+ ## Raising Exceptions
39
+
40
+ When processing responces with error statuses you may need to raise an exception instead of returning values. Do this using option `raise: true`
41
+
42
+ ```ruby
43
+ operation :find_cat do
44
+ # ...
45
+ response 422, raise: true
46
+ end
47
+ ```
48
+
49
+ This time the operation will raise a `Evil::Client::ResponseError` (inherited from the `RuntimeError`). The exception carries a rack response:
50
+
51
+ ```ruby
52
+ begin
53
+ client.operations[:find_cat].call
54
+ rescue Evil::Client::ResponseError => error
55
+ error.response
56
+ # => #<Rack::Response @code=422 ...>
57
+ end
58
+ ```
59
+
60
+ Like before, you can add a block to handle the response. In this case an exception will carry a result of the block.
61
+
62
+ ## Unexpected Responses
63
+
64
+ In case the server responded with undefined status, the operation raises `Evil::Client::UnexpectedResponseError` (inherited from the `RuntimeError`) that carries a rack response just like the `Evil::Client::ResponseError` before.
65
+
66
+ Notice that you can declare default responses using anonymous `operation {}` syntax. Only those responces that are declared neither by default, nor for a specific operation, will cause unexpected response behaviour.
data/docs/security.md ADDED
@@ -0,0 +1,102 @@
1
+ Use `security` declaration for the authorization schema. Inside the block you have access to 3 methods:
2
+ * `basic_auth`
3
+ * `token_auth`
4
+ * `key_auth`
5
+
6
+ ## Basic Authentication
7
+
8
+ Use `basic_auth(login, password)` to define [basic authentication following RFC-7617][basic_auth]:
9
+
10
+ ```ruby
11
+ operation :find_cat do |settings|
12
+ security do
13
+ basic_auth settings.login, settings.password
14
+ end
15
+ end
16
+ ```
17
+
18
+ This declaration with add a header `"Authentication" => "Basic {encoded token}"` to every request. The header is added independenlty of declaration for other [headers][headers].
19
+
20
+ ## Token Authentication
21
+
22
+ The command `token_auth(token, **options)` allows you to insert a customizable token to any part of the request. Unlike `basic_auth`, you need to provide the token (build, encrypt etc.) by hand.
23
+
24
+ ```ruby
25
+ operation :find_cat do |settings|
26
+ security do
27
+ token_auth settings.token
28
+ end
29
+ end
30
+ ```
31
+
32
+ By default the token is added to `"Authentication" => {token}` header of the request. You can prepend it with a necessary prefix. For example, you can define a [Bearer token authentication following RFC-6750][bearer]:
33
+
34
+ ```ruby
35
+ operation :find_cat do |settings|
36
+ security do
37
+ token_auth settings.token, prefix: "Bearer"
38
+ end
39
+ end
40
+ ```
41
+
42
+ Instead of headers, you can send a token in either request body, or a query. In this case the token will be sent under `access_key` ignoring a prefix:
43
+
44
+ ```ruby
45
+ operation :find_cat do |settings|
46
+ path { "/cats" }
47
+ security do
48
+ token_auth settings.token, using: :query
49
+ end
50
+ end
51
+
52
+ # will send a request to "../cats?access_key={token}"
53
+ ```
54
+
55
+ ## Authentication Using Arbitrary Key
56
+
57
+ The most customizeable option is to authenticate requests with an arbitrary key. This time key-value pair will be added to the selected part (`headers`, `body`, or `query`) of the request:
58
+
59
+ ```ruby
60
+ operation :find_cat do |settings|
61
+ path { "/cats" }
62
+ security do
63
+ key_auth :accss_key, settings.token, using: :query
64
+ end
65
+ end
66
+ ```
67
+
68
+ ## Authentication Using Several Schemes
69
+
70
+ You can define several schemes for the same request. All of them will be applied at once:
71
+
72
+ ```ruby
73
+ operation :find_cat do |settings|
74
+ security do
75
+ basic_auth settings.login, settings.password
76
+ token_auth settings.token, using: :query
77
+ end
78
+ end
79
+ ```
80
+
81
+ Moreover, you can declare shared authentication by default, and either update, or reload it for a specific operation:
82
+
83
+ ```ruby
84
+ operation do |settings|
85
+ security { basic_auth settings.login, settings.password }
86
+ end
87
+
88
+ operation :find_cat do |settings|
89
+ security { token_auth settings.token, using: :query } # added to default security
90
+ end
91
+
92
+ operation :find_cats do |settings|
93
+ security { token_auth settings.token } # reloads default "Authentication" header
94
+ end
95
+ ```
96
+
97
+
98
+ [basic_auth]: https://tools.ietf.org/html/rfc7617
99
+ [bearer]: https://tools.ietf.org/html/rfc6750
100
+ [headers]:
101
+ [body]:
102
+ [query]:
data/docs/settings.md ADDED
@@ -0,0 +1,32 @@
1
+ Use `settings` to parameterize an instance of the client.
2
+
3
+ Inside the block you can define both `param`s and `option`s for a client constructor. See [dry-initializer docs][dry-initializer] for detailed description of the methods' syntax.
4
+
5
+ ```ruby
6
+ require "evil-client"
7
+ require "dry-types"
8
+
9
+ class CatsClient < Evil::Client
10
+ settings do
11
+ param :roor_url
12
+ option :version, type: Dry::Types["coercible.int"], default: proc { 1 }
13
+ option :login, type: Dry::Types["strict.string"] # required
14
+ option :password, type: Dry::Types["strict.string"] # required
15
+ end
16
+ end
17
+ ```
18
+
19
+ Now you can initialize a client:
20
+
21
+ ```ruby
22
+ client = CatsClient.new "https://cats.example.com",
23
+ login: "cats_lover",
24
+ password: "purr"
25
+ ```
26
+
27
+ A container with assigned settings will be passed to blocks declaring [base_url][base_url], [connection][connection], and [operations][operation].
28
+
29
+ [base_url]:
30
+ [connection]:
31
+ [operation]:
32
+ [dry-initializer]: http://dry-rb.org/gems/dry-initializer
@@ -0,0 +1,25 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.name = "evil-client"
3
+ gem.version = "0.2.1"
4
+ gem.author = "Andrew Kozin (nepalez)"
5
+ gem.email = "andrew.kozin@gmail.com"
6
+ gem.homepage = "https://github.com/evilmartians/evil-client"
7
+ gem.summary = "Human-friendly DSL for building HTTP(s) clients in Ruby"
8
+ gem.license = "MIT"
9
+
10
+ gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
11
+ gem.test_files = gem.files.grep(/^spec/)
12
+ gem.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"]
13
+
14
+ gem.required_ruby_version = ">= 2.3"
15
+
16
+ gem.add_runtime_dependency "dry-initializer", "~> 0.7.0"
17
+ gem.add_runtime_dependency "mime-types", "~> 3.0"
18
+ gem.add_runtime_dependency "rack"
19
+
20
+ gem.add_development_dependency "dry-types", "~> 0.9"
21
+ gem.add_development_dependency "rspec", "~> 3.0"
22
+ gem.add_development_dependency "rake", "~> 11"
23
+ gem.add_development_dependency "webmock", "~> 2.1"
24
+ gem.add_development_dependency "rubocop", "~> 0.44"
25
+ end
@@ -0,0 +1,97 @@
1
+ require "dry-initializer"
2
+ require "mime-types"
3
+ require "rack"
4
+
5
+ # Absctract base class for clients to remote APIs
6
+ #
7
+ # @abstract
8
+ # @example
9
+ # class MyClient < Evil::Client
10
+ # # declare settings for the client's constructor
11
+ # # the settings parameterize the rest of the client's definitions
12
+ # settings do
13
+ # option :version, type: Dry::Types["strict.int"].default(1)
14
+ # option :user, type: Dry::Types["strict.string"]
15
+ # option :token, type: Dry::Types["strict.string"]
16
+ # end
17
+ #
18
+ # # define base url of the server
19
+ # base_url do |settings|
20
+ # "https://my_api.com/v#{settings.version}"
21
+ # end
22
+ #
23
+ # # define connection and its middleware stack from bottom to top
24
+ # connection :net_http do |settings|
25
+ # run AddCustomRequestId
26
+ # run EncryptToken if settings.token
27
+ # end
28
+ #
29
+ # # definitions shared by all operations (can be reloaded later)
30
+ # operation do |settings|
31
+ # type { :json }
32
+ # security { basic_auth "foo", "bar" }
33
+ # end
34
+ #
35
+ # # operation-specific definitions
36
+ # operation :find_cat do |settings|
37
+ # http_method :get
38
+ # path { "#{settings.url}/cats/find/#{id}" }
39
+ #
40
+ # query do
41
+ # option :id, type: Dry::Types["coercible.int"].constrained(gt: 0)
42
+ # end
43
+ #
44
+ # response 200, model: Cat
45
+ # response 400, raise: true
46
+ # response 422, raise: true do |body:|
47
+ # JSON.parse(body.first)
48
+ # end
49
+ # end
50
+ #
51
+ # # top-level DSL for operation
52
+ # scope :users do
53
+ # scope do # named `:[]` by default
54
+ # param :id, type: Dry::Types["strict.int"]
55
+ #
56
+ # def get
57
+ # operations[:find_users].call(id: id)
58
+ # end
59
+ # end
60
+ # end
61
+ # end
62
+ #
63
+ # # Initialize a client with a corresponding settings
64
+ # client = MyClient.new user: "andrew", token: "f982j23"
65
+ #
66
+ # # Use low-level DSL for searching a user
67
+ # client.operations[:find_user].call(id: 1)
68
+ #
69
+ # # Use top-level DSL for the same operation
70
+ # client.users[1].get
71
+ #
72
+ module Evil
73
+ class Client
74
+ require_relative "client/model"
75
+ require_relative "client/connection"
76
+ require_relative "client/middleware"
77
+ require_relative "client/operation"
78
+ require_relative "client/dsl"
79
+
80
+ extend DSL
81
+ include Dry::Initializer.define -> { param :operations }
82
+
83
+ # Builds a client instance with custom settings
84
+ def self.new(*settings)
85
+ super finalize(*settings)
86
+ end
87
+
88
+ private
89
+
90
+ def initialize(schema)
91
+ @operations = \
92
+ schema[:operations].each_with_object({}) do |(key, val), hash|
93
+ hash[key] = Operation.new val, schema[:connection]
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,35 @@
1
+ class Evil::Client
2
+ # @abstract Base class for a specific connection to remote uri
3
+ class Connection
4
+ REGISTRY = { net_http: "NetHTTP" }.freeze
5
+
6
+ extend Dry::Initializer::Mixin
7
+ param :base_uri
8
+
9
+ # Envokes a specific connection class
10
+ #
11
+ # @param [#to_sym] key
12
+ # @return [Class]
13
+ #
14
+ def self.[](name = nil)
15
+ key = (name || REGISTRY.keys.first).to_sym
16
+
17
+ klass = REGISTRY.fetch(key) do
18
+ fail ArgumentError.new "Connection '#{key}' is not registered." \
19
+ " Use the following keys: #{REGISTRY.keys}"
20
+ end
21
+
22
+ require_relative "connection/#{key}"
23
+ const_get klass
24
+ end
25
+
26
+ # @abstract Sends request to the server and returns rack-compatible response
27
+ #
28
+ # @param [Hash] env
29
+ # @return [Array]
30
+ #
31
+ def call(_env)
32
+ fail NotImplementedError
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ require "net/http"
2
+ require "net/https"
3
+
4
+ class Evil::Client
5
+ class Connection
6
+ # Net::HTTP based implementation of [Evil::Client::Connection]
7
+ class NetHTTP < Connection
8
+ # Sends a request to the remote uri,
9
+ # and returns rack-compatible response
10
+ #
11
+ # @param [Hash] env Middleware environment with keys:
12
+ # :http_method, :path, :query_string, :body_string, :headers
13
+ # @return [Array] Rack-compatible response [status, body, headers]
14
+ #
15
+ def call(env)
16
+ request = build_request(env)
17
+ Net::HTTP.start base_uri.host, base_uri.port, opts do |http|
18
+ handle http.request(request)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def opts
25
+ @opts ||= {}.tap { |hash| hash[:use_ssl] = base_uri.scheme == "https" }
26
+ end
27
+
28
+ def build_request(env)
29
+ type, path, query, body, headers = parse_env(env)
30
+
31
+ sender = build_sender(type)
32
+ uri = build_uri(path, query)
33
+
34
+ sender.new(uri).tap do |request|
35
+ request.body = body
36
+ headers.each { |key, value| request[key] = value }
37
+ end
38
+ end
39
+
40
+ def parse_env(env)
41
+ env.values_at :http_method, :path, :query_string, :body_string, :headers
42
+ end
43
+
44
+ def build_sender(type)
45
+ Net::HTTP.const_get type.capitalize
46
+ end
47
+
48
+ def build_uri(path, query)
49
+ base_uri.merge(path).tap { |uri| uri.query = query }
50
+ end
51
+
52
+ def handle(response)
53
+ [response.code.to_i, Hash(response.header), Array(response.body)]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,110 @@
1
+ class Evil::Client
2
+ # Defines a DSL to customize class-level settings of the specific client
3
+ module DSL
4
+ require_relative "dsl/operations"
5
+ require_relative "dsl/scope"
6
+
7
+ # Stack of default middleware before custom midleware and a connection
8
+ # This stack cannot be modified
9
+ DEFAULT_MIDDLEWARE = Middleware.new do
10
+ run Middleware::MergeSecurity
11
+ run Middleware::StringifyJson
12
+ run Middleware::StringifyQuery
13
+ run Middleware::NormalizeHeaders
14
+ end
15
+
16
+ # Helper to define params and options a for a client's constructor
17
+ #
18
+ # @example
19
+ # class MyClient < Evil::Client
20
+ # end
21
+ #
22
+ # MyClient.new "https://foo.com", user: "bar", token: "baz"
23
+ #
24
+ # @param [Proc] block
25
+ # @return [self]
26
+ #
27
+ def settings(&block)
28
+ return self unless block
29
+ schema[:settings] = Class.new { include Dry::Initializer.define(&block) }
30
+ self
31
+ end
32
+
33
+ # Helper to define base url of the server
34
+ #
35
+ # @param [#to_s] value
36
+ # @return [self]
37
+ #
38
+ def base_url(&block)
39
+ return self unless block
40
+ schema[:base_url] = block
41
+ self
42
+ end
43
+
44
+ # Helper specify a connection to be used by a client
45
+ #
46
+ # @param [#to_sym] type (nil)
47
+ # The specific type of connection. Uses NetHTTP by default.
48
+ # @return [self]
49
+ #
50
+ def connection(type = nil, &block)
51
+ schema[:connection] = Connection[type]
52
+ schema[:middleware] = Middleware.new(&block)
53
+ self
54
+ end
55
+
56
+ # Helper to declare operation, either default or specific
57
+ #
58
+ # @param [#to_sym] name (nil)
59
+ # @param [Proc] block
60
+ # @return [self]
61
+ #
62
+ def operation(name = nil, &block)
63
+ schema[:operations].register(name, &block)
64
+ self
65
+ end
66
+
67
+ # Helper to define scopes of the client's top-level DSL
68
+ #
69
+ # @param [#to_sym] name (:[])
70
+ # @param [Proc] block
71
+ # @return [self]
72
+ #
73
+ def scope(name = :[], &block)
74
+ klass = Class.new(Scope, &block)
75
+ define_method(name) do |*args, **options|
76
+ klass.new(*args, __scope__: self, **options)
77
+ end
78
+ self
79
+ end
80
+
81
+ # Takes constructor arguments and builds a final schema for the instance
82
+ #
83
+ # @param [Object] *args
84
+ # @return [Hash<Symbol, Object>]
85
+ #
86
+ def finalize(*args)
87
+ settings = schema[:settings].new(*args)
88
+ uri = URI(schema[:base_url].call(settings))
89
+ client = schema[:connection].new(uri)
90
+ middleware = schema[:middleware].finalize(settings)
91
+ stack = Middleware.prepend.(middleware.(Middleware.append.(client)))
92
+
93
+ { connection: stack, operations: schema[:operations].finalize(settings) }
94
+ end
95
+
96
+ private
97
+
98
+ BASE_URL = -> (_) { fail NotImplementedError.new "Base url not defined" }
99
+
100
+ def schema
101
+ @schema ||= {
102
+ settings: Class.new,
103
+ base_url: BASE_URL,
104
+ connection: Connection[nil],
105
+ middleware: Middleware.new,
106
+ operations: Operations.new
107
+ }
108
+ end
109
+ end
110
+ end