From b69b9f17326cf6c48647ef0308b0bdb500714c77 Mon Sep 17 00:00:00 2001 From: Rohan Likhite Date: Fri, 8 May 2026 11:49:57 -0400 Subject: [PATCH] Introduce CORE Api Authentication A new version of the API (CORE) is now available and will be used in the near future for new resources. This new API utilizes a basic authentication scheme. This PR lays the groundwork for future PR's to be able to interact with the CORE api, and adds the plumbing necessary to successfully configure the gem, and authenticate with CORE. 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 --- lib/intelligent_foods.rb | 4 +- lib/intelligent_foods/api/v2.rb | 68 +++++++++ lib/intelligent_foods/api/v2/authenticator.rb | 44 ++++++ lib/intelligent_foods/resources/api_error.rb | 4 +- .../resources/core_object.rb | 17 +++ .../api/v2/authenticator_spec.rb | 58 ++++++++ spec/intelligent_foods/api/v2_spec.rb | 129 ++++++++++++++++++ spec/support/helpers/api_helper.rb | 6 + 8 files changed, 328 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/core_object.rb create mode 100644 spec/intelligent_foods/api/v2/authenticator_spec.rb create mode 100644 spec/intelligent_foods/api/v2_spec.rb diff --git a/lib/intelligent_foods.rb b/lib/intelligent_foods.rb index a2a9d78..20e980b 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" @@ -31,7 +33,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/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/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/support/helpers/api_helper.rb b/spec/support/helpers/api_helper.rb index 67422ac..687016e 100644 --- a/spec/support/helpers/api_helper.rb +++ b/spec/support/helpers/api_helper.rb @@ -28,6 +28,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)