patchboard 0.4.0

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.
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: []