From c99e65fae80594a65ce0bb298382e142ae460226 Mon Sep 17 00:00:00 2001 From: Rohan Likhite Date: Fri, 8 May 2026 11:49:57 -0400 Subject: [PATCH] Introduce Product Resource Version 2 of the api is now available, and introduces the Product resource. We'd like to expose this via the gem, allowing our clients to consume this new resource. The Product resource will be retrieved via ID using a different api, version 2. This new API utilizes a basic authentication scheme. Since this is the first resource to be consumed via the v2 API this PR not only introduces Products, it also adds all the plumbing required to interact with the new api version. This change addresses the need by: * Introducing a v2 authenticator * Introducing a v2 api * Introducing a CoreObject resource which can be used by all core (v2) objects * Introducing a Product resource --- lib/intelligent_foods.rb | 10 +- lib/intelligent_foods/api/v2.rb | 68 +++++++++ lib/intelligent_foods/api/v2/authenticator.rb | 44 ++++++ lib/intelligent_foods/resources/allergen.rb | 5 + lib/intelligent_foods/resources/api_error.rb | 4 +- .../resources/core_object.rb | 17 +++ .../resources/dietary_tag.rb | 5 + lib/intelligent_foods/resources/ingredient.rb | 5 + .../resources/nutrition_fact.rb | 5 + lib/intelligent_foods/resources/object.rb | 4 + lib/intelligent_foods/resources/product.rb | 27 ++++ lib/intelligent_foods/testing/fake.rb | 15 ++ .../api/v2/authenticator_spec.rb | 58 ++++++++ spec/intelligent_foods/api/v2_spec.rb | 129 ++++++++++++++++++ .../resources/product_spec.rb | 17 +++ spec/support/fixtures/product_response.json | 44 ++++++ spec/support/helpers/api_helper.rb | 18 +++ 17 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 lib/intelligent_foods/api/v2.rb create mode 100644 lib/intelligent_foods/api/v2/authenticator.rb create mode 100644 lib/intelligent_foods/resources/allergen.rb create mode 100644 lib/intelligent_foods/resources/core_object.rb create mode 100644 lib/intelligent_foods/resources/dietary_tag.rb create mode 100644 lib/intelligent_foods/resources/ingredient.rb create mode 100644 lib/intelligent_foods/resources/nutrition_fact.rb create mode 100644 lib/intelligent_foods/resources/product.rb create mode 100644 spec/intelligent_foods/api/v2/authenticator_spec.rb create mode 100644 spec/intelligent_foods/api/v2_spec.rb create mode 100644 spec/intelligent_foods/resources/product_spec.rb create mode 100644 spec/support/fixtures/product_response.json 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]