Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Citi OFN Voucher] Add VINE connected app #12886

Merged
merged 12 commits into from
Oct 14, 2024
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ SMTP_PASSWORD="f00d"
# NEW_RELIC_AGENT_ENABLED=true
# NEW_RELIC_APP_NAME="Open Food Network"
# NEW_RELIC_LICENSE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# VINE API settings
# VINE_API_URL="https://vine-staging.openfoodnetwork.org.au/api/v1"
# VINE_API_SECRET="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
58 changes: 51 additions & 7 deletions app/controllers/admin/connected_apps_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ class ConnectedAppsController < ApplicationController
def create
authorize! :admin, enterprise

attributes = {}
attributes[:type] = connected_app_params[:type] if connected_app_params[:type]

app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes)
app.connect(api_key: spree_current_user.spree_api_key,
channel: SessionChannel.for_request(request))
connect

render_panel
end
Expand All @@ -26,6 +21,45 @@ def destroy

private

def create_connected_app
attributes = {}
attributes[:type] = connected_app_params[:type] if connected_app_params[:type]

@app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes)
end

def connect
return connect_vine if connected_app_params[:type] == "ConnectedApps::Vine"

create_connected_app
@app.connect(api_key: spree_current_user.spree_api_key,
channel: SessionChannel.for_request(request))
end

def connect_vine
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we can leave it for now but it would be better to move app-specific logic into the apps and have the controller be agnostic of the app's needs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I would prefer to have the connect logic to be separated from the model and having a service handle the connection bit before storing the result in the model.
But yeah, this should live in a service.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, too. If we take the logic out of the models then we don't really need a polymorphic model. The beauty of the polymorphic model is that it loads the right code automatically. But reducing models to data storage is really handy as well. Then we just need service objects that are coupled to app types. I'm not sure which is better. Maybe it's just that the VINE app has some additional logic that should go into a service.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice that we're validating the presence of two values now, which we want to store in the model. ActiveRecord validations are made for that kind of thing (I guess it would have to be a custom validation to handle the values inside the data blob, but it still seems appropriate).
I'm not sure it's worth moving the logic right now though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we might need other connected apps to see if a pattern emerges before we can refine the design. Maybe VINE is the odd one who knows. If I get a bit of time, I'll look at moving the VINE logic to a service.

if connected_app_params[:vine_api_key].empty?
return flash[:error] = I18n.t("admin.enterprises.form.connected_apps.vine.api_key_empty")
end

create_connected_app

jwt_service = VineJwtService.new(secret: ENV.fetch("VINE_API_SECRET"))
vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key],
jwt_generator: jwt_service)

if !@app.connect(api_key: connected_app_params[:vine_api_key], vine_api:)
error_message = "#{@app.errors.full_messages.to_sentence}. \
#{I18n.t('admin.enterprises.form.connected_apps.vine.api_key_error')}".squish
handle_error(error_message)
end
rescue Faraday::Error => e
log_and_notify_exception(e)
handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.connection_error"))
rescue KeyError => e
log_and_notify_exception(e)
handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.setup_error"))
end

def enterprise
@enterprise ||= Enterprise.find(params.require(:enterprise_id))
end
Expand All @@ -34,8 +68,18 @@ def render_panel
redirect_to "#{edit_admin_enterprise_path(enterprise)}#/connected_apps_panel"
end

def handle_error(message)
flash[:error] = message
@app.destroy
end

def log_and_notify_exception(exception)
Rails.logger.error exception.inspect
Bugsnag.notify(exception)
end

def connected_app_params
params.permit(:type)
params.permit(:type, :vine_api_key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could ask the app to give us a list of allowed parameters.

end
end
end
3 changes: 2 additions & 1 deletion app/models/connected_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
#
# Here we store keys and links to access the app.
class ConnectedApp < ApplicationRecord
TYPES = ['discover_regen', 'affiliate_sales_data'].freeze
TYPES = ['discover_regen', 'affiliate_sales_data', 'vine'].freeze

belongs_to :enterprise
after_destroy :disconnect

scope :discover_regen, -> { where(type: "ConnectedApp") }
scope :affiliate_sales_data, -> { where(type: "ConnectedApps::AffiliateSalesData") }
scope :vine, -> { where(type: "ConnectedApps::Vine") }

scope :connecting, -> { where(data: nil) }
scope :ready, -> { where.not(data: nil) }
Expand Down
21 changes: 21 additions & 0 deletions app/models/connected_apps/vine.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

# An enterprise can opt-in to use VINE API to manage vouchers
#
module ConnectedApps
class Vine < ConnectedApp
VINE_API_URL = "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team"

rioug marked this conversation as resolved.
Show resolved Hide resolved
def connect(api_key:, vine_api:, **_opts)
response = vine_api.my_team

return update data: { api_key: } if response.success?

errors.add(:base, I18n.t("activerecord.errors.models.connected_apps.vine.api_request_error"))
rioug marked this conversation as resolved.
Show resolved Hide resolved

false
end

def disconnect; end
end
end
39 changes: 39 additions & 0 deletions app/services/vine_api_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require "faraday"

class VineApiService
rioug marked this conversation as resolved.
Show resolved Hide resolved
attr_reader :api_key, :jwt_generator

def initialize(api_key:, jwt_generator:)
@vine_api_url = ENV.fetch("VINE_API_URL")
@api_key = api_key
@jwt_generator = jwt_generator
end

def my_team
my_team_url = "#{@vine_api_url}/my-team"

jwt = jwt_generator.generate_token
connection = Faraday.new(
request: { timeout: 30 },
headers: {
'X-Authorization': "JWT #{jwt}",
Accept: "application/json"
}
) do |f|
f.request :json
f.response :json
f.request :authorization, 'Bearer', api_key
end

response = connection.get(my_team_url)

if !response.success?
Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}"
Rails.logger.error "VineApiService#my_team -- response: #{response.body}"
end

response
end
end
21 changes: 21 additions & 0 deletions app/services/vine_jwt_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

class VineJwtService
ALGORITHM = "HS256"
ISSUER = "openfoodnetwork"

def initialize(secret: )
@secret = secret
end

def generate_token
generation_time = Time.zone.now
payload = {
iss: ISSUER,
iat: generation_time.to_i,
exp: (generation_time + 1.minute).to_i,
}

JWT.encode(payload, @secret, ALGORITHM)
end
end
27 changes: 27 additions & 0 deletions app/views/admin/enterprises/form/connected_apps/_vine.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
%section.connected_app
.connected-app__head
%div
%h3= t ".title"
%p= t ".tagline"
.connected-app__vine
- if connected_app.nil?
= form_with url: admin_enterprise_connected_apps_path(enterprise.id) do |f|
.connected-app__vine-content
.vine-api-key
= f.hidden_field :type, value: "ConnectedApps::Vine"
= f.label :vine_api_key, t(".vine_api_key")
%span.required *
= f.text_field :vine_api_key, { disabled: !managed_by_user?(enterprise) }
%div
- disabled = managed_by_user?(enterprise) ? {} : { disabled: true, "data-disable-with": false }
= f.submit t(".enable"), disabled

-# This is only seen by super-admins:
%em= t(".need_to_be_manager") unless managed_by_user?(enterprise)
- else
.connected-app__vine-content
.vine-disable
= button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete
%hr
.connected-app__description
= t ".description_html"
23 changes: 22 additions & 1 deletion app/webpacker/css/admin/connected_apps.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
border: none;
border-left: $border-radius solid $color-3;
border-radius: $border-radius;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07);
box-shadow:
0px 1px 0px rgba(0, 0, 0, 0.05),
0px 2px 2px rgba(0, 0, 0, 0.07);
margin: 2em 0;
padding: 0.5em 1em;

Expand All @@ -47,3 +49,22 @@
flex-shrink: 1;
}
}

.connected-app__vine {
margin: 1em 0;

.connected-app__vine-content {
display: flex;
justify-content: space-between;
align-items: end;

.vine-api-key {
width: 100%;
margin-right: 1em;
}

.vine-disable {
margin-left: auto;
}
}
}
4 changes: 3 additions & 1 deletion config/initializers/filter_parameter_logging.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# frozen_string_literal: true

# Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [:password]
Rails.application.config.filter_parameters += [:password, :vine_api_key]
23 changes: 23 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ en:
count_on_hand:
using_producer_stock_settings_but_count_on_hand_set: "must be blank because using producer stock settings"
limited_stock_but_no_count_on_hand: "must be specified because forcing limited stock"
connected_apps:
vine:
api_request_error: "An error occured when connecting to Vine API"
messages:
confirmation: "doesn't match %{attribute}"
blank: "can't be blank"
Expand Down Expand Up @@ -753,6 +756,7 @@ en:
connected_apps_enabled:
discover_regen: Discover Regenerative portal
affiliate_sales_data: DFC anonymised orders API for research purposes
vine: Voucher Engine (VINE)
rioug marked this conversation as resolved.
Show resolved Hide resolved
update:
resource: Connected app settings

Expand Down Expand Up @@ -1418,6 +1422,25 @@ en:
target="_blank"><b>Learn more about Discover Regenerative</b>
<i class="icon-external-link"></i></a>
</p>
vine:
title: "Voucher Engine (VINE)"
tagline: "Enable the use of VINE api to handle vouchers"
rioug marked this conversation as resolved.
Show resolved Hide resolved
rioug marked this conversation as resolved.
Show resolved Hide resolved
enable: "Connect"
disable: "Disconnect"
need_to_be_manager: "Only managers can connect apps."
vine_api_key: "VINE API Key"
description_html: |
<p>
To enable VINE for your enterprise, enter your API key.
</p>
<p>
<a href="#" target="_blank"><b>VINE</b>
<i class="icon-external-link"></i></a>
</p>
api_key_empty: "Please enter an API key"
api_key_error: "Check you entered your API key correctly, contact your instance manager if the error persists"
connection_error: "API connection error, please try again"
setup_error: "VINE API is not configured, please contact your instance manager"
actions:
edit_profile: Settings
properties: Properties
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions spec/models/connected_apps/vine_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ConnectedApps::Vine do
subject(:connected_app) { ConnectedApps::Vine.new(enterprise: create(:enterprise)) }

let(:vine_api_key) { "12345" }
let(:vine_api) { instance_double(VineApiService) }

describe "#connect" do
it "send a request to VINE api" do
expect(vine_api).to receive(:my_team).and_return(mock_api_response(true))

connected_app.connect(api_key: vine_api_key, vine_api: )
end

context "when request succeed", :vcr do
it "store the vine api key" do
allow(vine_api).to receive(:my_team).and_return(mock_api_response(true))

expect(connected_app.connect(api_key: vine_api_key, vine_api:)).to eq(true)
expect(connected_app.data).to eql({ "api_key" => vine_api_key })
end
end
rioug marked this conversation as resolved.
Show resolved Hide resolved

context "when request fails" do
it "doesn't store the vine api key" do
allow(vine_api).to receive(:my_team).and_return(mock_api_response(false))

expect(connected_app.connect(api_key: vine_api_key, vine_api:)).to eq(false)
expect(connected_app.data).to be_nil
expect(connected_app.errors[:base]).to include(
"An error occured when connecting to Vine API"
)
end
end
end

def mock_api_response(success)
mock_response = instance_double(Faraday::Response)
allow(mock_response).to receive(:success?).and_return(success)
mock_response
end
end
Loading
Loading