rodoo 0.1.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.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Rodoo
2
+
3
+ A Ruby gem wrapping Odoo's JSON-RPC 2.0 API (Odoo v19+) with an Active Record-style interface.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.0+
8
+ - Odoo v19 or higher (uses the `/json/2/` API endpoint)
9
+ - Odoo API key for authentication
10
+
11
+ ## Installation
12
+
13
+ Add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "rodoo"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ Or install directly:
26
+
27
+ ```bash
28
+ gem install rodoo
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ ### Using environment variables
34
+
35
+ Rodoo automatically reads `ODOO_URL` and `ODOO_API_KEY` from the environment:
36
+
37
+ ```bash
38
+ export ODOO_URL="https://your-instance.odoo.com"
39
+ export ODOO_API_KEY="your-api-key"
40
+ ```
41
+
42
+ ### Explicit configuration
43
+
44
+ ```ruby
45
+ Rodoo.configure do |config|
46
+ config.url = "https://your-instance.odoo.com"
47
+ config.api_key = "your-api-key"
48
+ config.timeout = 30 # Request timeout in seconds (default: 30)
49
+ config.open_timeout = 10 # Connection timeout in seconds (default: 10)
50
+ config.logger = Logger.new($stdout)
51
+ config.log_level = :debug # :info or :debug (default: :info)
52
+ end
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ### Finding records
58
+
59
+ ```ruby
60
+ # Find by ID
61
+ contact = Rodoo::Contact.find(42)
62
+ contact.name # => "Acme Corp"
63
+ contact.email # => "contact@acme.com"
64
+
65
+ # Find by attributes
66
+ contact = Rodoo::Contact.find_by(email: "john@example.com")
67
+ contact = Rodoo::Contact.find_by(name: "Acme Corp", is_company: true)
68
+
69
+ # Find by string condition
70
+ contact = Rodoo::Contact.find_by("credit_limit > 1000")
71
+
72
+ # Find by raw domain
73
+ contact = Rodoo::Contact.find_by([["name", "ilike", "%acme%"]])
74
+
75
+ # Find by attributes (raises NotFoundError if not found)
76
+ contact = Rodoo::Contact.find_by!(email: "john@example.com")
77
+ ```
78
+
79
+ ### Querying records
80
+
81
+ Rodoo supports multiple query syntaxes for convenience:
82
+
83
+ ```ruby
84
+ # Keyword arguments (equality)
85
+ companies = Rodoo::Contact.where(is_company: true)
86
+ active_companies = Rodoo::Contact.where(is_company: true, active: true)
87
+
88
+ # String conditions (parsed automatically)
89
+ high_credit = Rodoo::Contact.where("credit_limit > 1000")
90
+
91
+ # Multiple string conditions
92
+ filtered = Rodoo::Contact.where(["credit_limit > 1000", "active = true"])
93
+
94
+ # Raw Odoo domain syntax (for complex queries)
95
+ # See: https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html#reference-orm-domains
96
+ contacts = Rodoo::Contact.where([["name", "ilike", "%acme%"]])
97
+
98
+ # With pagination
99
+ contacts = Rodoo::Contact.where(is_company: true, limit: 10, offset: 20)
100
+
101
+ # Select specific fields
102
+ contacts = Rodoo::Contact.where(active: true, fields: ["name", "email"])
103
+
104
+ # Fetch all (with optional limit)
105
+ all_contacts = Rodoo::Contact.all(limit: 100)
106
+ ```
107
+
108
+ Supported operators in string conditions: `=`, `!=`, `<>`, `<`, `>`, `<=`, `>=`, `like`, `ilike`, `=like`, `=ilike`
109
+
110
+ ### Creating records
111
+
112
+ ```ruby
113
+ # Create and persist immediately
114
+ contact = Rodoo::Contact.create(
115
+ name: "New Contact",
116
+ email: "new@example.com",
117
+ is_company: false
118
+ )
119
+ contact.id # => 123
120
+
121
+ # Build and save later
122
+ contact = Rodoo::Contact.new(name: "Draft Contact")
123
+ contact.email = "draft@example.com"
124
+ contact.save
125
+ ```
126
+
127
+ ### Updating records
128
+
129
+ ```ruby
130
+ contact = Rodoo::Contact.find(42)
131
+
132
+ # Update specific attributes
133
+ contact.update(email: "updated@example.com", phone: "+1234567890")
134
+
135
+ # Or modify and save
136
+ contact.email = "another@example.com"
137
+ contact.save
138
+
139
+ # Reload from Odoo
140
+ contact.reload
141
+ ```
142
+
143
+ ### Deleting records
144
+
145
+ ```ruby
146
+ contact = Rodoo::Contact.find(42)
147
+ contact.destroy
148
+
149
+ contact.destroyed? # => true
150
+ ```
151
+
152
+ ### Available models
153
+
154
+ Rodoo includes pre-built models for common Odoo objects:
155
+
156
+ | Class | Odoo Model |
157
+ |-------|------------|
158
+ | `Rodoo::Contact` | `res.partner` |
159
+ | `Rodoo::Project` | `project.project` |
160
+ | `Rodoo::AnalyticAccount` | `account.analytic.account` |
161
+ | `Rodoo::AnalyticPlan` | `account.analytic.plan` |
162
+ | `Rodoo::AccountingEntry` | `account.move` (all types) |
163
+ | `Rodoo::CustomerInvoice` | `account.move` (move_type: out_invoice) |
164
+ | `Rodoo::ProviderInvoice` | `account.move` (move_type: in_invoice) |
165
+ | `Rodoo::CustomerCreditNote` | `account.move` (move_type: out_refund) |
166
+ | `Rodoo::ProviderCreditNote` | `account.move` (move_type: in_refund) |
167
+ | `Rodoo::JournalEntry` | `account.move` (move_type: entry) |
168
+
169
+ ### Custom models
170
+
171
+ Create your own model by inheriting from `Rodoo::Model`:
172
+
173
+ ```ruby
174
+ class Product < Rodoo::Model
175
+ model_name "product.product"
176
+ end
177
+
178
+ # Use it like any other model
179
+ product = Product.find(1)
180
+ products = Product.where(type: "consu", limit: 10)
181
+ ```
182
+
183
+ ### Error handling
184
+
185
+ Rodoo provides a structured exception hierarchy:
186
+
187
+ ```ruby
188
+ begin
189
+ contact = Rodoo::Contact.find(999999)
190
+ rescue Rodoo::NotFoundError => e
191
+ puts "Contact not found: #{e.message}"
192
+ rescue Rodoo::AuthenticationError => e
193
+ puts "Invalid credentials"
194
+ rescue Rodoo::AccessDeniedError => e
195
+ puts "Permission denied"
196
+ rescue Rodoo::ValidationError => e
197
+ puts "Validation failed: #{e.message}"
198
+ rescue Rodoo::TimeoutError => e
199
+ puts "Request timed out"
200
+ rescue Rodoo::ConnectionError => e
201
+ puts "Connection failed: #{e.message}"
202
+ rescue Rodoo::APIError => e
203
+ puts "API error: #{e.message} (code: #{e.code})"
204
+ end
205
+ ```
206
+
207
+ ## Development
208
+
209
+ After checking out the repo, run `bin/setup` to install dependencies.
210
+
211
+ ```bash
212
+ rake test # Run tests
213
+ rake rubocop # Run linter
214
+ rake # Run both
215
+ bin/console # Interactive REPL
216
+ ```
217
+
218
+ To install the gem locally:
219
+
220
+ ```bash
221
+ bundle exec rake install
222
+ ```
223
+
224
+ ## Contributing
225
+
226
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dekuple/rodoo.
227
+
228
+ ## License
229
+
230
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ class Configuration
5
+ VALID_OPTIONS = %i[
6
+ url
7
+ api_key
8
+ timeout
9
+ open_timeout
10
+ logger
11
+ log_level
12
+ ].freeze
13
+
14
+ attr_accessor(*VALID_OPTIONS)
15
+
16
+ DEFAULT_TIMEOUT = 30
17
+ DEFAULT_OPEN_TIMEOUT = 10
18
+ DEFAULT_LOG_LEVEL = :info
19
+
20
+ def initialize
21
+ @url = ENV.fetch("ODOO_URL", nil)
22
+ @api_key = ENV.fetch("ODOO_API_KEY", nil)
23
+ @timeout = DEFAULT_TIMEOUT
24
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
25
+ @log_level = DEFAULT_LOG_LEVEL
26
+ end
27
+
28
+ def validate!
29
+ raise ConfigurationError, "url is required" if url.nil? || url.empty?
30
+ raise ConfigurationError, "api_key or username/password is required" unless api_key
31
+
32
+ true
33
+ end
34
+
35
+ # Returns a hash of all options (useful for debugging)
36
+ def to_h
37
+ VALID_OPTIONS.each_with_object({}) do |key, hash|
38
+ hash[key] = send(key)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Rodoo
8
+ class Connection
9
+ attr_reader :config
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ @uri = URI.parse(config.url)
14
+ end
15
+
16
+ def execute(model, method, params = {})
17
+ path = "/json/2/#{model}/#{method}"
18
+ response = post(path, params)
19
+ handle_response(response)
20
+ end
21
+
22
+ private
23
+
24
+ def post(path, payload)
25
+ http = build_http_client
26
+ request = build_request(path, payload)
27
+ log_request(path, payload)
28
+ response = http.request(request)
29
+ log_response(response)
30
+ response
31
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
32
+ raise TimeoutError.new("Request timed out", original_error: e)
33
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
34
+ raise ConnectionError.new("Failed to connect to #{config.url}", original_error: e)
35
+ end
36
+
37
+ def build_http_client
38
+ Net::HTTP.new(@uri.host, @uri.port).tap do |http|
39
+ http.use_ssl = @uri.scheme == "https"
40
+ http.read_timeout = config.timeout
41
+ http.open_timeout = config.open_timeout
42
+ end
43
+ end
44
+
45
+ def build_request(path, payload)
46
+ Net::HTTP::Post.new(path).tap do |request|
47
+ request["Content-Type"] = "application/json"
48
+ request["Authorization"] = "Bearer #{config.api_key}" if config.api_key
49
+ request.body = payload.to_json
50
+ end
51
+ end
52
+
53
+ def handle_response(response)
54
+ case response
55
+ when Net::HTTPSuccess
56
+ parse_response(response.body)
57
+ when Net::HTTPUnauthorized
58
+ raise AuthenticationError.new("Invalid credentials", code: response.code)
59
+ when Net::HTTPForbidden
60
+ raise AccessDeniedError.new("Access denied", code: response.code)
61
+ when Net::HTTPNotFound
62
+ raise NotFoundError.new("Endpoint not found", code: response.code)
63
+ else
64
+ raise_api_error(response)
65
+ end
66
+ end
67
+
68
+ def raise_api_error(response)
69
+ error_data = parse_error_body(response.body)
70
+ unless error_data
71
+ raise APIError.new("HTTP #{response.code}: #{response.message}", code: response.code)
72
+ end
73
+
74
+ error_class = map_odoo_exception(error_data[:name])
75
+ message = error_data[:message] || "HTTP #{response.code}: #{response.message}"
76
+ raise error_class.new(message, code: response.code, data: error_data)
77
+ end
78
+
79
+ def parse_error_body(body)
80
+ return nil if body.nil? || body.empty?
81
+
82
+ JSON.parse(body, symbolize_names: true)
83
+ rescue JSON::ParserError
84
+ nil
85
+ end
86
+
87
+ def map_odoo_exception(exception_name)
88
+ case exception_name
89
+ when /ValidationError/, /UserError/
90
+ ValidationError
91
+ when /AccessError/, /AccessDenied/
92
+ AccessDeniedError
93
+ when /MissingError/
94
+ NotFoundError
95
+ else
96
+ APIError
97
+ end
98
+ end
99
+
100
+ def parse_response(body)
101
+ return nil if body.nil? || body.empty?
102
+
103
+ JSON.parse(body, symbolize_names: true)
104
+ rescue JSON::ParserError => e
105
+ raise Error.new("Invalid JSON response", original_error: e)
106
+ end
107
+
108
+ def log_request(path, payload)
109
+ return unless config.logger
110
+
111
+ if config.log_level == :debug
112
+ config.logger.debug("[Rodoo] POST #{path} #{payload.to_json}")
113
+ else
114
+ config.logger.info("[Rodoo] POST #{path}")
115
+ end
116
+ end
117
+
118
+ def log_response(response)
119
+ return unless config.logger
120
+
121
+ if config.log_level == :debug
122
+ config.logger.debug("[Rodoo] Response #{response.code}: #{response.body}")
123
+ else
124
+ config.logger.info("[Rodoo] Response #{response.code}")
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Converts various condition formats into Odoo domain arrays.
5
+ #
6
+ # Supports:
7
+ # - Hash: equality conditions `{ name: "John" }` → `[["name", "=", "John"]]`
8
+ # - String: parsed condition `"age > 18"` → `[["age", ">", 18]]`
9
+ # - Array of strings: `["age > 18", "active = true"]`
10
+ # - Array of arrays: raw Odoo domain (passthrough)
11
+ #
12
+ module DomainBuilder
13
+ CONDITION_PATTERN = /\A(\w+)\s*(=|!=|<>|<=|>=|<|>|like|ilike|=like|=ilike)\s*(.+)\z/i
14
+
15
+ module_function
16
+
17
+ def build(conditions, attrs = {})
18
+ return hash_to_domain(attrs) if attrs.any?
19
+ return [] if conditions.nil?
20
+
21
+ case conditions
22
+ when String then [parse_string_condition(conditions)]
23
+ when Hash then hash_to_domain(conditions)
24
+ when Array then array_to_domain(conditions)
25
+ else raise ArgumentError, "Invalid conditions: #{conditions.class}"
26
+ end
27
+ end
28
+
29
+ def hash_to_domain(hash)
30
+ hash.map { |k, v| [k.to_s, "=", v] }
31
+ end
32
+
33
+ def array_to_domain(arr)
34
+ return [] if arr.empty?
35
+ return arr.map { |s| parse_string_condition(s) } if arr.first.is_a?(String)
36
+
37
+ arr
38
+ end
39
+
40
+ def parse_string_condition(str)
41
+ match = str.strip.match(CONDITION_PATTERN)
42
+ raise ArgumentError, "Invalid condition: '#{str}'" unless match
43
+
44
+ field = match[1]
45
+ operator = match[2] == "<>" ? "!=" : match[2].downcase
46
+ value = parse_value(match[3].strip)
47
+
48
+ [field, operator, value]
49
+ end
50
+
51
+ def parse_value(str)
52
+ case str
53
+ when /\A["'](.*)["']\z/ then ::Regexp.last_match(1)
54
+ when /\Atrue\z/i then true
55
+ when /\Afalse\z/i then false
56
+ when /\A-?\d+\z/ then str.to_i
57
+ when /\A-?\d+\.\d+\z/ then str.to_f
58
+ else str
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ class Error < StandardError
5
+ attr_reader :original_error
6
+
7
+ def initialize(message = nil, original_error: nil)
8
+ @original_error = original_error
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ class ConfigurationError < Error; end
14
+ class ConnectionError < Error; end
15
+ class TimeoutError < ConnectionError; end
16
+
17
+ class APIError < Error
18
+ attr_reader :code, :data
19
+
20
+ def initialize(message = nil, code: nil, data: nil, **kwargs)
21
+ @code = code
22
+ @data = data
23
+ super(message, **kwargs)
24
+ end
25
+ end
26
+
27
+ class AuthenticationError < APIError; end
28
+ class NotFoundError < APIError; end
29
+ class ValidationError < APIError; end
30
+ class AccessDeniedError < APIError; end
31
+ end