pobo-sdk 1.0.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
+ SHA256:
3
+ metadata.gz: d7068813d4efd25d36f69d12e135071e73c78b66203af1b022fc0e8665ab7405
4
+ data.tar.gz: ffe2ef8bc23b2b34149b559d19f6c15f8c11a7e1d3639fcc732c0ba599109e2c
5
+ SHA512:
6
+ metadata.gz: 03d8fe0cf48b5c02db8e18d0122bff36ffdcbe99751b68182e5145436d7021490e289859ee535173134143d3f68364e5de3f27500b7294282e4499a7b4820a6f
7
+ data.tar.gz: aa250e2d7a3e4e995ca342fc63dead8ffdb5eb12b96b8ef6a77b27f502eb86b84c6fe592d83242ea16190218c16848d66da35105ab4216f41a5fba9f0d9a5f51
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Pobo
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,345 @@
1
+ # Pobo Ruby SDK
2
+
3
+ Official Ruby SDK for [Pobo API V2](https://api.pobo.space) - product content management and webhooks.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.0+
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'pobo-sdk'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ Or install it yourself as:
24
+
25
+ ```bash
26
+ gem install pobo-sdk
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### API Client
32
+
33
+ ```ruby
34
+ require 'pobo'
35
+
36
+ client = Pobo::Client.new(
37
+ api_token: 'your-api-token',
38
+ base_url: 'https://api.pobo.space', # optional
39
+ timeout: 30 # optional, in seconds
40
+ )
41
+ ```
42
+
43
+ ## Import
44
+
45
+ ### Import Order
46
+
47
+ ```
48
+ 1. Parameters (no dependencies)
49
+ 2. Categories (no dependencies)
50
+ 3. Products (depends on categories and parameters)
51
+ 4. Blogs (no dependencies)
52
+ ```
53
+
54
+ ### Import Parameters
55
+
56
+ ```ruby
57
+ parameters = [
58
+ Pobo::DTO::Parameter.new(
59
+ id: 1,
60
+ name: 'Color',
61
+ values: [
62
+ Pobo::DTO::ParameterValue.new(id: 1, value: 'Red'),
63
+ Pobo::DTO::ParameterValue.new(id: 2, value: 'Blue')
64
+ ]
65
+ ),
66
+ Pobo::DTO::Parameter.new(
67
+ id: 2,
68
+ name: 'Size',
69
+ values: [
70
+ Pobo::DTO::ParameterValue.new(id: 3, value: 'S'),
71
+ Pobo::DTO::ParameterValue.new(id: 4, value: 'M')
72
+ ]
73
+ )
74
+ ]
75
+
76
+ result = client.import_parameters(parameters)
77
+ puts "Imported: #{result.imported}, Values: #{result.values_imported}"
78
+ ```
79
+
80
+ ### Import Categories
81
+
82
+ ```ruby
83
+ categories = [
84
+ Pobo::DTO::Category.new(
85
+ id: 'CAT-001',
86
+ is_visible: true,
87
+ name: Pobo::DTO::LocalizedString.create('Electronics')
88
+ .with_translation(Pobo::Language::CS, 'Elektronika')
89
+ .with_translation(Pobo::Language::SK, 'Elektronika'),
90
+ url: Pobo::DTO::LocalizedString.create('https://example.com/electronics')
91
+ .with_translation(Pobo::Language::CS, 'https://example.com/cs/elektronika')
92
+ .with_translation(Pobo::Language::SK, 'https://example.com/sk/elektronika'),
93
+ description: Pobo::DTO::LocalizedString.create('<p>All electronics</p>')
94
+ .with_translation(Pobo::Language::CS, '<p>Veškerá elektronika</p>')
95
+ .with_translation(Pobo::Language::SK, '<p>Všetka elektronika</p>'),
96
+ images: ['https://example.com/images/electronics.jpg']
97
+ )
98
+ ]
99
+
100
+ result = client.import_categories(categories)
101
+ puts "Imported: #{result.imported}, Updated: #{result.updated}"
102
+ ```
103
+
104
+ ### Import Products
105
+
106
+ ```ruby
107
+ products = [
108
+ Pobo::DTO::Product.new(
109
+ id: 'PROD-001',
110
+ is_visible: true,
111
+ name: Pobo::DTO::LocalizedString.create('iPhone 15')
112
+ .with_translation(Pobo::Language::CS, 'iPhone 15')
113
+ .with_translation(Pobo::Language::SK, 'iPhone 15'),
114
+ url: Pobo::DTO::LocalizedString.create('https://example.com/iphone-15')
115
+ .with_translation(Pobo::Language::CS, 'https://example.com/cs/iphone-15')
116
+ .with_translation(Pobo::Language::SK, 'https://example.com/sk/iphone-15'),
117
+ short_description: Pobo::DTO::LocalizedString.create('Latest iPhone model')
118
+ .with_translation(Pobo::Language::CS, 'Nejnovější model iPhone'),
119
+ images: ['https://example.com/images/iphone-1.jpg'],
120
+ categories_ids: ['CAT-001', 'CAT-002'],
121
+ parameters_ids: [1, 2]
122
+ )
123
+ ]
124
+
125
+ result = client.import_products(products)
126
+
127
+ if result.has_errors?
128
+ result.errors.each do |error|
129
+ puts "Error: #{error['errors'].join(', ')}"
130
+ end
131
+ end
132
+ ```
133
+
134
+ ### Import Blogs
135
+
136
+ ```ruby
137
+ blogs = [
138
+ Pobo::DTO::Blog.new(
139
+ id: 'BLOG-001',
140
+ is_visible: true,
141
+ name: Pobo::DTO::LocalizedString.create('New Product Launch')
142
+ .with_translation(Pobo::Language::CS, 'Uvedení nového produktu')
143
+ .with_translation(Pobo::Language::SK, 'Uvedenie nového produktu'),
144
+ url: Pobo::DTO::LocalizedString.create('https://example.com/blog/new-product')
145
+ .with_translation(Pobo::Language::CS, 'https://example.com/cs/blog/novy-produkt')
146
+ .with_translation(Pobo::Language::SK, 'https://example.com/sk/blog/novy-produkt'),
147
+ category: 'news',
148
+ description: Pobo::DTO::LocalizedString.create('<p>We are excited to announce...</p>')
149
+ .with_translation(Pobo::Language::CS, '<p>S radostí oznamujeme...</p>'),
150
+ images: ['https://example.com/images/blog-1.jpg']
151
+ )
152
+ ]
153
+
154
+ result = client.import_blogs(blogs)
155
+ puts "Imported: #{result.imported}, Updated: #{result.updated}"
156
+ ```
157
+
158
+ ## Export
159
+
160
+ ### Export Products
161
+
162
+ ```ruby
163
+ response = client.get_products(page: 1, per_page: 50)
164
+
165
+ response.data.each do |product|
166
+ puts "#{product.id}: #{product.name.default}"
167
+ end
168
+
169
+ puts "Page #{response.current_page} of #{response.total_pages}"
170
+
171
+ # Iterate through all products (handles pagination automatically)
172
+ client.each_product do |product|
173
+ puts "#{product.id}: #{product.name.default}"
174
+ end
175
+
176
+ # Filter by last update time
177
+ since = Time.new(2024, 1, 1)
178
+ response = client.get_products(last_update_from: since)
179
+
180
+ # Filter only edited products
181
+ response = client.get_products(is_edited: true)
182
+ ```
183
+
184
+ ### Export Categories
185
+
186
+ ```ruby
187
+ response = client.get_categories
188
+
189
+ response.data.each do |category|
190
+ puts "#{category.id}: #{category.name.default}"
191
+ end
192
+
193
+ # Iterate through all categories
194
+ client.each_category do |category|
195
+ process_category(category)
196
+ end
197
+ ```
198
+
199
+ ### Export Blogs
200
+
201
+ ```ruby
202
+ response = client.get_blogs
203
+
204
+ response.data.each do |blog|
205
+ puts "#{blog.id}: #{blog.name.default}"
206
+ end
207
+
208
+ # Iterate through all blogs
209
+ client.each_blog do |blog|
210
+ process_blog(blog)
211
+ end
212
+ ```
213
+
214
+ ## Content (HTML/Marketplace)
215
+
216
+ Products, categories, and blogs include a `content` field with generated HTML content:
217
+
218
+ ```ruby
219
+ client.each_product do |product|
220
+ next unless product.content
221
+
222
+ # Get HTML content for web
223
+ html_cs = product.content.get_html(Pobo::Language::CS)
224
+ html_sk = product.content.get_html(Pobo::Language::SK)
225
+
226
+ # Get content for marketplace
227
+ marketplace_cs = product.content.get_marketplace(Pobo::Language::CS)
228
+
229
+ # Get default content
230
+ html_default = product.content.html_default
231
+ marketplace_default = product.content.marketplace_default
232
+ end
233
+ ```
234
+
235
+ ## Webhook Handler
236
+
237
+ ### Basic Usage (Rails)
238
+
239
+ ```ruby
240
+ class WebhooksController < ApplicationController
241
+ skip_before_action :verify_authenticity_token
242
+
243
+ def pobo
244
+ handler = Pobo::WebhookHandler.new(webhook_secret: ENV['POBO_WEBHOOK_SECRET'])
245
+
246
+ begin
247
+ payload = handler.handle_request(request)
248
+
249
+ case payload.event
250
+ when Pobo::WebhookEvent::PRODUCTS_UPDATE
251
+ SyncProductsJob.perform_later
252
+ when Pobo::WebhookEvent::CATEGORIES_UPDATE
253
+ SyncCategoriesJob.perform_later
254
+ end
255
+
256
+ render json: { status: 'ok' }
257
+ rescue Pobo::WebhookError => e
258
+ render json: { error: e.message }, status: :unauthorized
259
+ end
260
+ end
261
+ end
262
+ ```
263
+
264
+ ### Manual Handling
265
+
266
+ ```ruby
267
+ payload = handler.handle(
268
+ payload: request.raw_post,
269
+ signature: request.headers['X-Webhook-Signature']
270
+ )
271
+ ```
272
+
273
+ ### Webhook Payload
274
+
275
+ ```ruby
276
+ payload.event # String: "products.update" or "categories.update"
277
+ payload.timestamp # Time
278
+ payload.eshop_id # Integer
279
+ ```
280
+
281
+ ## Error Handling
282
+
283
+ ```ruby
284
+ begin
285
+ result = client.import_products(products)
286
+ rescue Pobo::ValidationError => e
287
+ puts "Validation error: #{e.message}"
288
+ rescue Pobo::ApiError => e
289
+ puts "API error (#{e.http_code}): #{e.message}"
290
+ puts e.response_body
291
+ end
292
+ ```
293
+
294
+ ## Localized Strings
295
+
296
+ ```ruby
297
+ # Create with default value
298
+ name = Pobo::DTO::LocalizedString.create('Default Name')
299
+
300
+ # Add translations using fluent interface
301
+ name = name
302
+ .with_translation(Pobo::Language::CS, 'Czech Name')
303
+ .with_translation(Pobo::Language::SK, 'Slovak Name')
304
+ .with_translation(Pobo::Language::EN, 'English Name')
305
+
306
+ # Get values
307
+ name.default # => 'Default Name'
308
+ name.get(Pobo::Language::CS) # => 'Czech Name'
309
+ name.to_hash # => { 'default' => '...', 'cs' => '...', ... }
310
+ ```
311
+
312
+ ### Supported Languages
313
+
314
+ | Code | Language |
315
+ |-----------|--------------------|
316
+ | `default` | Default (required) |
317
+ | `cs` | Czech |
318
+ | `sk` | Slovak |
319
+ | `en` | English |
320
+ | `de` | German |
321
+ | `pl` | Polish |
322
+ | `hu` | Hungarian |
323
+
324
+ ## API Methods
325
+
326
+ | Method | Description |
327
+ |-------------------------------------------------------------------|----------------------------------|
328
+ | `import_products(products)` | Bulk import products (max 100) |
329
+ | `import_categories(categories)` | Bulk import categories (max 100) |
330
+ | `import_parameters(parameters)` | Bulk import parameters (max 100) |
331
+ | `import_blogs(blogs)` | Bulk import blogs (max 100) |
332
+ | `get_products(page:, per_page:, last_update_from:, is_edited:)` | Get products page |
333
+ | `get_categories(page:, per_page:, last_update_from:, is_edited:)` | Get categories page |
334
+ | `get_blogs(page:, per_page:, last_update_from:, is_edited:)` | Get blogs page |
335
+ | `each_product(last_update_from:, is_edited:)` | Iterate all products |
336
+ | `each_category(last_update_from:, is_edited:)` | Iterate all categories |
337
+ | `each_blog(last_update_from:, is_edited:)` | Iterate all blogs |
338
+
339
+ ## Development
340
+
341
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
342
+
343
+ ## License
344
+
345
+ MIT License
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Pobo
7
+ class Client
8
+ DEFAULT_BASE_URL = "https://api.pobo.space"
9
+ MAX_BULK_ITEMS = 100
10
+ DEFAULT_TIMEOUT = 30
11
+
12
+ attr_reader :api_token, :base_url, :timeout
13
+
14
+ def initialize(api_token:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
15
+ @api_token = api_token
16
+ @base_url = base_url
17
+ @timeout = timeout
18
+ end
19
+
20
+ # Import methods
21
+
22
+ def import_products(products)
23
+ validate_bulk_size!(products)
24
+ payload = products.map { |p| p.respond_to?(:to_hash) ? p.to_hash : p }
25
+ response = request(:post, "/api/v2/rest/products", payload)
26
+ DTO::ImportResult.from_hash(response)
27
+ end
28
+
29
+ def import_categories(categories)
30
+ validate_bulk_size!(categories)
31
+ payload = categories.map { |c| c.respond_to?(:to_hash) ? c.to_hash : c }
32
+ response = request(:post, "/api/v2/rest/categories", payload)
33
+ DTO::ImportResult.from_hash(response)
34
+ end
35
+
36
+ def import_parameters(parameters)
37
+ validate_bulk_size!(parameters)
38
+ payload = parameters.map { |p| p.respond_to?(:to_hash) ? p.to_hash : p }
39
+ response = request(:post, "/api/v2/rest/parameters", payload)
40
+ DTO::ImportResult.from_hash(response)
41
+ end
42
+
43
+ def import_blogs(blogs)
44
+ validate_bulk_size!(blogs)
45
+ payload = blogs.map { |b| b.respond_to?(:to_hash) ? b.to_hash : b }
46
+ response = request(:post, "/api/v2/rest/blogs", payload)
47
+ DTO::ImportResult.from_hash(response)
48
+ end
49
+
50
+ # Export methods
51
+
52
+ def get_products(page: nil, per_page: nil, last_update_from: nil, is_edited: nil)
53
+ query = build_query_params(page, per_page, last_update_from, is_edited)
54
+ response = request(:get, "/api/v2/rest/products#{query}")
55
+ DTO::PaginatedResponse.from_hash(response, DTO::Product)
56
+ end
57
+
58
+ def get_categories(page: nil, per_page: nil, last_update_from: nil, is_edited: nil)
59
+ query = build_query_params(page, per_page, last_update_from, is_edited)
60
+ response = request(:get, "/api/v2/rest/categories#{query}")
61
+ DTO::PaginatedResponse.from_hash(response, DTO::Category)
62
+ end
63
+
64
+ def get_blogs(page: nil, per_page: nil, last_update_from: nil, is_edited: nil)
65
+ query = build_query_params(page, per_page, last_update_from, is_edited)
66
+ response = request(:get, "/api/v2/rest/blogs#{query}")
67
+ DTO::PaginatedResponse.from_hash(response, DTO::Blog)
68
+ end
69
+
70
+ # Iterator methods
71
+
72
+ def each_product(last_update_from: nil, is_edited: nil, &block)
73
+ return enum_for(:each_product, last_update_from: last_update_from, is_edited: is_edited) unless block_given?
74
+
75
+ page = 1
76
+ loop do
77
+ response = get_products(page: page, per_page: MAX_BULK_ITEMS, last_update_from: last_update_from, is_edited: is_edited)
78
+ response.data.each(&block)
79
+ break unless response.more_pages?
80
+
81
+ page += 1
82
+ end
83
+ end
84
+
85
+ def each_category(last_update_from: nil, is_edited: nil, &block)
86
+ return enum_for(:each_category, last_update_from: last_update_from, is_edited: is_edited) unless block_given?
87
+
88
+ page = 1
89
+ loop do
90
+ response = get_categories(page: page, per_page: MAX_BULK_ITEMS, last_update_from: last_update_from, is_edited: is_edited)
91
+ response.data.each(&block)
92
+ break unless response.more_pages?
93
+
94
+ page += 1
95
+ end
96
+ end
97
+
98
+ def each_blog(last_update_from: nil, is_edited: nil, &block)
99
+ return enum_for(:each_blog, last_update_from: last_update_from, is_edited: is_edited) unless block_given?
100
+
101
+ page = 1
102
+ loop do
103
+ response = get_blogs(page: page, per_page: MAX_BULK_ITEMS, last_update_from: last_update_from, is_edited: is_edited)
104
+ response.data.each(&block)
105
+ break unless response.more_pages?
106
+
107
+ page += 1
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def validate_bulk_size!(items)
114
+ raise ValidationError.empty_payload if items.empty?
115
+ raise ValidationError.too_many_items(items.size, MAX_BULK_ITEMS) if items.size > MAX_BULK_ITEMS
116
+ end
117
+
118
+ def build_query_params(page, per_page, last_update_from, is_edited)
119
+ params = {}
120
+ params[:page] = page if page
121
+ params[:per_page] = [per_page, MAX_BULK_ITEMS].min if per_page
122
+ params[:last_update_time_from] = last_update_from.strftime("%Y-%m-%d %H:%M:%S") if last_update_from
123
+ params[:is_edited] = is_edited.to_s if is_edited != nil
124
+
125
+ return "" if params.empty?
126
+
127
+ "?" + URI.encode_www_form(params)
128
+ end
129
+
130
+ def request(method, endpoint, data = nil)
131
+ response = connection.send(method) do |req|
132
+ req.url endpoint
133
+ if data
134
+ req.headers["Content-Type"] = "application/json"
135
+ req.body = JSON.generate(data)
136
+ end
137
+ end
138
+
139
+ handle_response(response)
140
+ end
141
+
142
+ def connection
143
+ @connection ||= Faraday.new(url: @base_url) do |f|
144
+ f.request :url_encoded
145
+ f.adapter Faraday.default_adapter
146
+ f.options.timeout = @timeout
147
+ f.options.open_timeout = 10
148
+ f.headers["Authorization"] = "Bearer #{@api_token}"
149
+ f.headers["Accept"] = "application/json"
150
+ end
151
+ end
152
+
153
+ def handle_response(response)
154
+ body = response.body.empty? ? {} : JSON.parse(response.body)
155
+
156
+ case response.status
157
+ when 200..299
158
+ body
159
+ when 401
160
+ raise ApiError.unauthorized
161
+ else
162
+ raise ApiError.from_response(response.status, body)
163
+ end
164
+ rescue JSON::ParserError
165
+ raise ApiError.new("Invalid JSON response", http_code: response.status, response_body: response.body)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class Blog
6
+ attr_reader :id, :is_visible, :name, :url, :category, :description,
7
+ :seo_title, :seo_description, :content, :images, :is_loaded,
8
+ :created_at, :updated_at
9
+
10
+ def initialize(
11
+ id:,
12
+ is_visible:,
13
+ name:,
14
+ url:,
15
+ category: nil,
16
+ description: nil,
17
+ seo_title: nil,
18
+ seo_description: nil,
19
+ content: nil,
20
+ images: [],
21
+ is_loaded: nil,
22
+ created_at: nil,
23
+ updated_at: nil
24
+ )
25
+ @id = id
26
+ @is_visible = is_visible
27
+ @name = name
28
+ @url = url
29
+ @category = category
30
+ @description = description
31
+ @seo_title = seo_title
32
+ @seo_description = seo_description
33
+ @content = content
34
+ @images = images
35
+ @is_loaded = is_loaded
36
+ @created_at = created_at
37
+ @updated_at = updated_at
38
+ end
39
+
40
+ def self.from_hash(hash)
41
+ new(
42
+ id: hash["id"] || hash[:id],
43
+ is_visible: hash["is_visible"] || hash[:is_visible],
44
+ name: LocalizedString.from_hash(hash["name"] || hash[:name]),
45
+ url: LocalizedString.from_hash(hash["url"] || hash[:url]),
46
+ category: hash["category"] || hash[:category],
47
+ description: LocalizedString.from_hash(hash["description"] || hash[:description]),
48
+ seo_title: LocalizedString.from_hash(hash["seo_title"] || hash[:seo_title]),
49
+ seo_description: LocalizedString.from_hash(hash["seo_description"] || hash[:seo_description]),
50
+ content: Content.from_hash(hash["content"] || hash[:content]),
51
+ images: hash["images"] || hash[:images] || [],
52
+ is_loaded: hash["is_loaded"] || hash[:is_loaded],
53
+ created_at: parse_time(hash["created_at"] || hash[:created_at]),
54
+ updated_at: parse_time(hash["updated_at"] || hash[:updated_at])
55
+ )
56
+ end
57
+
58
+ def to_hash
59
+ data = {
60
+ "id" => @id,
61
+ "is_visible" => @is_visible,
62
+ "name" => @name&.to_hash,
63
+ "url" => @url&.to_hash
64
+ }
65
+
66
+ data["category"] = @category if @category
67
+ data["description"] = @description.to_hash if @description
68
+ data["seo_title"] = @seo_title.to_hash if @seo_title
69
+ data["seo_description"] = @seo_description.to_hash if @seo_description
70
+ data["images"] = @images unless @images.empty?
71
+
72
+ data
73
+ end
74
+
75
+ alias to_h to_hash
76
+
77
+ private
78
+
79
+ def self.parse_time(value)
80
+ return nil if value.nil?
81
+ return value if value.is_a?(Time)
82
+
83
+ Time.parse(value)
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end