couch-client 0.0.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.
@@ -0,0 +1,70 @@
1
+ require 'cgi'
2
+
3
+ module CouchClient
4
+ class InvalidPathObject < Exception; end
5
+ class InvalidQueryObject < Exception; end
6
+ class InvalidDatabaseName < Exception; end
7
+
8
+ # The ConnectionHandler creates properly formed URIs and paths, while also
9
+ # specifying sensible defaults for CouchDB. Once initialized, parameters
10
+ # can be wrote and read using getter and setter syntax.
11
+ class ConnectionHandler
12
+ attr_accessor :scheme, :username, :password, :host, :port
13
+ attr_reader :database
14
+
15
+ # ConnectionHandler is constructed without any parameters, and with defaults
16
+ # for scheme, host and port. Other settings are set via the accessors above.
17
+ def initialize
18
+ @scheme = "http"
19
+ @host = "localhost"
20
+ @port = 5984
21
+ end
22
+
23
+ # Sets the database and ensures that it follows proper naming conventions.
24
+ def database=(database)
25
+ if database.match(/^[a-z0-9_$()+-\/]+$/)
26
+ @database = database
27
+ else
28
+ raise InvalidDatabaseName.new("only lowercase characters (a-z), digits (0-9), or _, $, (, ), +, - and / are allowed.")
29
+ end
30
+ end
31
+
32
+ # Creates a properly formed URI that can be used by a HTTP library.
33
+ def uri(path_obj = nil, query_obj = nil)
34
+ str = "#{@scheme}://#{@host}:#{@port}"
35
+ str += path(path_obj, query_obj)
36
+ str
37
+ end
38
+
39
+ # Creates a properly formed path that can be used by a HTTP library.
40
+ # `path_obj` can be an Array or NilClass, `query_obj` can be Hash or NilClass.
41
+ def path(path_obj = nil, query_obj = nil)
42
+ path_obj ||= []
43
+ query_obj ||= {}
44
+
45
+ path_str = if path_obj.is_a?(Array)
46
+ # If an Array, stringify and escape (unless it is a design document) each object and join with a "/"
47
+ ([@database] + path_obj).map{|p| p.to_s.match(/_design\//) ? p.to_s : CGI.escape(p.to_s)}.join("/")
48
+ else
49
+ # Else, raise an error
50
+ raise InvalidPathObject.new("path must be of type 'Array' not of type '#{path_obj.class}'")
51
+ end
52
+
53
+ query_str = if query_obj.is_a?(Hash)
54
+ # If a Hash, stringify and escape each object, join each key/value with a "=" and each pair with a "&"
55
+ query_obj.to_a.map{|q| q.map{|r| CGI.escape(r.to_s)}.join("=")}.join("&")
56
+ else
57
+ # Else, raise an error
58
+ raise InvalidQueryObject.new("path must be of type 'Hash' or 'NilClass' not of type '#{query_obj.class}'")
59
+ end
60
+
61
+ str = "/" + path_str
62
+ str += "?" + query_str unless query_str.empty?
63
+ str
64
+ end
65
+
66
+ def inspect
67
+ "#<#{self.class}>"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,98 @@
1
+ module CouchClient
2
+ # ConsistentHash allows indifferent access with either with symbols or strings
3
+ # while also converting symbol values in Arrays or Hashes into strings values.
4
+ #
5
+ # ConsistentHash only overides methods that already exist in the Hash class;
6
+ # see the documentation in the Ruby core API for clarification and examples.
7
+ #
8
+ # This code was is heavily influenced by ActiveSupport::HashWithIndifferentAccess.
9
+ class ConsistentHash < Hash
10
+ # ConsistentHash is constructed with either a Hash or a default value. When
11
+ # a Hash is given, a new ConsistentHash is constructed with the same default
12
+ # value. When any other value is given it will be set as the default value.
13
+ def initialize(hash = {})
14
+ if hash.is_a?(Hash)
15
+ super()
16
+ update(hash).tap do |new_hash|
17
+ new_hash.default = hash.default
18
+ end
19
+ else
20
+ super(hash)
21
+ end
22
+ end
23
+
24
+ def default(key = nil)
25
+ if key.is_a?(Symbol) && include?(key = key.to_s)
26
+ self[key]
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ alias_method :regular_writer, :[]= unless method_defined?(:regular_writer)
33
+ alias_method :regular_update, :update unless method_defined?(:regular_update)
34
+
35
+ def []=(key, value)
36
+ regular_writer(convert_key(key), convert_value(value))
37
+ end
38
+
39
+ def update(other_hash)
40
+ other_hash.each_pair do |key, value|
41
+ regular_writer(convert_key(key), convert_value(value))
42
+ end
43
+ self
44
+ end
45
+
46
+ alias_method :merge!, :update
47
+
48
+ def key?(key)
49
+ super(convert_key(key))
50
+ end
51
+
52
+ alias_method :include?, :key?
53
+ alias_method :has_key?, :key?
54
+ alias_method :member?, :key?
55
+
56
+ def fetch(key, *extras)
57
+ super(convert_key(key), *extras)
58
+ end
59
+
60
+ def values_at(*indices)
61
+ indices.collect {|key| self[convert_key(key)]}
62
+ end
63
+
64
+ def dup
65
+ ConsistentHash.new(self)
66
+ end
67
+
68
+ def merge(hash)
69
+ dup.update(hash)
70
+ end
71
+
72
+ def delete(key)
73
+ super(convert_key(key))
74
+ end
75
+
76
+ def to_hash
77
+ Hash.new(default).merge!(self)
78
+ end
79
+
80
+ protected
81
+
82
+ def convert_key(key)
83
+ key.is_a?(Symbol) ? key.to_s : key
84
+ end
85
+
86
+ def convert_value(value)
87
+ if value.instance_of?(Hash)
88
+ ConsistentHash.new(value)
89
+ elsif value.instance_of?(Symbol)
90
+ value.to_s
91
+ elsif value.instance_of?(Array)
92
+ value.collect{|e| convert_value(e)}
93
+ else
94
+ value
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,39 @@
1
+ module CouchClient
2
+ # The Database is just a organized collection of functions that interact with the
3
+ # CouchDB database such as stats, creation, compaction, replication and deletion.
4
+ class Database
5
+ # Database is constructed with a connection that is used to make HTTP requests to the server.
6
+ def initialize(connection)
7
+ @connection = connection
8
+ end
9
+
10
+ def stats
11
+ @connection.hookup.get.last
12
+ end
13
+
14
+ def exists?
15
+ @connection.hookup.get.first == 200
16
+ end
17
+
18
+ def create
19
+ @connection.hookup.put.last
20
+ end
21
+
22
+ def delete!
23
+ @connection.hookup.delete.last
24
+ end
25
+
26
+ def compact!
27
+ @connection.hookup.post(["_compact"]).last
28
+ end
29
+
30
+ # TODO: add replicate method
31
+ def replicate
32
+ raise "pending"
33
+ end
34
+
35
+ def inspect
36
+ "#<#{self.class}>"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,79 @@
1
+ module CouchClient
2
+ class ViewNotFound < Exception; end
3
+ class ShowNotFound < Exception; end
4
+ class ListNotFound < Exception; end
5
+ class FullTextNotFound < Exception; end
6
+
7
+ # The Design is the interface used to interact with design documents
8
+ # in order make view, show, list and fulltext requests.
9
+ class Design
10
+ attr_accessor :id
11
+
12
+ # Design is constructed with an id of the design documemnt and
13
+ # a connection that is used to make HTTP requests to the server.
14
+ def initialize(id, connection)
15
+ @id = id
16
+ @connection = connection
17
+ end
18
+
19
+ # Makes requests to the server that return mappped/reduced view collections.
20
+ def view(name, options = {})
21
+ # key, startkey and endkey must be JSON encoded
22
+ ["key", "startkey", "endkey"].each do |key|
23
+ options[key] &&= options[key].to_json
24
+ end
25
+
26
+ code, body = @connection.hookup.get(["_design", id, "_view", name], options)
27
+
28
+ case code
29
+ when 200
30
+ # Return a Collection if results were found
31
+ Collection.new(code, body, @connection)
32
+ when 404
33
+ # Raise an error if nothing was found
34
+ raise ViewNotFound.new("could not find view field '#{name}' for design '#{id}'")
35
+ else
36
+ # Also raise an error if something else happens
37
+ raise Error.new("code: #{code}, error: #{body["error"]}, reason: #{body["reason"]}")
38
+ end
39
+ end
40
+
41
+ # TODO: add show method
42
+ def show(name, options = {})
43
+ raise "pending"
44
+ end
45
+
46
+ # TODO: add list method
47
+ def list(name, options = {})
48
+ raise "pending"
49
+ end
50
+
51
+ # Makes requests to the server that return lucene search results.
52
+ def fulltext(name, options = {})
53
+ code, body = @connection.hookup.get(["_fti", "_design", id, name], options)
54
+
55
+ case code
56
+ when 200
57
+ if body["rows"]
58
+ # Return a serch result if a query was provided
59
+ Collection.new(code, body, self)
60
+ else
61
+ # Return a status hash if a query was not provided
62
+ body
63
+ end
64
+ else
65
+ if body["reason"] == "no_such_view"
66
+ # Raise an error if a fulltext function was not found
67
+ raise FullTextNotFound.new("could not find fulltext field '#{name}' for design '#{id}'")
68
+ else
69
+ # Also raise an error if something else happens
70
+ raise Error.new("code: #{code}, error: #{body["error"]}, reason: #{body["reason"]}")
71
+ end
72
+ end
73
+ end
74
+
75
+ def inspect
76
+ "#<#{self.class}: id: #{@id}>"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,151 @@
1
+ module CouchClient
2
+ class InvalidId < Exception; end
3
+ class AttachmentError < Exception; end
4
+ class DocumentNotAvailable < Exception; end
5
+
6
+ # The Document is an extended Hash that provides additional methods to
7
+ # save, update (with attachments), and delete documents on the CouchDB.
8
+ class Document < ConsistentHash
9
+ attr_reader :code, :error
10
+
11
+ # Document is constructed with a status code, response body,
12
+ # connection object and a flag stating if the document has been deleted.
13
+ def initialize(code, body, connection, deleted = false)
14
+ self.merge!(body)
15
+
16
+ @code = code
17
+ @error = {}
18
+ @connection = connection
19
+ @deleted = deleted
20
+
21
+ if self.attachments
22
+ self.attachments = AttachmentList.new(attachments)
23
+
24
+ self.attachments.keys.each do |key|
25
+ self.attachments[key] = Attachment.new(id, key, attachments[key], @connection)
26
+ end
27
+ end
28
+ end
29
+
30
+ # Hookup#(id|id=|rev|rev=|attachments|attachments=) are convenience methods
31
+ # that correspond to ["_id"], ["_rev"] and ["_attachments"] document fields.
32
+ ["id", "rev", "attachments"].each do |method|
33
+ define_method(method) do
34
+ self["_#{method}"]
35
+ end
36
+
37
+ define_method("#{method}=") do |value|
38
+ self["_#{method}"] = value
39
+ end
40
+ end
41
+
42
+ # Returns a copy of the same document that is currently saved on the server.
43
+ def saved_doc(query = {})
44
+ if new?
45
+ raise DocumentNotAvailable.new('this document is new and therefore has not been saved yet')
46
+ else
47
+ @connection[self.id, query]
48
+ end
49
+ end
50
+
51
+ # Tries to save the document to the server. If it us unable to,
52
+ # it will save the error and make it available with via #error.
53
+ def save
54
+ # Ensure that "_id" is a String if it is defined.
55
+ if self.key?("_id") && !self["_id"].is_a?(String)
56
+ raise InvalidId.new("document _id must be a String")
57
+ end
58
+
59
+ # Documents without an id must use post, with an id must use put
60
+ @code, body = if self.id
61
+ @connection.hookup.put([self.id], nil, self)
62
+ else
63
+ @connection.hookup.post(nil, nil, self)
64
+ end
65
+
66
+ # If the document was saved
67
+ if body["ok"]
68
+ # Update id and rev, ensure the deleted flag is set to `false` and return `true`
69
+ self.id ||= body["id"]
70
+ self.rev = body["rev"]
71
+ @deleted = false
72
+ true
73
+ else
74
+ # Save error message and return `false`.
75
+ @error = {body["error"] => body["reason"]}
76
+ false
77
+ end
78
+ end
79
+
80
+ # Tries to attach a file to the document. If it us unable to,
81
+ # it will save the error and make it available with via #error.
82
+ def attach(name, content, content_type)
83
+ # The document must already be saved to the server before a file can be attached.
84
+ if self.rev
85
+ @code, body = @connection.hookup.put([self.id, name], {"rev" => self.rev}, content, content_type)
86
+
87
+ # If the document was saved
88
+ if body["ok"]
89
+ # Update rev and return `true`
90
+ self.rev = body["rev"]
91
+ true
92
+ else
93
+ # Save error message and return `false`.
94
+ @error = {body["error"] => body["reason"]}
95
+ false
96
+ end
97
+ else
98
+ # Raise an error if the document is new before trying to attach a file.
99
+ raise AttachmentError.new("a document must exist before an attachment can be uploaded to it")
100
+ end
101
+ end
102
+
103
+ # Tries to delete a file from the server. If it us unable to,
104
+ # it will save the error and make it available with via #error.
105
+ def delete!
106
+ @code, body = @connection.hookup.delete([id], {"rev" => rev})
107
+
108
+ # If the document was deleted
109
+ if body["ok"]
110
+ # Update the rev, set the deleted flag to `true` and return `true`
111
+ self.rev = body["rev"]
112
+ @deleted = true
113
+ true
114
+ else
115
+ # Save error message and return `false`.
116
+ @error = {body["error"] => body["reason"]}
117
+ false
118
+ end
119
+ end
120
+
121
+ # Identifies the document as a design document
122
+ def design?
123
+ !!self.id.match(/_design\//) # Design documents start with "_design/"
124
+ end
125
+
126
+ # Identifies the document as not yet being saved to the server
127
+ def new?
128
+ !rev
129
+ end
130
+
131
+ # Identifies that there are currently errors that must be resolved
132
+ def error?
133
+ !@error.empty?
134
+ end
135
+
136
+ # Identifies that the document does not yet pass a `validate_on_update` function
137
+ def invalid?
138
+ @code == 403 && @error["forbidden"]
139
+ end
140
+
141
+ # Identifies that the document cannot be saved due to conflicts
142
+ def conflict?
143
+ !!@error["conflict"]
144
+ end
145
+
146
+ # Identifies that the document has been deleted
147
+ def deleted?
148
+ @deleted
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,107 @@
1
+ require 'curb'
2
+ require 'json'
3
+
4
+ module CouchClient
5
+ class InvalidHTTPVerb < Exception; end
6
+ class InvalidJSONData < Exception; end
7
+ class SymbolUsedInField < Exception; end
8
+
9
+ # The Hookup is the basic HTTP interface that connects CouchClient to CouchDB.
10
+ # Hookup can use any HTTP library if the conventions listed below are followed.
11
+ #
12
+ # If modified, Hookup must have head, get, post, put and delete instance methods.
13
+ class Hookup
14
+ attr_reader :handler
15
+
16
+ # Hookup is constructed with a connection handler, which formats the
17
+ # proper URIs with a domain, authentication, path and query string.
18
+ def initialize(handler)
19
+ @handler = handler
20
+ end
21
+
22
+ # Hookup#(head|get|delete) has the following method signature
23
+ # hookup.verb(["path", "name"], {"query_key" => "query_value"}, "content/type")
24
+ #
25
+ # And has the following response
26
+ # [code, {"data_key" => "data_value"}]
27
+ #
28
+ # Except, if the verb is `head`, which has the following response
29
+ # [code, nil]
30
+ #
31
+ # Or, if the verb is `get` and content_type is not "application/json", which has the following response
32
+ # [code, "string containing file data"]
33
+ #
34
+ # By default path is nil, query is nil, and content_type is "application/json"
35
+ [:head, :get, :delete].each do |verb|
36
+ define_method(verb) do |*args|
37
+ # These methods do not have a body parameter. As such, the following
38
+ # prevents setting content_type to nil instead not setting it at all.
39
+ params = [verb, args.shift, args.shift, nil]
40
+ params << args.shift unless args.empty?
41
+ curl(*params)
42
+ end
43
+ end
44
+
45
+ # Hookup#(post|put) has the following method signature
46
+ # hookup.verb(["path", "name"], {"query_key" => "query_value"}, {"data_key" => "data_value"}, "content/type")
47
+ #
48
+ # And has the following response
49
+ # [code, {"data_key" => "data_value"}]
50
+ #
51
+ # By default path is nil, query is nil, data is {} and content_type is "application/json"
52
+ [:post, :put].each do |verb|
53
+ define_method(verb) do |*args|
54
+ curl(verb, *args)
55
+ end
56
+ end
57
+
58
+ def inspect
59
+ "#<#{self.class}>"
60
+ end
61
+
62
+ private
63
+
64
+ # This is the method that actually makes curl request for each verb method listed above.
65
+ def curl(verb, path = nil, query = nil, data = {}, content_type = "application/json")
66
+ # Setup curb options block
67
+ options = lambda do |easy|
68
+ easy.headers["User-Agent"] = "couch-client v#{VERSION}"
69
+ easy.headers["Content-Type"] = content_type if content_type
70
+ easy.headers["Accepts"] = content_type if content_type
71
+ easy.username = handler.username
72
+ easy.userpwd = handler.password
73
+ end
74
+
75
+ easy = case verb
76
+ when :head, :get, :delete
77
+ # head, get and delete http methods only take a uri string and options block
78
+ Curl::Easy.send("http_#{verb}", handler.uri(path, query), &options)
79
+ when :post, :put
80
+ # post and put http methods take a uri string, data string and options block
81
+ # also convert the hash into json if the content_type of the request is json
82
+ data = data.to_json if content_type == "application/json"
83
+ Curl::Easy.send("http_#{verb}", handler.uri(path, query), data, &options)
84
+ else
85
+ raise InvalidHTTPVerb.new("only `head`, `get`, `post`, `put` and `delete` are supported")
86
+ end
87
+
88
+ # code is the http code (e.g. 200 or 404)
89
+ code = easy.response_code
90
+
91
+ # body is either a nil, a hash or a string containing attachment data
92
+ body = if easy.body_str == "" || easy.body_str.nil?
93
+ nil
94
+ elsif content_type == "application/json" || [:post, :put, :delete].include?(verb)
95
+ begin
96
+ JSON.parse(easy.body_str)
97
+ rescue
98
+ raise InvalidJSONData.new("document received is not valid JSON")
99
+ end
100
+ else
101
+ easy.body_str
102
+ end
103
+
104
+ [code, body]
105
+ end
106
+ end
107
+ end