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