Roman2K-web-service 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2008-2009 Roman Le Négrate
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.mdown ADDED
@@ -0,0 +1,147 @@
1
+ # WebService
2
+
3
+ REST client. Alternative to [`ActiveResource`](http://api.rubyonrails.org/classes/ActiveResource/Base.html).
4
+
5
+ ## Installation
6
+
7
+ Since `WebService` depends on the gem `class-inheritable-attributes` which cannot be installed directly by RubyGems because the account name has to be prepended to the actual gem name, please refer to [`README`](http://github.com/Roman2K/class-inheritable-attributes/tree) for instructions on how to install that dependency manually.
8
+
9
+ Then, install the `web-service` gem itself:
10
+
11
+ $ gem install Roman2K-web-service -s http://gems.github.com/
12
+
13
+ ## Usage example
14
+
15
+ Let's say you have a Rails app exposing a RESTful API for articles and their comments:
16
+
17
+ map.resources :articles, :has_many => :comments
18
+
19
+ When building the corresponding client for this API to consume these resources, the code could look like:
20
+
21
+ require 'web_service'
22
+
23
+ class BasicResource < WebService::Resource
24
+ # Mandatory:
25
+ self.site = WebService::Site.new("http://login:password@host:port")
26
+
27
+ # Optional (and possibly in subclasses):
28
+ self.site = WebService::Site.new("https://secure.site")
29
+ self.credentials = ["login", "password"]
30
+ self.element_name = "custom_element_name"
31
+ self.singleton = true # /foo instead of /foos/1
32
+ end
33
+
34
+ class Article < BasicResource
35
+ has_many :comments
36
+ end
37
+
38
+ class Comment < BasicResource
39
+ belongs_to :article
40
+ end
41
+
42
+ ### Attributes
43
+
44
+ article = Article.new(:title => "First article")
45
+ article.title # => "First article"
46
+ article.title? # => true
47
+ article.title = " "
48
+ article.title? # => false
49
+ article.body # raises NoMethodError
50
+ article.body? # raises NoMethodError
51
+
52
+ ### CRUD
53
+
54
+ article = Article.new(:title => "First article")
55
+ begin
56
+ article.save
57
+ # => POST /articles
58
+ rescue WebService::ResourceInvalid
59
+ article.body = "Like the title says."
60
+ article.save
61
+ # => POST /articles
62
+ end
63
+
64
+ article.body += " Updated."
65
+ article.save
66
+ # => PUT /articles/1
67
+
68
+ article.destroy
69
+ # => DELETE /articles/1
70
+
71
+ ### Fetching
72
+
73
+ article.id # => 1
74
+ Article[1] == article # => true
75
+ Article[99] # => nil
76
+ Article.find(99) # raises WebService::ResourceNotFound
77
+
78
+ Article.all # => [Article[1], ..., Article[42]]
79
+ Article.first # => Article[1]
80
+ Article.last # => Article[42]
81
+
82
+ comment = Comment.new(:article_id => 1)
83
+ comment.article # => Article[1]
84
+ # => GET /articles/1
85
+ comment.article # => Article[1]
86
+ # => (cache hit)
87
+
88
+ ### Nested resources (associations)
89
+
90
+ Use `article.comments` like you would `Comment`, only scoped to `article`.
91
+
92
+ article.comments.all
93
+ # => GET /articles/1/comments
94
+
95
+ Associating a comment with a article:
96
+
97
+ article.comments.create(:body => "First comment.")
98
+ # => POST /articles/1/comments
99
+
100
+ The same in a more formal way:
101
+
102
+ comment = Comment.new(:body => "First comment.")
103
+ comment.article = article
104
+ comment.article == article # => true
105
+ comment.article_id # => 1
106
+ comment.save
107
+
108
+ ### Arbitrary actions
109
+
110
+ All of resource classes, resource instances and association collections respond to all four HTTP verbs (`GET`, `POST`, `PUT` and `DELETE`) to issue requests to arbitrary actions.
111
+
112
+ **Note:** these methods do not return resource instances, but rather plain unserialized objects, as returned by the server:
113
+
114
+ Article.delete(:unpopular) # => [{'id' => 5, ...}, ...]
115
+ # => DELETE /articles/unpopular
116
+
117
+ Article.get(:popular, :page => 2) # => [{'id' => 1, ...}, ...]
118
+ # => GET /articles/popular?page=2
119
+
120
+ article.put(:publish, :at => 1.hour.from_now)
121
+ # => PUT /articles/1/publish {:at => 1.hour.from_now}
122
+
123
+ article.comments.put(:spam, 5)
124
+ # => PUT /articles/1/comments/5/spam
125
+
126
+ article.comments.get("/spam")
127
+ # => PUT /articles/1/comments/spam
128
+
129
+ ## Formats
130
+
131
+ * Requests are sent **JSON**-encoded.
132
+ * Responses can be returned either **JSON**- or **XML**-encoded.
133
+
134
+ ## Status
135
+
136
+ * Fetched resources should be cached in memory, so that the following returns true:
137
+
138
+ comments = article.comments
139
+ comments.all.object_id == comments.all.object_id
140
+
141
+ * There are **tests** left to be written. Run `rake coverage` to find out what still has to be tested. Note that for RCov to work with Ruby 1.8.7, you might need to install the latest `rcov` gem from [GitHub](http://github.com/spicycode/rcov):
142
+
143
+ gem install spicycode-rcov -s http://gems.github.com
144
+
145
+ ## Credits
146
+
147
+ Written by [Roman Le Négrate](http://roman.flucti.com) ([contact](mailto:roman.lenegrate@gmail.com)). Released under the MIT license: see the `LICENSE` file.
data/Rakefile ADDED
@@ -0,0 +1,39 @@
1
+ require "echoe"
2
+
3
+ Echoe.new('web-service', '0.1.1') do |p|
4
+ p.description = "REST client; an alternative to ActiveResource"
5
+ p.url = "https://github.com/Roman2K/web-service"
6
+ p.author = "Roman Le Négrate"
7
+ p.email = "roman.lenegrate@gmail.com"
8
+ p.ignore_pattern = "*.gemspec"
9
+ p.dependencies = ["activesupport >=2.2.2", "class-inheritable-attributes"]
10
+ p.development_dependencies = ["mocha", "test-unit-ext"]
11
+ p.rdoc_options = %w(--main README.mdown --inline-source --line-numbers --charset UTF-8)
12
+ end
13
+
14
+ # Weirdly enough, Echoe's default `test' task doesn't get overridden by the one
15
+ # defined below. Even weirder, `rake test' runs both tasks! The same applies to
16
+ # `coverage'. Dirty workaround:
17
+ %w(test coverage).each do |name|
18
+ Rake.application.instance_eval("@tasks").delete(name)
19
+ end
20
+
21
+ task :default => :test
22
+
23
+ desc "Run the test suite"
24
+ task :test do
25
+ all_test_files.each { |test| require test }
26
+ end
27
+
28
+ desc "Measure test coverage"
29
+ COVERAGE_OUT = "doc/coverage"
30
+ COVERAGE_CODE = %w(lib)
31
+ task :coverage do
32
+ rm_rf COVERAGE_OUT; mkdir_p COVERAGE_OUT
33
+ sh %(rcov -I.:lib:test -x '^(?!#{COVERAGE_CODE * '|'})/' --text-summary --sort coverage --no-validator-links -o #{COVERAGE_OUT} #{all_test_files * ' '})
34
+ system %(open #{COVERAGE_OUT}/index.html)
35
+ end
36
+
37
+ def all_test_files
38
+ Dir['test/**/*_test.rb'].sort
39
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_support'
2
+ require 'class_inheritable_attributes'
3
+ require 'ostruct'
4
+ require 'cgi'
5
+ require 'uri'
6
+ require 'net/https'
7
+ require 'web_service/core_ext'
8
+
9
+ module WebService
10
+ autoload :Site, 'web_service/site'
11
+ autoload :Resource, 'web_service/resource'
12
+ autoload :AttributeAccessors, 'web_service/attribute_accessors'
13
+ autoload :RemoteCollection, 'web_service/remote_collection'
14
+ autoload :ResponseHandling, 'web_service/response_handling'
15
+ autoload :NamedRequestMethods, 'web_service/named_request_methods'
16
+ autoload :CRUDOperations, 'web_service/crud_operations'
17
+
18
+ class Error < StandardError
19
+ end
20
+ class ResourceNotSaved < Error
21
+ end
22
+ class NotResourceClass < Error
23
+ end
24
+
25
+ class << self
26
+ def logger
27
+ @logger ||= begin
28
+ require 'logger'
29
+ Logger.new(STDOUT)
30
+ end
31
+ end
32
+ attr_writer :logger
33
+ end
34
+
35
+ include ResponseHandling::Exceptions
36
+ end
@@ -0,0 +1,147 @@
1
+ module WebService
2
+ module AttributeAccessors
3
+ def initialize(attributes={})
4
+ self.attributes = attributes
5
+ end
6
+
7
+ def attributes
8
+ attribute_registry.dup
9
+ end
10
+
11
+ def attributes=(attributes)
12
+ # Resolve single-key hash
13
+ attributes = attributes[self.class.element_name] if attributes.size == 1 && attributes[self.class.element_name]
14
+
15
+ # Clear previous attributes
16
+ attribute_registry.clear
17
+
18
+ # Assign the ID first, so that it can be used while assigning the rest of the attributes
19
+ if id_key = [:id, "id"].find { |key| attributes.include? key }
20
+ self.id = attributes.delete(id_key)
21
+ end
22
+ attributes.each { |name, value| send("#{name}=", value) }
23
+ end
24
+
25
+ # TODO handle the argument for whether to include singleton methods or not
26
+ def methods
27
+ super | attribute_accessor_methods.to_a
28
+ end
29
+
30
+ def respond_to?(method, include_private=false)
31
+ super || attribute_accessor_methods.include?(MethodList::TO_METHOD_NAME[method])
32
+ end
33
+
34
+ def read_attribute(attr_name)
35
+ if (id = attribute_registry["#{attr_name}_id"])
36
+ association_registry[attr_name.to_s] ||= resource_class_for(attr_name).find(id)
37
+ else
38
+ attribute_registry[attr_name.to_s] || association_registry[attr_name.to_s]
39
+ end
40
+ end
41
+
42
+ def attribute_readable?(attr_name)
43
+ attribute_registry.key?(attr_name) || association_registry.key?(attr_name) || attribute_registry.key?("#{attr_name}_id")
44
+ end
45
+
46
+ def write_attribute(attr_name, value)
47
+ attr_name = attr_name.to_s
48
+ resolve_record_descriptor = lambda { |descriptor|
49
+ if Hash === descriptor && descriptor.size == 1
50
+ if attributes = descriptor[attr_name]
51
+ attributes
52
+ elsif klass = resource_class_for?(descriptor.keys.first)
53
+ klass.new(descriptor.values.first)
54
+ end
55
+ end
56
+ }
57
+ value = resolve_record_descriptor[value] || (value.kind_of?(Array) and value.map { |elt| resolve_record_descriptor[elt] || elt }) || value
58
+ association_registry[$`] &&= nil if attr_name =~ /_id$/
59
+ if value.nil?
60
+ attribute_registry[attr_name] = nil
61
+ attribute_registry["#{attr_name}_id"] &&= nil unless attr_name =~ /_id$/
62
+ association_registry[attr_name] &&= nil
63
+ elsif resource_class?(value.class)
64
+ value.saved? or raise ResourceNotSaved, "resource must have an ID in order to be associated to another resource"
65
+ attribute_registry["#{attr_name}_id"], association_registry[attr_name] = value.id, value
66
+ elsif Hash === value and id = value["id"] and klass = resource_class_for?(attr_name)
67
+ attribute_registry["#{attr_name}_id"], association_registry[attr_name] = id, klass.new(value)
68
+ else
69
+ attribute_registry[attr_name] = value
70
+ end
71
+ end
72
+
73
+ def attribute_set?(attr_name)
74
+ !attribute_registry[attr_name.to_s].blank? || !attribute_registry["#{attr_name}_id"].blank?
75
+ end
76
+
77
+ private
78
+
79
+ def attribute_registry
80
+ @attributes ||= {}
81
+ end
82
+
83
+ def association_registry
84
+ @associations ||= {}
85
+ end
86
+
87
+ def method_missing(method_id, *args)
88
+ method_name = method_id.to_s
89
+ case
90
+ when method_name =~ /=$/
91
+ write_attribute($`, *args)
92
+ when attribute_readable?(method_name)
93
+ read_attribute(method_name, *args)
94
+ when method_name =~ /\?$/
95
+ super unless respond_to?($`)
96
+ attribute_set?($`, *args)
97
+ else
98
+ super
99
+ end
100
+ end
101
+
102
+ def attribute_accessor_methods
103
+ @attribute_accessor_methods ||= MethodList.new([attribute_registry, association_registry])
104
+ end
105
+
106
+ def resource_class_for(association_name)
107
+ klass = association_name.to_s.camelize.constantize
108
+ unless resource_class?(klass)
109
+ raise NotResourceClass, "class #{klass} found for association `#{association_name}' is not a resource class"
110
+ end
111
+ klass
112
+ end
113
+
114
+ def resource_class_for?(association_name)
115
+ resource_class_for(association_name)
116
+ rescue NameError
117
+ raise unless /uninitialized constant/ =~ $!
118
+ rescue NotResourceClass
119
+ raise unless /for association `#{association_name}'/ =~ $!
120
+ end
121
+
122
+ def resource_class?(klass)
123
+ Class === klass && klass.respond_to?(:find) && klass.public_method_defined?(:saved?)
124
+ end
125
+ end
126
+
127
+ class MethodList
128
+ TO_METHOD_NAME = (Kernel.methods.first.kind_of?(String) ? :to_s : :to_sym).to_proc
129
+ METHOD_SUFFIXES = ["", "=", "?"]
130
+
131
+ include Enumerable
132
+
133
+ def initialize(registries)
134
+ @registries = registries
135
+ end
136
+
137
+ def each
138
+ METHOD_SUFFIXES.each do |suffix|
139
+ @registries.each do |registry|
140
+ registry.each_key do |attr_name|
141
+ yield TO_METHOD_NAME["#{attr_name}#{suffix}"]
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,36 @@
1
+ Object.class_eval do
2
+ undef :id if method_defined?(:id)
3
+ end
4
+
5
+ Array.class_eval do
6
+ alias_method :index_without_block_form, :index
7
+ def index(*args, &block)
8
+ if args.empty? && block
9
+ each_with_index { |obj, idx| return idx if block.call(obj) }; nil
10
+ elsif args.size == 1
11
+ index_without_block_form(*args, &block)
12
+ else
13
+ raise ArgumentError, "wrong number of arguments (%d for 1)" % args.size
14
+ end
15
+ end
16
+ end if Array.instance_method(:index).arity == 1
17
+
18
+ def URI(object)
19
+ URI === object ? object : URI.parse(object.to_s)
20
+ end
21
+
22
+ URI.class_eval do
23
+ def obfuscate
24
+ returning(dup) do |obfuscated|
25
+ obfuscated.user &&= '***'
26
+ obfuscated.password &&= '***'
27
+ end
28
+ end
29
+ end
30
+
31
+ class << CGI
32
+ alias_method :old_escape, :escape
33
+ def escape(string)
34
+ old_escape(string).gsub(/\./, '%' + '.'.unpack('H2')[0])
35
+ end
36
+ end
@@ -0,0 +1,114 @@
1
+ module WebService
2
+ module CRUDOperations
3
+ include ResponseHandling::Exceptions
4
+ include Enumerable
5
+
6
+ delegate :each, :to => :all
7
+
8
+ def all(*args)
9
+ return @cache if @cache
10
+ expect(request(:get, *args), Net::HTTPOK) do |data|
11
+ instantiate_several_from_http_response_data(data)
12
+ end
13
+ end
14
+
15
+ def cache=(collection)
16
+ @cache = collection.map { |res| instantiate_resource(res) }
17
+ end
18
+
19
+ def flush_cache
20
+ @cache = nil
21
+ self
22
+ end
23
+
24
+ def first(*args)
25
+ all(*args).first
26
+ end
27
+
28
+ def last(*args)
29
+ all(*args).last
30
+ end
31
+
32
+ def find(id, *args)
33
+ expect(request(:get, id, *args), Net::HTTPOK) do |data|
34
+ instantiate_single_from_http_response_data(data)
35
+ end
36
+ end
37
+
38
+ def [](id)
39
+ find(id)
40
+ rescue ResourceNotFound
41
+ nil
42
+ end
43
+
44
+ def build(attributes={})
45
+ default_attributes = respond_to?(:implicit_attributes) ? implicit_attributes : {}
46
+ instantiate_resource(default_attributes.stringify_keys.merge(attributes.stringify_keys))
47
+ end
48
+
49
+ def create(attributes={})
50
+ expect(request(:post, body_for_create_or_update(attributes)), Net::HTTPCreated, Net::HTTPAccepted) do |data|
51
+ instantiate_single_from_http_response_data(data)
52
+ end
53
+ end
54
+
55
+ def update(id, attributes={})
56
+ expect(request(:put, id, body_for_create_or_update(attributes)), Net::HTTPOK, Net::HTTPAccepted) do |data|
57
+ instantiate_single_from_http_response_data(data)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def element_name
64
+ @element_name ||= respond_to?(:resource_class) ? resource_class.element_name : super
65
+ end
66
+
67
+ def expect(response, *types)
68
+ case response
69
+ when *types
70
+ yield response.data
71
+ else
72
+ raise ConnectionError.new(response, "unexpected response")
73
+ end
74
+ end
75
+
76
+ def body_for_create_or_update(attributes)
77
+ {element_name => build(attributes).attributes}
78
+ end
79
+
80
+ def instantiate_single_from_http_response_data(data)
81
+ return nil if data.to_s.blank?
82
+
83
+ unless data.respond_to?(:key?) && data.respond_to?(:[])
84
+ raise ArgumentError, "wrong data type for marshalled resource: #{data.class} (expected a Hash)"
85
+ end
86
+ unless data.key?(element_name)
87
+ raise ArgumentError, "wrong format for marshalled resource: expected a Hash with #{element_name.inspect} as sole key"
88
+ end
89
+ instantiate_resource(data[element_name])
90
+ end
91
+
92
+ def instantiate_several_from_http_response_data(data)
93
+ [data].flatten.map { |entry| instantiate_single_from_http_response_data(entry) }
94
+ end
95
+
96
+ def instantiate_resource(*args, &block)
97
+ klass = respond_to?(:new) ? self : resource_class
98
+
99
+ # TODO Fix the need that hack:
100
+ #
101
+ # It makes has_one saves work, as foo.bar builds a subclass of Bar
102
+ # with singleton=true so that the RemoteCollection of foo.bar will
103
+ # make POST requests to /foos/1/bar instead of /foos/1/bars.
104
+ #
105
+ # See Resource#remote_collection
106
+ #
107
+ basic_collection = self if respond_to?(:resource_class) && resource_class.name.to_s.empty?
108
+
109
+ resource = args.size == 1 && !block && klass === args.first ? args.first : klass.new(*args, &block)
110
+ resource.instance_variable_set(:@basic_remote_collection, basic_collection)
111
+ return resource
112
+ end
113
+ end
114
+ end