diff --git a/lib/intelligent_foods.rb b/lib/intelligent_foods.rb index a2a9d78..cc5704b 100644 --- a/lib/intelligent_foods.rb +++ b/lib/intelligent_foods.rb @@ -7,7 +7,9 @@ require "intelligent_foods/api_client" require "intelligent_foods/api/v1" +require "intelligent_foods/api/v2" require "intelligent_foods/api/v1/authenticator" +require "intelligent_foods/api/v2/authenticator" require "intelligent_foods/api_operations/retrieve" require "intelligent_foods/authorization/base" require "intelligent_foods/authorization/basic" @@ -15,13 +17,19 @@ require "intelligent_foods/resources/api_error" require "intelligent_foods/authorization/bearer" require "intelligent_foods/resources/object" +require "intelligent_foods/resources/core_object" +require "intelligent_foods/resources/allergen" +require "intelligent_foods/resources/dietary_tag" require "intelligent_foods/resources/inventory_level" +require "intelligent_foods/resources/ingredient" require "intelligent_foods/resources/order" require "intelligent_foods/resources/order_item" require "intelligent_foods/serializers/order_item_serializer" require "intelligent_foods/resources/menu" require "intelligent_foods/resources/menu_item" require "intelligent_foods/serializers/menu_item_serializer" +require "intelligent_foods/resources/nutrition_fact" +require "intelligent_foods/resources/product" require "intelligent_foods/resources/recipient" require "intelligent_foods/serializers/recipient_serializer" require "intelligent_foods/version" @@ -31,7 +39,7 @@ module IntelligentFoods class Error < StandardError; end class << self - attr_accessor :client_id, :client_secret, :environment + attr_accessor :client_id, :client_secret, :environment, :username, :password def configure yield self diff --git a/lib/intelligent_foods/api/v2.rb b/lib/intelligent_foods/api/v2.rb new file mode 100644 index 0000000..61f1a2b --- /dev/null +++ b/lib/intelligent_foods/api/v2.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module IntelligentFoods + class V2 + attr_accessor :authentication + attr_reader :environment, :username, :password + + PRODUCTION = "production" + STAGING = "staging" + + def initialize(username: nil, password: nil, authentication: nil, + environment: STAGING) + @authentication = authentication + @environment = environment + @username = username + @password = password + end + + def base_url + "https://core.intelligentfoods.#{tld}" + end + + def authenticate! + return if authenticated? + + authenticator = V2::Authenticator.build(self) + @authentication = authenticator.save! + end + + def client + build_api_client! + end + + def authenticated? + authentication.present? + end + + def reset_authentication + @authentication = nil + self + end + + def self.build(config: IntelligentFoods) + new(environment: config.environment, + username: config.username, + password: config.password) + end + + protected + + def build_api_client! + authenticate! + IntelligentFoods::ApiClient.build(self) + end + + def tld + @tld ||= determine_tld + end + + def determine_tld + if environment == PRODUCTION + "com" + else + "dev" + end + end + end +end diff --git a/lib/intelligent_foods/api/v2/authenticator.rb b/lib/intelligent_foods/api/v2/authenticator.rb new file mode 100644 index 0000000..57dc998 --- /dev/null +++ b/lib/intelligent_foods/api/v2/authenticator.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module IntelligentFoods + class V2::Authenticator + attr_accessor :base_url, :authentication + + def initialize(base_url:, authentication:) + @base_url = base_url + @authentication = authentication + end + + def save! + path = "#{base_url}/auth/info" + client = IntelligentFoods::ApiClient.new(authentication: authentication) + response = client.get(path: path) + handle_authentication_response!(response: response) + end + + def self.build(api) + authentication = Authorization::Basic. + factory(client_id: api.username, + client_secret: api.password) + new(base_url: api.base_url, authentication: authentication) + end + + protected + + def handle_authentication_response!(response:) + if response.success? + authentication + else + handle_errors! + end + end + + def response_has_errors?(response) + response.has_key?(:error) + end + + def handle_errors! + raise AuthenticationError.new(status: 401, title: "Authentication Failed") + end + end +end diff --git a/lib/intelligent_foods/resources/allergen.rb b/lib/intelligent_foods/resources/allergen.rb new file mode 100644 index 0000000..5fb9938 --- /dev/null +++ b/lib/intelligent_foods/resources/allergen.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module IntelligentFoods + class Allergen < IntelligentFoods::CoreObject; end +end diff --git a/lib/intelligent_foods/resources/api_error.rb b/lib/intelligent_foods/resources/api_error.rb index a45f679..a3b99bd 100644 --- a/lib/intelligent_foods/resources/api_error.rb +++ b/lib/intelligent_foods/resources/api_error.rb @@ -21,7 +21,9 @@ def message def self.build(response) data = response.data - data => { status:, title:, details: } + status = data.fetch(:status, data[:type]) + title = data.fetch(:title, data[:message]) + details = data[:details] new(status: status, title: title, details: details) end diff --git a/lib/intelligent_foods/resources/core_object.rb b/lib/intelligent_foods/resources/core_object.rb new file mode 100644 index 0000000..822cf8c --- /dev/null +++ b/lib/intelligent_foods/resources/core_object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module IntelligentFoods + class CoreObject < IntelligentFoods::Object + def resources_path + "#{api.base_url}/api/#{object_name.downcase}" + end + + def resource_path + "#{api.base_url}/api/#{object_name.downcase}/#{id}" + end + + def api + @api ||= IntelligentFoods::V2.build + end + end +end diff --git a/lib/intelligent_foods/resources/dietary_tag.rb b/lib/intelligent_foods/resources/dietary_tag.rb new file mode 100644 index 0000000..f152ce2 --- /dev/null +++ b/lib/intelligent_foods/resources/dietary_tag.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module IntelligentFoods + class DietaryTag < IntelligentFoods::CoreObject; end +end diff --git a/lib/intelligent_foods/resources/ingredient.rb b/lib/intelligent_foods/resources/ingredient.rb new file mode 100644 index 0000000..7283d98 --- /dev/null +++ b/lib/intelligent_foods/resources/ingredient.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module IntelligentFoods + class Ingredient < IntelligentFoods::CoreObject; end +end diff --git a/lib/intelligent_foods/resources/nutrition_fact.rb b/lib/intelligent_foods/resources/nutrition_fact.rb new file mode 100644 index 0000000..0437c11 --- /dev/null +++ b/lib/intelligent_foods/resources/nutrition_fact.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module IntelligentFoods + class NutritionFact < IntelligentFoods::CoreObject; end +end diff --git a/lib/intelligent_foods/resources/object.rb b/lib/intelligent_foods/resources/object.rb index 653944d..0d058b9 100644 --- a/lib/intelligent_foods/resources/object.rb +++ b/lib/intelligent_foods/resources/object.rb @@ -8,6 +8,10 @@ def self.build(data) JSON.parse(data.to_json, object_class: self) end + def self.build_from_array(array) + array.map { |item| build(item) } + end + def resources_path "#{api.base_url}/#{object_name.downcase}" end diff --git a/lib/intelligent_foods/resources/product.rb b/lib/intelligent_foods/resources/product.rb new file mode 100644 index 0000000..3ff1684 --- /dev/null +++ b/lib/intelligent_foods/resources/product.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module IntelligentFoods + class Product < IntelligentFoods::CoreObject + def initialize(args = {}) + super + end + + def object_name + "products" + end + + def self.build_from_response(data) + product = build(data) + product.ingredients = Ingredient.build_from_array(data[:ingredients]) + product.nutrition_facts = NutritionFact. + build_from_array(data[:nutrition_facts]) + product.nutrition_facts_cooked = NutritionFact. + build_from_array( + data.fetch(:nutrition_facts_cooked, []) + ) + product.allergens = Allergen.build_from_array(data[:allergens]) + product.dietary_tags = DietaryTag.build_from_array(data[:dietary_tags]) + product + end + end +end diff --git a/lib/intelligent_foods/testing/fake.rb b/lib/intelligent_foods/testing/fake.rb index 64dcda8..eac89d5 100644 --- a/lib/intelligent_foods/testing/fake.rb +++ b/lib/intelligent_foods/testing/fake.rb @@ -5,6 +5,12 @@ module Testing class Fake def self.configure(*); end + class Allergen + def self.new(code: "MILK", source: "ANCHOVY") + OpenStruct.new(code: code, source: source) + end + end + class DistributionCenterInventory def self.create(name: "WEST_COAST", quantity: 100) OpenStruct.new(distribution_center: name, quantity: quantity) @@ -89,6 +95,15 @@ def self.new(sku:, quantity:, protein_sku:) end end + class Product + def self.new(code:, name:, status: "DRAFT", nutrition_facts: []) + OpenStruct.new(code: code, name: name, status: status, + nutrition_facts: nutrition_facts, + allergens: [Fake::Allergen.new], + dietary_tags: ["NUTS"]) + end + end + def self.client Fake::Client.new end diff --git a/spec/intelligent_foods/api/v2/authenticator_spec.rb b/spec/intelligent_foods/api/v2/authenticator_spec.rb new file mode 100644 index 0000000..b94f28a --- /dev/null +++ b/spec/intelligent_foods/api/v2/authenticator_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.describe IntelligentFoods::V2::Authenticator do + describe "#save!" do + it "returns a authorization scheme" do + basic = IntelligentFoods::Authorization::Basic.new(token: "test") + auth = IntelligentFoods::V2::Authenticator. + new(base_url: "http://test.com", authentication: basic) + stub_authentication + + result = auth.save! + + expect(result).to respond_to(:header) + end + + context "when authentication fails" do + it "raises a AuthenticationError" do + basic = IntelligentFoods::Authorization::Basic.new(token: "test") + auth = IntelligentFoods::V2::Authenticator. + new(base_url: "http://test.com", authentication: basic) + response_body = { error: "Authentication Failed" } + response = error_response(body: response_body) + stub_api_response response: response + + expect { + auth.save! + }.to raise_error(IntelligentFoods::AuthenticationError) + end + end + end + + describe ".build" do + it "sets the base_url" do + api = IntelligentFoods::V2.new + + result = IntelligentFoods::V2::Authenticator.build(api) + + expect(result.base_url).to eq(api.base_url) + end + + it "sets the authentication" do + api = IntelligentFoods::V2.new + basic = IntelligentFoods::Authorization::Basic.new(token: "test") + allow(IntelligentFoods::Authorization::Basic).to receive(:factory). + and_return(basic) + + result = IntelligentFoods::V2::Authenticator.build(api) + + expect(result.authentication).to eq(basic) + end + end + + def build_config(environment: "staging", client_id: "cid_123", + client_secret: "cs_123") + OpenStruct.new(environment: environment, client_id: client_id, + client_secret: client_secret) + end +end diff --git a/spec/intelligent_foods/api/v2_spec.rb b/spec/intelligent_foods/api/v2_spec.rb new file mode 100644 index 0000000..72c35c3 --- /dev/null +++ b/spec/intelligent_foods/api/v2_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +RSpec.describe IntelligentFoods::V2 do + describe "#base_url" do + context "when the environment is production" do + it "returns the correct url" do + api = IntelligentFoods::V2.new(environment: "production") + + result = api.base_url + + expect(result).to eq("https://core.intelligentfoods.com") + end + end + + context "when the environment is not production" do + it "returns the correct url" do + api = IntelligentFoods::V2.new(environment: "staging") + + result = api.base_url + + expect(result).to eq("https://core.intelligentfoods.dev") + end + end + end + + describe "#client" do + it "returns a api client" do + api = IntelligentFoods::V2.new + build_stubbed_authenticator(api) + + result = api.client + + expect(result).to respond_to(:post) + end + end + + describe "#authenticate" do + it "builds a authenticator" do + api = IntelligentFoods::V2.new + build_stubbed_authenticator(api) + + api.authenticate! + + expect(IntelligentFoods::V2::Authenticator).to have_received(:build).once + end + + it "authenticates" do + api = IntelligentFoods::V2.new + authenticator = build_stubbed_authenticator(api) + + api.authenticate! + + expect(authenticator).to have_received(:save!).once + end + + context "when the authentication is present" do + it "is authenticated" do + api = IntelligentFoods::V2.new + build_stubbed_authenticator(api) + + api.authenticate! + + expect(api).to be_authenticated + end + end + end + + describe "reset authentication" do + it "removes the present authentication" do + auth = IntelligentFoods::Authorization::Basic.new(token: "1234") + api = IntelligentFoods::V2.new(authentication: auth) + + result = api.reset_authentication + + expect(result.authentication).to be_blank + end + + it "is not authenticated" do + auth = IntelligentFoods::Authorization::Basic.new(token: "1234") + api = IntelligentFoods::V2.new(authentication: auth) + + result = api.reset_authentication + + expect(result).not_to be_authenticated + end + end + + describe ".build" do + it "sets the environment" do + config = build_config(environment: "preview") + + result = IntelligentFoods::V2.build(config: config) + + expect(result.environment).to eq("preview") + end + + it "sets the username" do + config = build_config(username: "secretUsername") + + result = IntelligentFoods::V2.build(config: config) + + expect(result.username).to eq("secretUsername") + end + + it "sets the password" do + config = build_config(password: "secretPassword") + + result = IntelligentFoods::V2.build(config: config) + + expect(result.password).to eq("secretPassword") + end + end + + def build_config(environment: "staging", username: "string", + password: "string") + OpenStruct.new(environment: environment, username: username, + password: password) + end + + def build_stubbed_authenticator(api) + authenticator = IntelligentFoods::V2::Authenticator.build(api) + authentication = authenticator.authentication + client = IntelligentFoods::ApiClient.new(authentication: authentication) + allow(IntelligentFoods::V2::Authenticator).to receive(:build). + and_return(authenticator) + allow(authenticator).to receive(:save!).and_return(client) + authenticator + end +end diff --git a/spec/intelligent_foods/resources/product_spec.rb b/spec/intelligent_foods/resources/product_spec.rb new file mode 100644 index 0000000..908e17e --- /dev/null +++ b/spec/intelligent_foods/resources/product_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe IntelligentFoods::Product do + describe ".retrieve" do + it "returns the product" do + product_id = "MP0527" + body = build_product_response(product_id: product_id) + response = build_response(body: body) + stub_api_v2_authentication + stub_api_response response: response + + result = IntelligentFoods::Product.retrieve(product_id) + + expect(result.code).to eq(product_id) + end + end +end diff --git a/spec/support/fixtures/product_response.json b/spec/support/fixtures/product_response.json new file mode 100644 index 0000000..c9c4fa7 --- /dev/null +++ b/spec/support/fixtures/product_response.json @@ -0,0 +1,44 @@ +{ + "code": "string", + "name": "string", + "status": "DRAFT", + "refrigeration_type": "NONE", + "refrigeration_level": "STANDARD", + "unit_volume": { + "value": 0.1 + }, + "unit_slots": { + "value": 0.1 + }, + "ingredients": [ + { + "inventory_code": "string", + "name": "string", + "quantity": 0.1, + "unit": "string" + } + ], + "nutrition_facts": [ + { + "code": "CALORIES", + "amount": 0, + "unit": "string" + } + ], + "nutrition_facts_cooked": [ + { + "code": "CALORIES", + "amount": 0, + "unit": "string" + } + ], + "allergens": [ + { + "code": "MILK", + "source": "ANCHOVY" + } + ], + "dietary_tags": [ + "PESCATARIAN" + ] +} diff --git a/spec/support/helpers/api_helper.rb b/spec/support/helpers/api_helper.rb index 67422ac..061c843 100644 --- a/spec/support/helpers/api_helper.rb +++ b/spec/support/helpers/api_helper.rb @@ -4,6 +4,8 @@ module ApiHelper ERROR_API_RESPONSE = "spec/support/fixtures/error_response.json".freeze INVENTORY_LEVELS_API_RESPONSE = "spec/support/fixtures/inventory_levels_response.json".freeze + PRODUCT_API_RESPONSE = + "spec/support/fixtures/product_response.json".freeze def authentication_response(access_token:) build_response body: { access_token: access_token } @@ -28,6 +30,12 @@ def stub_api_v1_authentication(authenticated: true) allow(api).to receive(:authenticated?).and_return(authenticated) end + def stub_api_v2_authentication(authenticated: true) + api = IntelligentFoods::V2.build + allow(IntelligentFoods::V2).to receive(:new).and_return(api) + allow(api).to receive(:authenticated?).and_return(authenticated) + end + def stub_api_response(response: OpenStruct.new(body: "{}", code: 200), http: double) allow(Net::HTTP).to receive(:start).and_yield(http) @@ -64,6 +72,10 @@ def read_inventory_levels_api_response parse_json_file(INVENTORY_LEVELS_API_RESPONSE) end + def read_product_api_response + parse_json_file(PRODUCT_API_RESPONSE) + end + def build_menu_response(menu_id: "2023-01-01", menu_items: []) stubbed_response = read_menu_api_response stubbed_response[:id] = menu_id @@ -79,6 +91,12 @@ def build_inventory_levels_response read_inventory_levels_api_response end + def build_product_response(product_id: "string") + stubbed_response = read_product_api_response + stubbed_response[:code] = product_id + stubbed_response + end + def stubbed_menu_items response = read_menu_api_response response[:items]