erp_integration 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/rake ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rake' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('bundle', __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('rake', 'rake')
data/bin/reek ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'reek' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('bundle', __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('reek', 'reek')
data/bin/release ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ VERSION=$1
3
+
4
+ # Build the new gem and publish it to Rubygems.
5
+ gem build erp_integration.gemspec
6
+ gem push "erp_integration-$VERSION.gem" --host https://rubygems.org
7
+ rm "erp_integration-$VERSION.gem"
data/bin/rspec ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('bundle', __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('rspec-core', 'rspec')
data/bin/rubocop ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rubocop' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('bundle', __dir__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('rubocop', 'rubocop')
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/erp_integration/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'erp_integration'
7
+ spec.version = ErpIntegration::VERSION
8
+ spec.authors = ['Stefan Vermaas']
9
+ spec.email = ['stefan@knowndecimal.com']
10
+
11
+ spec.summary = 'Connects Mejuri with third-party ERP vendors'
12
+ spec.license = 'MIT'
13
+ spec.homepage = 'https://www.github.com/mejuri-inc/erp-integration'
14
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.3.7')
15
+
16
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://www.github.com/mejuri-inc/erp-integration'
20
+ spec.metadata['changelog_uri'] = 'https://www.github.com/mejuri-inc/erp-integration/blob/main/CHANGELOG.md'
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
26
+ end
27
+ spec.bindir = 'exe'
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ['lib']
30
+
31
+ # ActiveSupport is used for extended support of String inflections.
32
+ spec.add_dependency 'activesupport', '>= 0.12'
33
+
34
+ # Faraday is a HTTP/REST API client library to interact with third-party API vendors
35
+ spec.add_dependency 'faraday', '>= 0.17.3', '< 1.8.0'
36
+ spec.add_dependency 'faraday_middleware', '~> 0.14.0'
37
+
38
+ # Development dependencies
39
+ spec.add_development_dependency 'byebug', '~> 11.0'
40
+ spec.add_development_dependency 'flay', '~> 2.12', '>= 2.12.1'
41
+ spec.add_development_dependency 'github_changelog_generator', '~> 1.15.2'
42
+ spec.add_development_dependency 'rake', '~> 13.0'
43
+ spec.add_development_dependency 'reek', '< 6.0'
44
+ spec.add_development_dependency 'rspec', '~> 3.10'
45
+ spec.add_development_dependency 'rubocop', '< 0.82.0'
46
+ spec.add_development_dependency 'rubocop-rake', '~> 0.5'
47
+ spec.add_development_dependency 'rubocop-rspec', '< 1.39.0'
48
+ spec.add_development_dependency 'webmock', '~> 3.14.0'
49
+
50
+ # The `parallel` gem is a dev dependency for Rubocop. However, the versions
51
+ # for parallel after 1.19.2 don't work with ruby 2.3.x. As ruby 2.3.x is
52
+ # required for Mejuri, we're manually locking it to stay on `1.19.2`.
53
+ spec.add_development_dependency 'parallel', '1.19.2'
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday_middleware'
4
+
5
+ module ErpIntegration
6
+ module Clients
7
+ # The `FulfilClient` is a simple HTTP client configured to be used for API
8
+ # requests to the Fulfil endpoints.
9
+ class FulfilClient
10
+ attr_reader :api_key, :merchant_id
11
+ attr_writer :connection, :faraday_adapter
12
+
13
+ def initialize(api_key:, merchant_id:)
14
+ @api_key = api_key
15
+ @merchant_id = merchant_id
16
+ end
17
+
18
+ # Generates the url prefix for the Faraday connection client.
19
+ # @return [String] The base url for the Fulfil HTTP client
20
+ def base_url
21
+ "https://#{merchant_id}.fulfil.io/"
22
+ end
23
+
24
+ # Sets the default adapter for the Faraday Connection.
25
+ # @return [Symbol] The default Faraday adapter
26
+ def faraday_adapter
27
+ @faraday_adapter ||= Faraday.default_adapter
28
+ end
29
+
30
+ # Sets up the Faraday connection to talk to the Fulfil API.
31
+ # @return [Faraday::Connection] The configured Faraday connection
32
+ def connection
33
+ @connection ||= Faraday.new(url: base_url) do |faraday|
34
+ faraday.headers = default_headers
35
+
36
+ faraday.request :json # Encode request bodies as JSON
37
+ faraday.request :retry # Retry transient failures
38
+
39
+ faraday.response :follow_redirects
40
+ faraday.response :json # Decode response bodies as JSON
41
+
42
+ # Custom error handling for the error response
43
+ faraday.use ErpIntegration::Middleware::ErrorHandling
44
+
45
+ # Adapter definition should be last in order to make the json parsers be loaded correctly
46
+ faraday.adapter faraday_adapter
47
+ end
48
+ end
49
+
50
+ %i[delete get patch put post].each do |action_name|
51
+ define_method(action_name) do |path, options = {}|
52
+ connection.public_send(action_name, "api/#{version}/#{path}", options).body
53
+ end
54
+ end
55
+
56
+ # Sets the default version for the Fulfil API endpoints.
57
+ # @return [String] The Fulfil API version to be used
58
+ def version
59
+ @version ||= 'v2'
60
+ end
61
+
62
+ private
63
+
64
+ def default_headers
65
+ {
66
+ 'Accept': 'application/json',
67
+ 'Content-Type': 'application/json',
68
+ 'X-API-KEY': api_key
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ # Use the `Configuration` class to configure the ERP Integration gem. Use
5
+ # an initializer in your project configure the ERP Integration gem.
6
+ #
7
+ # @example
8
+ # ```ruby
9
+ # # config/initializers/erp_integration.rb
10
+ # ErpIntegration.configure do |config|
11
+ # config.fulfil_api_key = "..."
12
+ # end
13
+ # ```
14
+ class Configuration
15
+ # The `fulfil_api_key` is used by the `FulfilClient` to authorize the
16
+ # requests to the Fulfil API endpoints.
17
+ # @return [String] The API key for Fulfil.
18
+ attr_accessor :fulfil_api_key
19
+
20
+ # The `fulfil_merchant_id` is used by the `FulfilClient` to connect to
21
+ # the right Fulfil API endpoints.
22
+ # @return [String] The merchant ID for Fulfil.
23
+ attr_accessor :fulfil_merchant_id
24
+
25
+ # Allows configuring an adapter for the `Order` resource. When none is
26
+ # configured, it will default to Fulfil.
27
+ # @return [Symbol] The configured adapter for the orders
28
+ attr_writer :order_adapter
29
+
30
+ def initialize(**options)
31
+ options.each_pair do |key, value|
32
+ public_send("#{key}=", value) if respond_to?("#{key}=")
33
+ end
34
+ end
35
+
36
+ def order_adapter
37
+ @order_adapter || :fulfil
38
+ end
39
+ end
40
+
41
+ # Returns ERP Integration's configuration.
42
+ # @return [ErpIntegration::Configuration] ERP Integration's configuration
43
+ def self.config
44
+ @config ||= Configuration.new
45
+ end
46
+
47
+ # Allows setting a new configuration for the ERP Integration gem.
48
+ # @return [ErpIntegration::Configuration] ERP Integration's new configuration
49
+ def self.config=(configuration)
50
+ raise BadConfiguration unless configuration.is_a?(Configuration)
51
+
52
+ @config = configuration
53
+ end
54
+
55
+ # Allows modifying ERP Integration's configuration.
56
+ #
57
+ # @example
58
+ # ErpIntegration.configure do |config|
59
+ # config.some_api_key = "..."
60
+ # end
61
+ #
62
+ def self.configure
63
+ yield(config)
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ # The `ErpIntegration::Error` is the default error class for any exception raised
5
+ # on purpose by this gem.
6
+ class Error < StandardError; end
7
+
8
+ # The `ErpIntegration::BadConfiguration` is raised whenever the newly provided
9
+ # configuration is not a valid configuration object.
10
+ class BadConfiguration < Error
11
+ def initialize
12
+ super(
13
+ '[ERP Integration] The provided configuration object is not a ' \
14
+ 'ErpIntegration::Configuration instance. Please provide a ' \
15
+ 'ErpIntegration::Configuration instance when overriding the configuration directly.'
16
+ )
17
+ end
18
+ end
19
+
20
+ class HttpError < Faraday::Error
21
+ class BadRequest < HttpError; end
22
+ class AuthorizationRequired < HttpError; end
23
+ class PaymentRequired < HttpError; end
24
+ class Forbidden < HttpError; end
25
+ class NotFound < HttpError; end
26
+ class MethodNotAllowed < HttpError; end
27
+ class NotAccepted < HttpError; end
28
+ class UnprocessableEntity < HttpError; end
29
+ class TooManyRequests < HttpError; end
30
+ class InternalServerError < HttpError; end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ module Middleware
5
+ # The `ErrorHandling` middleware allows to raise our own exceptions that
6
+ # are easier to catch for the host application (e.g. `mejuri-web`).
7
+ class ErrorHandling < Faraday::Response::Middleware
8
+ HTTP_ERROR_CODES = {
9
+ 400 => ErpIntegration::HttpError::BadRequest,
10
+ 401 => ErpIntegration::HttpError::AuthorizationRequired,
11
+ 402 => ErpIntegration::HttpError::PaymentRequired,
12
+ 403 => ErpIntegration::HttpError::Forbidden,
13
+ 404 => ErpIntegration::HttpError::NotFound,
14
+ 405 => ErpIntegration::HttpError::MethodNotAllowed,
15
+ 406 => ErpIntegration::HttpError::NotAccepted,
16
+ 422 => ErpIntegration::HttpError::UnprocessableEntity,
17
+ 429 => ErpIntegration::HttpError::TooManyRequests,
18
+ 500 => ErpIntegration::HttpError::InternalServerError
19
+ }.freeze
20
+
21
+ def on_complete(response)
22
+ key = response[:status].to_i
23
+ raise HTTP_ERROR_CODES[key], response_values(response) if HTTP_ERROR_CODES.key?(key)
24
+ end
25
+
26
+ def response_values(response)
27
+ { status: response.status, headers: response.response_headers, body: response.body }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ # The `Order` class exposes available ERP Order operations
5
+ # assigning them to the proper adapter
6
+ class Order < Resource
7
+ attr_accessor :id, :number
8
+
9
+ # Allows cancelling the entire sales order.
10
+ # @param id [Integer|String] The ID of the to be cancelled order.
11
+ # @return [boolean] Whether the sales order was cancelled successfully or not.
12
+ def self.cancel(id)
13
+ adapter.new.cancel(id)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ module Orders
5
+ # The `FulfilOrder` handles all interaction with sales orders in Fulfil.
6
+ class FulfilOrder
7
+ extend Forwardable
8
+ def_delegator 'ErpIntegration', :config
9
+
10
+ # Allows cancelling the entire sales order in Fulfil.
11
+ # @param id [Integer|String] The ID of the to be cancelled order.
12
+ # @return [boolean] Whether the sales order was cancelled successfully or not.
13
+ def cancel(id)
14
+ client.put("model/sale.sale/#{id}/cancel")
15
+ true
16
+
17
+ # Fulfil will return an 400 (a.k.a. "Bad Request") status code when a sales order couldn't
18
+ # be cancelled. If a sales order isn't cancellable by design, no exception should be raised.
19
+ #
20
+ # See the Fulfil's documentation for more information:
21
+ # https://developers.fulfil.io/rest_api/model/sale.sale/#cancel-a-sales-order
22
+ rescue ErpIntegration::HttpError::BadRequest
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def client
29
+ @client ||= Clients::FulfilClient.new(
30
+ api_key: config.fulfil_api_key,
31
+ merchant_id: config.fulfil_merchant_id
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErpIntegration
4
+ # The `ErpIntegration::Resource` is a generic, re-usable model for third-party sources.
5
+ #
6
+ # For the `ErpIntegration::Resource`, we're using the adapter pattern.
7
+ # Meaning; the `ErpIntegration::Resource` is a facade that delegates the
8
+ # actual work to an adapter.
9
+ #
10
+ # Every class that inherits from the `ErpIntegration::Resource`, will be able
11
+ # to configure a designated adapter. This allows configuring an adapter
12
+ # per resource to maximize the flexibility.
13
+ #
14
+ # @example
15
+ # ErpIntegration.configure do |config|
16
+ # config.order_adapter = :fulfil
17
+ # end
18
+ #
19
+ # $ ErpIntegration::Order.adapter
20
+ # => #<ErpIntegration::Orders::FulfilOrder />
21
+ #
22
+ # To add a new resource, follow these steps:
23
+ # 1. Add a new `attr_writer` in the `ErpIntegration::Configuration` class.
24
+ # 2. Add a new method to the `ErpIntegration::Configuration` that sets up the
25
+ # default adapter.
26
+ # 3. Create a new generic resource model in the `lib/erp_integration` folder.
27
+ # 4. Create a new pluralized folder name in the `lib/erp_integration` folder
28
+ # (e.g. `orders` for the `Order` resource).
29
+ # 5. Create a new adapter class prefixed with the adapter's name
30
+ # (e.g. `FulfilOrder` for the `Order` resource in the `lib/erp_integration/orders` folder).
31
+ class Resource
32
+ attr_accessor :raw_api_response
33
+
34
+ def initialize(attributes = {})
35
+ @raw_api_response = attributes
36
+
37
+ attributes.each_pair do |name, value|
38
+ public_send(:"#{name}=", value) if respond_to?(:"#{name}=")
39
+ end
40
+ end
41
+
42
+ class << self
43
+ # Dynamically defines and loads the adapter for the class inheriting from
44
+ # the `ErpIntegration::Resource`.
45
+ # @return [Class] The adapter class for the resource.
46
+ def adapter
47
+ return @adapter if defined?(@adapter)
48
+
49
+ require_relative File.join(File.dirname(__FILE__), '..', "#{adapter_path}.rb")
50
+ @adapter = adapter_klass.constantize
51
+ end
52
+
53
+ # Dynamically exposes the adapter class to the resource.
54
+ # @return [String] the adapter class as a string
55
+ def adapter_klass
56
+ "ErpIntegration::#{adapter_path.classify}"
57
+ end
58
+
59
+ # Retrieves the adapter type for the resource from the global configuration.
60
+ # @return [String] the adapter type for the resource
61
+ def adapter_type
62
+ ErpIntegration.config.send("#{resource_name}_adapter").to_s
63
+ end
64
+
65
+ # Provides a relative path to the adapter for the resource.
66
+ # @return [String] the path to the adapter
67
+ def adapter_path
68
+ "#{resource_name.pluralize}/#{adapter_type}_#{resource_name}"
69
+ end
70
+
71
+ # Derives the name of the resource from the class name.
72
+ # @return [String] the resource name
73
+ def resource_name
74
+ name.split('::').last.underscore
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,3 @@
1
+ module ErpIntegration
2
+ VERSION = "0.2.0".freeze
3
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/string/inflections'
5
+ require 'faraday'
6
+ require 'json'
7
+
8
+ require_relative 'erp_integration/version'
9
+ require_relative 'erp_integration/errors'
10
+ require_relative 'erp_integration/configuration'
11
+
12
+ # Middleware
13
+ require_relative 'erp_integration/middleware/error_handling'
14
+
15
+ # Resources
16
+ require_relative 'erp_integration/resource'
17
+ require_relative 'erp_integration/order'
18
+
19
+ # HTTP clients
20
+ require_relative 'erp_integration/clients/fulfil_client'
21
+
22
+ # The `ErpIntegration` integrates Mejuri with third-party ERP vendors.
23
+ module ErpIntegration
24
+ end