diff --git a/Gemfile b/Gemfile index dcf202d4f3..53130494e0 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem 'rails_safe_tasks', '~> 1.0' gem "activerecord-import" gem "catalog", path: "./engines/catalog" +gem 'dfc_provider', path: './engines/dfc_provider' gem "order_management", path: "./engines/order_management" gem 'web', path: './engines/web' diff --git a/Gemfile.lock b/Gemfile.lock index e5ebbf35a7..cf7b88e370 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,6 +61,13 @@ PATH specs: catalog (0.0.1) +PATH + remote: engines/dfc_provider + specs: + dfc_provider (0.0.1) + jwt (~> 2.2) + rspec (~> 3.9) + PATH remote: engines/order_management specs: @@ -710,6 +717,7 @@ DEPENDENCIES delayed_job_web devise (~> 2.2.5) devise-encryptable (= 0.2.0) + dfc_provider! diffy eventmachine (>= 1.2.3) factory_bot_rails diff --git a/config/routes.rb b/config/routes.rb index dcb828b3be..fcb633f9e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,9 @@ Openfoodnetwork::Application.routes.draw do get 'sitemap.xml', to: 'sitemap#index', defaults: { format: 'xml' } + # Mount DFC API endpoints + mount DfcProvider::Engine, at: '/' + # Mount Spree's routes mount Spree::Core::Engine, :at => '/' end diff --git a/engines/dfc_provider/README.md b/engines/dfc_provider/README.md new file mode 100644 index 0000000000..8e73633c46 --- /dev/null +++ b/engines/dfc_provider/README.md @@ -0,0 +1,10 @@ +# DfcProvider + +This engine is implementing the Data Food Consortium specifications in order to serve semantic data. +You can find more details about this on https://github.com/datafoodconsortium. + +Basically, it allows an OFN user linked to an enterprise: +* to serve his Products Catalog through a dedicated API using JSON-LD format, structured by the DFC Ontology +* to be authenticated thanks to an Access Token from DFC Authorization server (using an OIDC implementation) + +The API endpoint for the catalog is `/api/dfc_provider/enterprise/prodcuts.json` and you need to pass the token inside an authentication header (`Authentication: Bearer 123mytoken456`). diff --git a/engines/dfc_provider/app/controllers/dfc_provider/api/products_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/api/products_controller.rb new file mode 100644 index 0000000000..54c5bd4556 --- /dev/null +++ b/engines/dfc_provider/app/controllers/dfc_provider/api/products_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Controller used to provide the API products for the DFC application +module DfcProvider + module Api + class ProductsController < ::ActionController::Base + # To access 'base_url' helper + include Rails.application.routes.url_helpers + + before_filter :check_authorization, + :check_user, + :check_enterprise + + respond_to :json + + def index + products = @enterprise. + inventory_variants. + includes(:product, :inventory_items) + + serialized_data = ::DfcProvider::ProductSerializer. + new(products, base_url). + serialized_data + + render json: serialized_data + end + + private + + def check_enterprise + @enterprise = + if params[:enterprise_id] == 'default' + @user.enterprises.first + else + @user.enterprises.where(id: params[:enterprise_id]).first + end + + return if @enterprise.present? + + head :not_found + end + + def check_authorization + return if access_token.present? + + head :unprocessable_entity + end + + def check_user + @user = authorization_control.process + + return if @user.present? + + head :unauthorized + end + + def base_url + "#{root_url}api/dfc_provider" + end + + def access_token + request.headers['Authorization'].to_s.split(' ').last + end + + def authorization_control + DfcProvider::AuthorizationControl.new(access_token) + end + end + end +end diff --git a/engines/dfc_provider/app/serializers/dfc_provider/product_serializer.rb b/engines/dfc_provider/app/serializers/dfc_provider/product_serializer.rb new file mode 100644 index 0000000000..42749a3d8d --- /dev/null +++ b/engines/dfc_provider/app/serializers/dfc_provider/product_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Serializer used to render the products passed +# into JSON-LD format based on DFC ontology +module DfcProvider + class ProductSerializer + def initialize(products, base_url) + @products = products + @base_url = base_url + end + + def serialized_data + { + "@context" => + { + "DFC" => "http://datafoodconsortium.org/ontologies/DFC_FullModel.owl#", + "@base" => @base_url + }, + "@id" => "/enterprise/products", + "DFC:supplies" => serialized_products + } + end + + private + + def serialized_products + @products.map do |variant| + { + "DFC:description" => variant.name, + "DFC:quantity" => variant.total_on_hand, + "@id" => variant.id, + "DFC:hasUnit" => { "@id" => "/unit/#{variant.unit_description.presence || 'piece'}" } + } + end + end + end +end diff --git a/engines/dfc_provider/app/services/dfc_provider/authorization_control.rb b/engines/dfc_provider/app/services/dfc_provider/authorization_control.rb new file mode 100644 index 0000000000..beb1885816 --- /dev/null +++ b/engines/dfc_provider/app/services/dfc_provider/authorization_control.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Service used to authorize the user on DCF Provider API +# It controls an OICD Access token and an enterprise. +module DfcProvider + class AuthorizationControl + def initialize(access_token) + @access_token = access_token + end + + def process + decode_token + find_ofn_user + end + + def decode_token + data = JWT.decode( + @access_token, + nil, + false + ) + + @header = data.last + @payload = data.first + end + + def find_ofn_user + Spree::User.where(email: @payload['email']).first + end + end +end diff --git a/engines/dfc_provider/config/routes.rb b/engines/dfc_provider/config/routes.rb new file mode 100644 index 0000000000..b88e13e768 --- /dev/null +++ b/engines/dfc_provider/config/routes.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +DfcProvider::Engine.routes.draw do + namespace :api do + scope :dfc_provider, as: :dfc_provider, path: '/dfc_provider' do + resources :enterprises, only: :none do + resources :products, only: [:index] + end + end + end +end diff --git a/engines/dfc_provider/dfc_provider.gemspec b/engines/dfc_provider/dfc_provider.gemspec new file mode 100644 index 0000000000..4684aaaa18 --- /dev/null +++ b/engines/dfc_provider/dfc_provider.gemspec @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path('lib', __dir__) + +# Maintain your gem's version: +require "dfc_provider/version" + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |spec| + spec.name = 'dfc_provider' + spec.version = DfcProvider::VERSION + spec.authors = ["developers@ofn"] + spec.summary = 'Provides an API stack implementing DFC semantic ' \ + 'specifications' + + spec.files = Dir["{app,config,lib}/**/*"] + ['README.md'] + spec.test_files = Dir['spec/**/*'] + + spec.add_dependency 'jwt', '~> 2.2' + spec.add_dependency 'rspec', '~> 3.9' +end diff --git a/engines/dfc_provider/lib/dfc_provider.rb b/engines/dfc_provider/lib/dfc_provider.rb new file mode 100644 index 0000000000..b0cf98121a --- /dev/null +++ b/engines/dfc_provider/lib/dfc_provider.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "dfc_provider/engine" + +module DfcProvider +end diff --git a/engines/dfc_provider/lib/dfc_provider/engine.rb b/engines/dfc_provider/lib/dfc_provider/engine.rb new file mode 100644 index 0000000000..fedaef351a --- /dev/null +++ b/engines/dfc_provider/lib/dfc_provider/engine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DfcProvider + class Engine < ::Rails::Engine + isolate_namespace DfcProvider + end +end diff --git a/engines/dfc_provider/lib/dfc_provider/version.rb b/engines/dfc_provider/lib/dfc_provider/version.rb new file mode 100644 index 0000000000..3b891391e3 --- /dev/null +++ b/engines/dfc_provider/lib/dfc_provider/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module DfcProvider + VERSION = '0.0.1' +end diff --git a/engines/dfc_provider/spec/controllers/dfc_provider/api/products_controller_spec.rb b/engines/dfc_provider/spec/controllers/dfc_provider/api/products_controller_spec.rb new file mode 100644 index 0000000000..c3f40a16a0 --- /dev/null +++ b/engines/dfc_provider/spec/controllers/dfc_provider/api/products_controller_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DfcProvider::Api::ProductsController, type: :controller do + render_views + + let(:user) { create(:user) } + let(:enterprise) { create(:distributor_enterprise, owner: user) } + let(:product) { create(:simple_product, supplier: enterprise ) } + let!(:visible_inventory_item) do + create(:inventory_item, + enterprise: enterprise, + variant: product.variants.first, + visible: true) + end + + describe('.index') do + context 'with authorization token' do + before do + request.env['Authorization'] = 'Bearer 123456.abcdef.123456' + end + + context 'with an authenticated user' do + before do + allow_any_instance_of(DfcProvider::AuthorizationControl) + .to receive(:process) + .and_return(user) + end + + context 'with an enterprise' do + context 'given with an id' do + context 'related to the user' do + before { get :index, enterprise_id: 'default' } + + it 'is successful' do + expect(response.status).to eq 200 + end + + it 'renders the related product' do + expect(response.body) + .to include(product.variants.first.name) + end + end + + context 'not related to the user' do + let(:enterprise) { create(:enterprise) } + + it 'returns not_found head' do + get :index, enterprise_id: enterprise.id + expect(response.status).to eq 404 + end + end + end + + context 'as default' do + before { get :index, enterprise_id: 'default' } + + it 'is successful' do + expect(response.status).to eq 200 + end + + it 'renders the related product' do + expect(response.body) + .to include(product.variants.first.name) + end + end + end + + context 'without a recorded enterprise' do + let(:enterprise) { create(:enterprise) } + + it 'returns not_found head' do + get :index, enterprise_id: 'default' + expect(response.status).to eq 404 + end + end + end + + context 'without an authenticated user' do + it 'returns unauthorized head' do + allow_any_instance_of(DfcProvider::AuthorizationControl) + .to receive(:process) + .and_return(nil) + + get :index, enterprise_id: 'default' + expect(response.status).to eq 401 + end + end + end + + context 'without an authorization token' do + it 'returns unprocessable_entity head' do + get :index, enterprise_id: enterprise.id + expect(response.status).to eq 422 + end + end + end +end diff --git a/engines/dfc_provider/spec/spec_helper.rb b/engines/dfc_provider/spec/spec_helper.rb new file mode 100644 index 0000000000..3492f4f944 --- /dev/null +++ b/engines/dfc_provider/spec/spec_helper.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "../../spec/spec_helper.rb" + +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f }