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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class Category
6
+ attr_reader :id, :is_visible, :name, :url, :description, :seo_title,
7
+ :seo_description, :content, :images, :guid, :is_loaded,
8
+ :created_at, :updated_at
9
+
10
+ def initialize(
11
+ id:,
12
+ is_visible:,
13
+ name:,
14
+ url:,
15
+ description: nil,
16
+ seo_title: nil,
17
+ seo_description: nil,
18
+ content: nil,
19
+ images: [],
20
+ guid: nil,
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
+ @description = description
30
+ @seo_title = seo_title
31
+ @seo_description = seo_description
32
+ @content = content
33
+ @images = images
34
+ @guid = guid
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
+ description: LocalizedString.from_hash(hash["description"] || hash[:description]),
47
+ seo_title: LocalizedString.from_hash(hash["seo_title"] || hash[:seo_title]),
48
+ seo_description: LocalizedString.from_hash(hash["seo_description"] || hash[:seo_description]),
49
+ content: Content.from_hash(hash["content"] || hash[:content]),
50
+ images: hash["images"] || hash[:images] || [],
51
+ guid: hash["guid"] || hash[:guid],
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["description"] = @description.to_hash if @description
67
+ data["seo_title"] = @seo_title.to_hash if @seo_title
68
+ data["seo_description"] = @seo_description.to_hash if @seo_description
69
+ data["images"] = @images unless @images.empty?
70
+
71
+ data
72
+ end
73
+
74
+ alias to_h to_hash
75
+
76
+ private
77
+
78
+ def self.parse_time(value)
79
+ return nil if value.nil?
80
+ return value if value.is_a?(Time)
81
+
82
+ Time.parse(value)
83
+ rescue ArgumentError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class Content
6
+ attr_reader :html, :marketplace
7
+
8
+ def initialize(html: {}, marketplace: {})
9
+ @html = html.transform_keys(&:to_s)
10
+ @marketplace = marketplace.transform_keys(&:to_s)
11
+ end
12
+
13
+ def self.from_hash(hash)
14
+ return nil if hash.nil?
15
+
16
+ new(
17
+ html: hash["html"] || hash[:html] || {},
18
+ marketplace: hash["marketplace"] || hash[:marketplace] || {}
19
+ )
20
+ end
21
+
22
+ def get_html(language)
23
+ @html[language.to_s]
24
+ end
25
+
26
+ def get_marketplace(language)
27
+ @marketplace[language.to_s]
28
+ end
29
+
30
+ def html_default
31
+ @html["default"]
32
+ end
33
+
34
+ def marketplace_default
35
+ @marketplace["default"]
36
+ end
37
+
38
+ def to_hash
39
+ {
40
+ "html" => @html,
41
+ "marketplace" => @marketplace
42
+ }
43
+ end
44
+
45
+ alias to_h to_hash
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class ImportResult
6
+ attr_reader :success, :imported, :updated, :skipped, :errors,
7
+ :values_imported, :values_updated
8
+
9
+ def initialize(
10
+ success:,
11
+ imported: 0,
12
+ updated: 0,
13
+ skipped: 0,
14
+ errors: [],
15
+ values_imported: nil,
16
+ values_updated: nil
17
+ )
18
+ @success = success
19
+ @imported = imported
20
+ @updated = updated
21
+ @skipped = skipped
22
+ @errors = errors
23
+ @values_imported = values_imported
24
+ @values_updated = values_updated
25
+ end
26
+
27
+ def self.from_hash(hash)
28
+ new(
29
+ success: hash["success"] || hash[:success] || true,
30
+ imported: hash["imported"] || hash[:imported] || 0,
31
+ updated: hash["updated"] || hash[:updated] || 0,
32
+ skipped: hash["skipped"] || hash[:skipped] || 0,
33
+ errors: hash["errors"] || hash[:errors] || [],
34
+ values_imported: hash["values_imported"] || hash[:values_imported],
35
+ values_updated: hash["values_updated"] || hash[:values_updated]
36
+ )
37
+ end
38
+
39
+ def errors?
40
+ !@errors.empty?
41
+ end
42
+
43
+ alias has_errors? errors?
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class LocalizedString
6
+ attr_reader :translations
7
+
8
+ def initialize(default_value = nil, translations: {})
9
+ @translations = translations.transform_keys(&:to_s)
10
+ @translations["default"] = default_value if default_value
11
+ end
12
+
13
+ def self.create(default_value)
14
+ new(default_value)
15
+ end
16
+
17
+ def self.from_hash(hash)
18
+ return nil if hash.nil?
19
+
20
+ instance = new
21
+ hash.each do |key, value|
22
+ instance.translations[key.to_s] = value
23
+ end
24
+ instance
25
+ end
26
+
27
+ def with_translation(language, value)
28
+ new_translations = @translations.dup
29
+ new_translations[language.to_s] = value
30
+ self.class.new(nil, translations: new_translations)
31
+ end
32
+
33
+ def default
34
+ @translations["default"]
35
+ end
36
+
37
+ def get(language)
38
+ @translations[language.to_s]
39
+ end
40
+
41
+ def to_hash
42
+ @translations.dup
43
+ end
44
+
45
+ alias to_h to_hash
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class PaginatedResponse
6
+ attr_reader :data, :current_page, :per_page, :total
7
+
8
+ def initialize(data:, current_page:, per_page:, total:)
9
+ @data = data
10
+ @current_page = current_page
11
+ @per_page = per_page
12
+ @total = total
13
+ end
14
+
15
+ def self.from_hash(hash, item_class)
16
+ items = (hash["data"] || hash[:data] || []).map do |item|
17
+ item_class.from_hash(item)
18
+ end
19
+
20
+ meta = hash["meta"] || hash[:meta] || {}
21
+
22
+ new(
23
+ data: items,
24
+ current_page: meta["current_page"] || meta[:current_page] || 1,
25
+ per_page: meta["per_page"] || meta[:per_page] || 100,
26
+ total: meta["total"] || meta[:total] || 0
27
+ )
28
+ end
29
+
30
+ def total_pages
31
+ return 0 if @per_page.zero?
32
+
33
+ (@total.to_f / @per_page).ceil
34
+ end
35
+
36
+ def more_pages?
37
+ @current_page < total_pages
38
+ end
39
+
40
+ alias has_more_pages? more_pages?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class ParameterValue
6
+ attr_reader :id, :value
7
+
8
+ def initialize(id:, value:)
9
+ @id = id
10
+ @value = value
11
+ end
12
+
13
+ def self.from_hash(hash)
14
+ new(
15
+ id: hash["id"] || hash[:id],
16
+ value: hash["value"] || hash[:value]
17
+ )
18
+ end
19
+
20
+ def to_hash
21
+ {
22
+ "id" => @id,
23
+ "value" => @value
24
+ }
25
+ end
26
+
27
+ alias to_h to_hash
28
+ end
29
+
30
+ class Parameter
31
+ attr_reader :id, :name, :values
32
+
33
+ def initialize(id:, name:, values: [])
34
+ @id = id
35
+ @name = name
36
+ @values = values
37
+ end
38
+
39
+ def self.from_hash(hash)
40
+ values = (hash["values"] || hash[:values] || []).map do |v|
41
+ ParameterValue.from_hash(v)
42
+ end
43
+
44
+ new(
45
+ id: hash["id"] || hash[:id],
46
+ name: hash["name"] || hash[:name],
47
+ values: values
48
+ )
49
+ end
50
+
51
+ def to_hash
52
+ {
53
+ "id" => @id,
54
+ "name" => @name,
55
+ "values" => @values.map(&:to_hash)
56
+ }
57
+ end
58
+
59
+ alias to_h to_hash
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class Product
6
+ attr_reader :id, :is_visible, :name, :url, :short_description, :description,
7
+ :seo_title, :seo_description, :content, :images, :categories_ids,
8
+ :parameters_ids, :guid, :is_loaded, :categories, :created_at, :updated_at
9
+
10
+ def initialize(
11
+ id:,
12
+ is_visible:,
13
+ name:,
14
+ url:,
15
+ short_description: nil,
16
+ description: nil,
17
+ seo_title: nil,
18
+ seo_description: nil,
19
+ content: nil,
20
+ images: [],
21
+ categories_ids: [],
22
+ parameters_ids: [],
23
+ guid: nil,
24
+ is_loaded: nil,
25
+ categories: [],
26
+ created_at: nil,
27
+ updated_at: nil
28
+ )
29
+ @id = id
30
+ @is_visible = is_visible
31
+ @name = name
32
+ @url = url
33
+ @short_description = short_description
34
+ @description = description
35
+ @seo_title = seo_title
36
+ @seo_description = seo_description
37
+ @content = content
38
+ @images = images
39
+ @categories_ids = categories_ids
40
+ @parameters_ids = parameters_ids
41
+ @guid = guid
42
+ @is_loaded = is_loaded
43
+ @categories = categories
44
+ @created_at = created_at
45
+ @updated_at = updated_at
46
+ end
47
+
48
+ def self.from_hash(hash)
49
+ new(
50
+ id: hash["id"] || hash[:id],
51
+ is_visible: hash["is_visible"] || hash[:is_visible],
52
+ name: LocalizedString.from_hash(hash["name"] || hash[:name]),
53
+ url: LocalizedString.from_hash(hash["url"] || hash[:url]),
54
+ short_description: LocalizedString.from_hash(hash["short_description"] || hash[:short_description]),
55
+ description: LocalizedString.from_hash(hash["description"] || hash[:description]),
56
+ seo_title: LocalizedString.from_hash(hash["seo_title"] || hash[:seo_title]),
57
+ seo_description: LocalizedString.from_hash(hash["seo_description"] || hash[:seo_description]),
58
+ content: Content.from_hash(hash["content"] || hash[:content]),
59
+ images: hash["images"] || hash[:images] || [],
60
+ categories_ids: hash["categories_ids"] || hash[:categories_ids] || [],
61
+ parameters_ids: hash["parameters_ids"] || hash[:parameters_ids] || [],
62
+ guid: hash["guid"] || hash[:guid],
63
+ is_loaded: hash["is_loaded"] || hash[:is_loaded],
64
+ categories: hash["categories"] || hash[:categories] || [],
65
+ created_at: parse_time(hash["created_at"] || hash[:created_at]),
66
+ updated_at: parse_time(hash["updated_at"] || hash[:updated_at])
67
+ )
68
+ end
69
+
70
+ def to_hash
71
+ data = {
72
+ "id" => @id,
73
+ "is_visible" => @is_visible,
74
+ "name" => @name&.to_hash,
75
+ "url" => @url&.to_hash
76
+ }
77
+
78
+ data["short_description"] = @short_description.to_hash if @short_description
79
+ data["description"] = @description.to_hash if @description
80
+ data["seo_title"] = @seo_title.to_hash if @seo_title
81
+ data["seo_description"] = @seo_description.to_hash if @seo_description
82
+ data["images"] = @images unless @images.empty?
83
+ data["categories_ids"] = @categories_ids unless @categories_ids.empty?
84
+ data["parameters_ids"] = @parameters_ids unless @parameters_ids.empty?
85
+
86
+ data
87
+ end
88
+
89
+ alias to_h to_hash
90
+
91
+ private
92
+
93
+ def self.parse_time(value)
94
+ return nil if value.nil?
95
+ return value if value.is_a?(Time)
96
+
97
+ Time.parse(value)
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module DTO
5
+ class WebhookPayload
6
+ attr_reader :event, :timestamp, :eshop_id
7
+
8
+ def initialize(event:, timestamp:, eshop_id:)
9
+ @event = event
10
+ @timestamp = timestamp
11
+ @eshop_id = eshop_id
12
+ end
13
+
14
+ def self.from_hash(hash)
15
+ timestamp = hash["timestamp"] || hash[:timestamp]
16
+ timestamp = Time.at(timestamp) if timestamp.is_a?(Integer)
17
+ timestamp = Time.parse(timestamp) if timestamp.is_a?(String)
18
+
19
+ new(
20
+ event: hash["event"] || hash[:event],
21
+ timestamp: timestamp,
22
+ eshop_id: hash["eshop_id"] || hash[:eshop_id]
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module Language
5
+ DEFAULT = "default"
6
+ CS = "cs"
7
+ SK = "sk"
8
+ EN = "en"
9
+ DE = "de"
10
+ PL = "pl"
11
+ HU = "hu"
12
+
13
+ ALL = [DEFAULT, CS, SK, EN, DE, PL, HU].freeze
14
+
15
+ def self.valid?(code)
16
+ ALL.include?(code)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ module WebhookEvent
5
+ PRODUCTS_UPDATE = "products.update"
6
+ CATEGORIES_UPDATE = "categories.update"
7
+
8
+ ALL = [PRODUCTS_UPDATE, CATEGORIES_UPDATE].freeze
9
+
10
+ def self.valid?(event)
11
+ ALL.include?(event)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ class Error < StandardError; end
5
+
6
+ class ApiError < Error
7
+ attr_reader :http_code, :response_body
8
+
9
+ def initialize(message, http_code: nil, response_body: nil)
10
+ @http_code = http_code
11
+ @response_body = response_body
12
+ super(message)
13
+ end
14
+
15
+ def self.unauthorized
16
+ new("Authorization token required", http_code: 401)
17
+ end
18
+
19
+ def self.from_response(http_code, body)
20
+ message = body.is_a?(Hash) ? (body["error"] || body["message"] || "API error") : "API error"
21
+ new(message, http_code: http_code, response_body: body)
22
+ end
23
+ end
24
+
25
+ class ValidationError < Error
26
+ attr_reader :errors
27
+
28
+ def initialize(message, errors: {})
29
+ @errors = errors
30
+ super(message)
31
+ end
32
+
33
+ def self.empty_payload
34
+ new("Payload cannot be empty")
35
+ end
36
+
37
+ def self.too_many_items(count, max)
38
+ new("Too many items: #{count} provided, maximum is #{max}")
39
+ end
40
+ end
41
+
42
+ class WebhookError < Error
43
+ def self.missing_signature
44
+ new("Missing webhook signature")
45
+ end
46
+
47
+ def self.invalid_signature
48
+ new("Invalid webhook signature")
49
+ end
50
+
51
+ def self.invalid_payload
52
+ new("Invalid webhook payload")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pobo
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module Pobo
7
+ class WebhookHandler
8
+ SIGNATURE_HEADER = "X-Webhook-Signature"
9
+
10
+ def initialize(webhook_secret:)
11
+ @webhook_secret = webhook_secret
12
+ end
13
+
14
+ # Handle webhook from Rack request
15
+ def handle_request(request)
16
+ payload = request.body.read
17
+ request.body.rewind if request.body.respond_to?(:rewind)
18
+
19
+ signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"] ||
20
+ request.get_header("HTTP_X_WEBHOOK_SIGNATURE") rescue nil
21
+
22
+ handle(payload: payload, signature: signature)
23
+ end
24
+
25
+ # Handle webhook with raw payload and signature
26
+ def handle(payload:, signature:)
27
+ raise WebhookError.missing_signature if signature.nil? || signature.empty?
28
+ raise WebhookError.invalid_signature unless valid_signature?(payload, signature)
29
+
30
+ data = JSON.parse(payload)
31
+ DTO::WebhookPayload.from_hash(data)
32
+ rescue JSON::ParserError
33
+ raise WebhookError.invalid_payload
34
+ end
35
+
36
+ private
37
+
38
+ def valid_signature?(payload, signature)
39
+ expected = OpenSSL::HMAC.hexdigest("SHA256", @webhook_secret, payload)
40
+ secure_compare(expected, signature)
41
+ end
42
+
43
+ def secure_compare(a, b)
44
+ return false unless a.bytesize == b.bytesize
45
+
46
+ OpenSSL.fixed_length_secure_compare(a, b)
47
+ rescue NoMethodError
48
+ # Fallback for older Ruby versions
49
+ a == b
50
+ end
51
+ end
52
+ end
data/lib/pobo.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pobo/version"
4
+ require_relative "pobo/errors"
5
+
6
+ require_relative "pobo/enum/language"
7
+ require_relative "pobo/enum/webhook_event"
8
+
9
+ require_relative "pobo/dto/localized_string"
10
+ require_relative "pobo/dto/content"
11
+ require_relative "pobo/dto/product"
12
+ require_relative "pobo/dto/category"
13
+ require_relative "pobo/dto/blog"
14
+ require_relative "pobo/dto/parameter"
15
+ require_relative "pobo/dto/import_result"
16
+ require_relative "pobo/dto/paginated_response"
17
+ require_relative "pobo/dto/webhook_payload"
18
+
19
+ require_relative "pobo/client"
20
+ require_relative "pobo/webhook_handler"
21
+
22
+ module Pobo
23
+ class << self
24
+ # Convenience method to create a client
25
+ def client(api_token:, base_url: Client::DEFAULT_BASE_URL, timeout: Client::DEFAULT_TIMEOUT)
26
+ Client.new(api_token: api_token, base_url: base_url, timeout: timeout)
27
+ end
28
+
29
+ # Convenience method to create a webhook handler
30
+ def webhook_handler(webhook_secret:)
31
+ WebhookHandler.new(webhook_secret: webhook_secret)
32
+ end
33
+ end
34
+ end
data/pobo.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/pobo/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "pobo-sdk"
7
+ spec.version = Pobo::VERSION
8
+ spec.authors = ["Pobo"]
9
+ spec.email = ["tomas@pobo.cz"]
10
+
11
+ spec.summary = "Official Ruby SDK for Pobo API V2"
12
+ spec.description = "Ruby SDK for Pobo API V2 - product content management and webhooks"
13
+ spec.homepage = "https://github.com/pobo-builder/ruby-sdk"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) ||
24
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
25
+ end
26
+ end
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_dependency "faraday", "~> 2.0"
30
+ spec.add_dependency "faraday-net_http", "~> 3.0"
31
+
32
+ spec.add_development_dependency "rake", "~> 13.0"
33
+ spec.add_development_dependency "rspec", "~> 3.0"
34
+ spec.add_development_dependency "webmock", "~> 3.0"
35
+ spec.add_development_dependency "rubocop", "~> 1.0"
36
+ end