patchboard 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 96e98fdd770b6bfa97c132fc1f7c9edfc01937c2
4
+ data.tar.gz: 4f854ad38cafa53c9775a73d668dfc20d7072e69
5
+ SHA512:
6
+ metadata.gz: ac38663214aa9bb712080af92da4340a42128da75798b4408369d8dff343cd6b7e7896a80b4474705e9b16ce75a6f8c24a5e5643a1662dab493874c60a34c507
7
+ data.tar.gz: 82e03045526bcc3946fd9f5bbcdd66740f22a7f04a5fe462068df2124e5e91cc80fbca842e4fbc7b10db2fea70963b6776e8fe8a7068ca1a96b982fc3ecda456
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Matthew King
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # A Ruby client for Patchboard APIs
@@ -0,0 +1,107 @@
1
+ require_relative "response"
2
+
3
+ class Patchboard
4
+
5
+ class Action
6
+
7
+ attr_reader :method, :headers, :status
8
+
9
+ def initialize(patchboard, name, definition)
10
+ @name = name
11
+ @patchboard = patchboard
12
+ @api = @patchboard.api
13
+ @schema_manager = @patchboard.schema_manager
14
+ @method = definition[:method]
15
+
16
+
17
+ @headers = {
18
+ }
19
+
20
+ request, response = definition[:request], definition[:response]
21
+
22
+ if request
23
+ @auth_scheme = request[:authorization]
24
+ if request[:type]
25
+ @headers["Content-Type"] = request[:type]
26
+ @request_schema = @schema_manager.find :media_type => request[:type]
27
+ end
28
+ end
29
+
30
+ if response && response[:type]
31
+ @headers["Accept"] = response[:type]
32
+ @response_schema = @schema_manager.find :media_type => response[:type]
33
+ end
34
+ @status = response[:status] || 200
35
+ end
36
+
37
+ def http
38
+ @http ||= @patchboard.http
39
+ end
40
+
41
+ def request(resource, url, *args)
42
+ options = self.prepare_request(resource, url, *args)
43
+ raw = self.http.request @method, url, options.merge(:response => :object)
44
+ response = Response.new(raw)
45
+ if response.status != @status
46
+ raise "Unexpected response status: #{response.status}"
47
+ end
48
+ out = @api.decorate(resource.context, @response_schema, response.data)
49
+ out.response = response
50
+ out
51
+ end
52
+
53
+ def prepare_request(resource, url, *args)
54
+ context = resource.context
55
+ headers = {}.merge(@headers)
56
+ options = {
57
+ :url => url, :method => @method, :headers => headers
58
+ }
59
+
60
+ if @auth_scheme && context.respond_to?(:authorizer)
61
+ credential = context.authorizer(@auth_scheme, resource, @name)
62
+ headers["Authorization"] = "#{@auth_scheme} #{credential}"
63
+ end
64
+
65
+ input_options = self.process_args(args)
66
+ if input_options[:body]
67
+ options[:body] = input_options[:body]
68
+ end
69
+ # This code looks forward to the time when we have figured out
70
+ # how we want Patchboard clients to take extra arguments for
71
+ # requests. Leaving it here now to show why process_args returns a Hash,
72
+ # not just the body.
73
+ if input_options[:headers]
74
+ options[:headers].merge!(input_options[:headers])
75
+ end
76
+ options
77
+ end
78
+
79
+ def process_args(args)
80
+ options = {}
81
+ signature = args.map {|arg| arg.class.to_s }.join(".")
82
+ if @request_schema
83
+ case signature
84
+ when "String"
85
+ options[:body] = args[0]
86
+ when "Hash", "Array"
87
+ options[:body] = args[0].to_json
88
+ else
89
+ raise "Invalid arguments for action: request content is required"
90
+ end
91
+ else
92
+ case signature
93
+ when ""
94
+ else
95
+ raise "Invalid arguments for action"
96
+ end
97
+ end
98
+ options
99
+ end
100
+
101
+
102
+
103
+
104
+ end
105
+
106
+ end
107
+
@@ -0,0 +1,141 @@
1
+
2
+ class Patchboard
3
+
4
+ class API
5
+ attr_reader :mappings, :resources, :schemas, :service_url
6
+
7
+ def initialize(definition)
8
+
9
+ @service_url = definition[:service_url]
10
+ @resources = Hashie::Mash.new definition[:resources]
11
+ @resources.each do |name, definition|
12
+ definition.name = name
13
+ end
14
+ @schemas = definition[:schemas]
15
+
16
+ @mappings = {}
17
+ definition[:mappings].each do |name, mapping|
18
+ @mappings[name] = Mapping.new(self, name, mapping)
19
+ end
20
+ end
21
+
22
+ def find_mapping(schema)
23
+ # TODO: stitch this into the actual schemas.
24
+ # ex: schema.mapping
25
+ if id = (schema[:id] || schema[:$ref])
26
+ name = id.split("#").last
27
+ @mappings[name.to_sym]
28
+ end
29
+ end
30
+
31
+ def decorate(context, schema, data)
32
+ unless schema
33
+ return Hashie::Mash.new(data)
34
+ end
35
+
36
+ if mapping = self.find_mapping(schema)
37
+ # when we have a resource class, instantiate it using the input data.
38
+ data = mapping.klass.new context, data
39
+ else
40
+ # Otherwise traverse the schema in search of subschemas that have
41
+ # resource classes available.
42
+ case schema[:type]
43
+ when "array"
44
+ # TODO: handle the case where schema.items is an array, which
45
+ # signifies a tuple. schema.additionalItems then becomes important.
46
+ data.map! do |item|
47
+ self.decorate(context, schema[:items], item)
48
+ end
49
+
50
+ when "object"
51
+ if schema[:properties]
52
+ schema[:properties].each do |key, prop_schema|
53
+ if value = data[key]
54
+ data[key] = self.decorate(context, prop_schema, value)
55
+ end
56
+ end
57
+ end
58
+ # TODO: handle schema.patternProperties
59
+ # TODO: consider alternative to iterating over all keys.
60
+ if schema[:additionalProperties]
61
+ data.each do |key, value|
62
+ next if schema[:properties] && schema[:properties][key]
63
+ data[key] = self.decorate(context, schema[:additionalProperties], value)
64
+ end
65
+ end
66
+ data = Hashie::Mash.new data
67
+ end
68
+ end
69
+ data
70
+ end
71
+
72
+
73
+ end
74
+
75
+ class Mapping
76
+
77
+ attr_accessor :klass
78
+ attr_reader :name, :resource, :url, :path, :template, :query
79
+
80
+ def initialize(api, name, definition)
81
+ @api = api
82
+ @name = name
83
+ @definition = definition
84
+ @resource = @definition[:resource]
85
+ @query = @definition[:query]
86
+ @url = @definition[:url]
87
+ @path = @definition[:path]
88
+ @template = @definition[:template]
89
+
90
+ unless (resource_name = @definition[:resource])
91
+ raise "Mapping does not specify 'resource'"
92
+ end
93
+
94
+ unless (@resource = @api.resources[resource_name.to_sym])
95
+ raise "Mapping specifies a resource that is not defined"
96
+ end
97
+
98
+ unless (@definition[:url] || @definition[:path] || @definition[:template])
99
+ raise "Mapping is missing any form of URL specification"
100
+ end
101
+
102
+ end
103
+
104
+ def generate_url(params={})
105
+ if @url
106
+ base = @url
107
+ elsif params[:url]
108
+ base = params[:url]
109
+ elsif @path
110
+ if @api.service_url
111
+ base = [@api.service_url, @path].join("/")
112
+ else
113
+ raise "Tried to generate url from path, but API did not define service_url"
114
+ end
115
+ elsif @template
116
+ raise "Template mappings are not yet implemented in the client"
117
+ end
118
+
119
+ if @query
120
+ parts = []
121
+ keys = @query.keys.sort()
122
+ # TODO check query schema
123
+ keys.each do |key|
124
+ if string = (params[key.to_s] || params[key.to_sym])
125
+ parts << "#{URI.escape(key.to_s)}=#{URI.escape(string)}"
126
+ end
127
+ end
128
+ if parts.size > 0
129
+ query_string = "?#{parts.join("&")}"
130
+ else
131
+ query_string = ""
132
+ end
133
+ [base, query_string].join()
134
+ else
135
+ base
136
+ end
137
+ end
138
+
139
+ end
140
+
141
+ end
@@ -0,0 +1,49 @@
1
+ class Patchboard
2
+
3
+ class Endpoints
4
+
5
+ def initialize(context, api, klasses)
6
+ @context = context
7
+ @api = api
8
+ @klasses = klasses
9
+
10
+ @api.mappings.each do |name, mapping|
11
+ if klass = @klasses[name]
12
+ if mapping.template || mapping.query
13
+ # A mapping with a template or query property requires
14
+ # additional input before it can express a usable URL.
15
+ # Thus the endpoint method takes parameters and instantiates
16
+ # a resource of the correct class.
17
+
18
+ define_singleton_method name do |params={}|
19
+ if params.is_a? String
20
+ url = params
21
+ else
22
+ url = mapping.generate_url(params)
23
+ end
24
+ klass.new(context, {:url => url})
25
+ end
26
+ elsif mapping.path
27
+ # When a mapping has the 'path' property, all that is needed to
28
+ # create a usable resource is the full URL. Thus this endpoint
29
+ # method returns an instantiated resource directly.
30
+ define_singleton_method name do
31
+ klass.new(context, :url => mapping.generate_url())
32
+ end
33
+ elsif mapping.url
34
+ define_singleton_method name do
35
+ klass.new(context, :url => mapping.url)
36
+ end
37
+ else
38
+ raise "Mapping '#{name}' is invalid"
39
+ end
40
+ else
41
+ raise "No resource class for mapping '#{name}'"
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ end
48
+
49
+
@@ -0,0 +1,111 @@
1
+
2
+ class Patchboard
3
+
4
+ class Resource
5
+
6
+ def self.assemble(patchboard, definition, schema, mapping)
7
+
8
+ define_singleton_method(:api) do
9
+ patchboard.api
10
+ end
11
+
12
+ define_singleton_method(:mapping) do
13
+ mapping
14
+ end
15
+
16
+ define_singleton_method(:schema) do
17
+ schema
18
+ end
19
+
20
+ if schema && schema[:properties]
21
+ schema[:properties].each do |name, definition|
22
+ define_method name do
23
+ @attributes[name]
24
+ end
25
+ end
26
+ end
27
+
28
+ if schema && schema[:additionalProperties] != false
29
+ define_method :method_missing do |name, *args, &block|
30
+ if args.size == 0
31
+ @attributes[name.to_sym]
32
+ else
33
+ super(name, *args, &block)
34
+ end
35
+ end
36
+ end
37
+
38
+ define_singleton_method :generate_url do |params|
39
+ mapping.generate_url(params)
40
+ end
41
+
42
+ definition[:actions].each do |name, action|
43
+ action = Action.new(patchboard, name, action)
44
+
45
+ define_method name do |*args|
46
+ action.request self, @url, *args
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.decorate(instance, attributes)
52
+ # TODO: non destructive decoration
53
+ # TODO: add some sort of validation for the input attributes.
54
+ # Hey, we have a JSON Schema, why not use it?
55
+ if self.schema && (properties = self.schema[:properties])
56
+ context = instance.context
57
+ properties.each do |key, sub_schema|
58
+ next unless (value = attributes[key])
59
+
60
+ if mapping = self.api.find_mapping(sub_schema)
61
+ if mapping.query
62
+ # TODO: find a way to define this at runtime, not once
63
+ # for every instance.
64
+ instance.define_singleton_method key do |params|
65
+ params[:url] = value[:url]
66
+ url = mapping.generate_url(params)
67
+ mapping.klass.new context, :url => url
68
+ end
69
+ else
70
+ attributes[key] = mapping.klass.new context, value
71
+ end
72
+ else
73
+ attributes[key] = self.api.decorate(context, sub_schema, value)
74
+ end
75
+
76
+ end
77
+ end
78
+ attributes
79
+ end
80
+
81
+ attr_accessor :response
82
+ attr_reader :url, :context, :attributes
83
+
84
+ def initialize(context, attributes={})
85
+ @context = context
86
+ @attributes = self.class.decorate self, Hashie::Mash.new(attributes)
87
+ @url = @attributes[:url]
88
+ end
89
+
90
+ def inspect
91
+ id = "%x" % (self.object_id << 1)
92
+ %Q{
93
+ #<#{self.class}:0x#{id}
94
+ @url="#{@url}" @context=#{@context}>
95
+ }.strip
96
+ end
97
+
98
+ def [](key)
99
+ @attributes[key]
100
+ end
101
+
102
+ def []=(key, value)
103
+ @attributes[key] = value
104
+ end
105
+
106
+
107
+ def curl
108
+ end
109
+ end
110
+
111
+ end
@@ -0,0 +1,29 @@
1
+ class Patchboard
2
+ class Response
3
+
4
+ attr_accessor :resource
5
+ attr_reader :raw, :data
6
+ def initialize(raw)
7
+ @raw = raw
8
+ if @raw.headers["Content-Type"]
9
+ if @raw.headers["Content-Type"] =~ %r{application/.*json}
10
+ @data = JSON.parse @raw.body, :symbolize_names => true
11
+ end
12
+ end
13
+ end
14
+
15
+ def method_missing(name, *args, &block)
16
+ if @raw.respond_to? name
17
+ @raw.send(name, *args, &block)
18
+ else
19
+ super
20
+ end
21
+ end
22
+
23
+ def respond_to?(*args)
24
+ @raw.respond_to?(*args) || super
25
+ end
26
+
27
+ end
28
+ end
29
+
@@ -0,0 +1,50 @@
1
+ require "pp"
2
+ class Patchboard
3
+
4
+ class SchemaManager
5
+
6
+ def initialize(schemas)
7
+ @schemas = schemas
8
+ @media_types = {}
9
+ @ids = {}
10
+ @names = {}
11
+
12
+ @schemas.each do |schema|
13
+ # TODO error checking for missing id
14
+ base_id = schema[:id].chomp("#")
15
+ schema[:definitions].each do |name, definition|
16
+ # `definitions` is the conventional place to put schemas,
17
+ # so we'll define fragment IDs by default where they are
18
+ # not explicitly specified.
19
+ id = definition[:id] || [base_id, name].join("#")
20
+ self.register_schema(id, definition)
21
+ end
22
+ end
23
+ end
24
+
25
+ def register_schema(id, schema)
26
+ schema[:id] = id
27
+ @ids[id] = schema
28
+ name = id.split("#")[1].to_sym
29
+ @names[name] = schema
30
+ if type = schema[:mediaType]
31
+ @media_types[type] = schema
32
+ end
33
+ end
34
+
35
+ def find(options)
36
+ if type = options[:mediaType] || options[:media_type]
37
+ @media_types[type]
38
+ elsif ref = options[:ref]
39
+ @ids[ref]
40
+ elsif name = options[:name]
41
+ @names[name]
42
+ else
43
+ raise "Unusable argument to find: #{options}"
44
+ end
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+
@@ -0,0 +1,12 @@
1
+ class Patchboard
2
+ module Util
3
+ module_function
4
+
5
+ def camel_case(arg)
6
+ arg.to_s.split('_').map do |word|
7
+ "#{word.slice(/^\w/).upcase}#{word.slice(/^\w(\w+)/, 1)}"
8
+ end.join
9
+ end
10
+
11
+ end
12
+ end
data/lib/patchboard.rb ADDED
@@ -0,0 +1,130 @@
1
+ # Allows you to determine which methods are defined on a specific class
2
+ # or module
3
+ class Module
4
+ def local_methods
5
+ self.methods.select { |m| self.method(m).owner == self }
6
+ end
7
+
8
+ def local_instance_methods
9
+ self.instance_methods.select { |m| self.instance_method(m).owner == self }
10
+ end
11
+ end
12
+
13
+ gem "http"
14
+ gem "json"
15
+ gem "hashie"
16
+
17
+ require "http"
18
+ require "json"
19
+ require "hashie"
20
+
21
+ require_relative "patchboard/api"
22
+ require_relative "patchboard/util"
23
+ require_relative "patchboard/resource"
24
+ require_relative "patchboard/endpoints"
25
+ require_relative "patchboard/action"
26
+ require_relative "patchboard/schema_manager"
27
+
28
+ class Patchboard
29
+
30
+ module Resources
31
+ # This module exists to provide a default namespace for the classes
32
+ # generated when reflecting on the API. If Patchboard is instantiated
33
+ # with the option :namespace => SomeModule, that module will be used
34
+ # instead.
35
+ end
36
+
37
+
38
+ def self.http
39
+ @http ||= HTTP.with_headers "User-Agent" => "patchboard-rb v0.1.0"
40
+ end
41
+
42
+ def self.discover(url, options={}, &block)
43
+ begin
44
+ response = self.http.request "GET", url,
45
+ :response => :object,
46
+ :headers => {
47
+ "Accept" => "application/json"
48
+ }
49
+ data = JSON.parse(response.body, :symbolize_names => true)
50
+ self.new(data, options, &block)
51
+ rescue JSON::ParserError => error
52
+ raise "Unparseable API description: #{error}"
53
+ rescue Errno::ECONNREFUSED => error
54
+ raise "Problem discovering API: #{error}"
55
+ end
56
+ end
57
+
58
+ attr_reader :api, :resources, :http, :schema_manager, :context
59
+
60
+ def initialize(api, options={}, &block)
61
+ @api = API.new(api)
62
+ @options = options
63
+ @context_creator = block
64
+
65
+ if options[:namespace]
66
+ if options[:namespace].is_a? Module
67
+ @namespace = options[:namespace]
68
+ else
69
+ raise "Namespace must be a Module"
70
+ end
71
+ end
72
+
73
+ @endpoint_classes = {}
74
+
75
+ @schema_manager = SchemaManager.new(@api.schemas)
76
+
77
+ @http = self.class.http
78
+ self.create_classes()
79
+ client = self.spawn({})
80
+ @resources = client.resources
81
+ @context = client.context
82
+ end
83
+
84
+ def spawn(context=nil)
85
+ context ||= @context_creator.call
86
+ self.class::Client.new(context, @api, @endpoint_classes)
87
+ end
88
+
89
+ class Client
90
+
91
+ attr_reader :resources, :context
92
+ def initialize(context, api, klasses)
93
+ @context = context
94
+ @resources = Endpoints.new @context, api, klasses
95
+ end
96
+
97
+ end
98
+
99
+ def create_classes
100
+ klasses = {}
101
+ @api.mappings.each do |name, mapping|
102
+ resource_name = mapping.resource.name.to_sym
103
+ schema = @schema_manager.find :name => resource_name
104
+
105
+ klass = klasses[name] ||= begin
106
+ resource_def = mapping.resource
107
+ self.create_class(name, resource_def, schema, mapping)
108
+ end
109
+ @endpoint_classes[name] = klass
110
+ end
111
+ end
112
+
113
+ def create_class(resource_name, definition, schema, mapping)
114
+ patchboard = self
115
+
116
+ mapping.klass = klass = Class.new(self.class::Resource) do |klass|
117
+ self.assemble(patchboard, definition, schema, mapping)
118
+ end
119
+
120
+ if @namespace
121
+ @namespace.const_set Util.camel_case(resource_name).to_sym, klass
122
+ else
123
+ Patchboard::Resources.const_set Util.camel_case(resource_name).to_sym, klass
124
+ end
125
+ klass
126
+ end
127
+
128
+
129
+ end
130
+
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: patchboard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew King
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-03-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.8.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.8.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: http
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.5.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.5.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: hashie
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.0.5
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.0.5
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest-reporters
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.0.2
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.0.2
69
+ description:
70
+ email: automatthew@gmail.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - LICENSE
76
+ - README.md
77
+ - lib/patchboard.rb
78
+ - lib/patchboard/action.rb
79
+ - lib/patchboard/api.rb
80
+ - lib/patchboard/endpoints.rb
81
+ - lib/patchboard/resource.rb
82
+ - lib/patchboard/response.rb
83
+ - lib/patchboard/schema_manager.rb
84
+ - lib/patchboard/util.rb
85
+ homepage: https://github.com/pandastrike/patchboard-rb
86
+ licenses:
87
+ - MIT
88
+ metadata: {}
89
+ post_install_message:
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubyforge_project:
105
+ rubygems_version: 2.2.0
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Ruby client for Patchboard APIs
109
+ test_files: []