diff --git a/engines/dfc_provider/README.md b/engines/dfc_provider/README.md index 22fd5c018a..0b39158005 100644 --- a/engines/dfc_provider/README.md +++ b/engines/dfc_provider/README.md @@ -1,12 +1,41 @@ -# DfcProvider +# Data Food Consortium API for the Open Food Network app (OFN DFC API) -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. +This engine implements the [Data Food Consortium] specifications. It serves and +reads semantic data encoded in JSON-LD. -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) +[Data Food Consortium]: https://github.com/datafoodconsortium -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`). +## Authentication -This feature is still under active development. \ No newline at end of file +The DFC uses OpenID Connect (OIDC) to authenticate requests. You need an +account with a trusted OIDC provider. Currently these are: + +* https://login.lescommuns.org/auth/ + +But you can also authenticate with your OFN user login (session cookie) through +your browser. + +## API endpoints + +The API is under development and this list may be out of date. + +``` +/api/dfc-v1.7/persons/:id + * show: firstName, lastName, affiliatedOrganizations + +/api/dfc-v1.7/enterprises/:id + * show: name, suppliedProducts, catalogItems + +/api/dfc-v1.7/enterprises/:enterprise_id/supplied_products (index) + +/api/dfc-v1.7/enterprises/:enterprise_id/supplied_products/:id + * create: name, description, quantity + * show: name, description, productType, quantity + * update: description + +/api/dfc-v1.7/enterprises/:enterprise_id/catalog_items (index) + +/api/dfc-v1.7/enterprises/:enterprise_id/catalog_items/:id + * show: product, sku, stockLimitation, offers (price, stockLimitation) + * update: sku, stockLimitation +``` diff --git a/engines/dfc_provider/app/controllers/dfc_provider/supplied_products_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/supplied_products_controller.rb index 034770bab2..c7b9a60134 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/supplied_products_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/supplied_products_controller.rb @@ -1,13 +1,25 @@ # frozen_string_literal: true +require "data_food_consortium/connector/connector" + # Controller used to provide the SuppliedProducts API for the DFC application # SuppliedProducts are products that are managed by an enterprise. module DfcProvider class SuppliedProductsController < DfcProvider::BaseController before_action :check_enterprise + rescue_from JSON::LD::JsonLdError::LoadingDocumentFailed, with: -> do + head :bad_request + end + + def create + supplied_product = import.first + product = SuppliedProductBuilder.import(supplied_product) + product.supplier = current_enterprise + product.save! + end def show - product = DfcBuilder.supplied_product(variant) + product = SuppliedProductBuilder.supplied_product(variant) render json: DfcLoader.connector.export(product) end @@ -27,6 +39,10 @@ module DfcProvider private + def import + DfcLoader.connector.import(request.body) + end + def variant @variant ||= VariantFetcher.new(current_enterprise).scope.find(params[:id]) diff --git a/engines/dfc_provider/app/services/dfc_builder.rb b/engines/dfc_provider/app/services/dfc_builder.rb index 1e364b8faa..d67630c5a0 100644 --- a/engines/dfc_provider/app/services/dfc_builder.rb +++ b/engines/dfc_provider/app/services/dfc_builder.rb @@ -6,7 +6,7 @@ class DfcBuilder enterprise_id: variant.product.supplier_id, id: variant.id, ) - product = supplied_product(variant) + product = SuppliedProductBuilder.supplied_product(variant) DataFoodConsortium::Connector::CatalogItem.new( id, product: product, @@ -16,21 +16,6 @@ class DfcBuilder ) end - def self.supplied_product(variant) - id = urls.enterprise_supplied_product_url( - enterprise_id: variant.product.supplier_id, - id: variant.id, - ) - - DataFoodConsortium::Connector::SuppliedProduct.new( - id, - name: variant.name_to_display, - description: variant.description, - productType: product_type, - quantity: QuantitativeValueBuilder.quantity(variant), - ) - end - def self.offer(variant) # We don't have an endpoint for offers yet and this URL is only a # placeholder for now. The offer is actually affected by order cycle and diff --git a/engines/dfc_provider/app/services/enterprise_builder.rb b/engines/dfc_provider/app/services/enterprise_builder.rb index 41f8833544..a8327d97da 100644 --- a/engines/dfc_provider/app/services/enterprise_builder.rb +++ b/engines/dfc_provider/app/services/enterprise_builder.rb @@ -3,8 +3,8 @@ class EnterpriseBuilder < DfcBuilder def self.enterprise(enterprise) variants = VariantFetcher.new(enterprise).scope.to_a - supplied_products = variants.map(&method(:supplied_product)) catalog_items = variants.map(&method(:catalog_item)) + supplied_products = catalog_items.map(&:product) DataFoodConsortium::Connector::Enterprise.new( enterprise.name diff --git a/engines/dfc_provider/app/services/quantitative_value_builder.rb b/engines/dfc_provider/app/services/quantitative_value_builder.rb index c88deef266..8291b3c0b0 100644 --- a/engines/dfc_provider/app/services/quantitative_value_builder.rb +++ b/engines/dfc_provider/app/services/quantitative_value_builder.rb @@ -26,4 +26,19 @@ class QuantitativeValueBuilder < DfcBuilder DfcLoader.connector.MEASURES.UNIT.QUANTITYUNIT.PIECE end end + + def self.apply(quantity, product) + product.variant_unit, product.variant_unit_name = + case quantity.unit + when DfcLoader.connector.MEASURES.UNIT.QUANTITYUNIT.LITRE + ["volume", "liter"] + when DfcLoader.connector.MEASURES.UNIT.QUANTITYUNIT.GRAM + ["weight", "gram"] + else + ["items", "items"] + end + + product.variant_unit_scale = 1 + product.unit_value = quantity.value + end end diff --git a/engines/dfc_provider/app/services/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb new file mode 100644 index 0000000000..40dfe0c186 --- /dev/null +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class SuppliedProductBuilder < DfcBuilder + def self.supplied_product(variant) + id = urls.enterprise_supplied_product_url( + enterprise_id: variant.product.supplier_id, + id: variant.id, + ) + + DataFoodConsortium::Connector::SuppliedProduct.new( + id, + name: variant.name_to_display, + description: variant.description, + productType: product_type, + quantity: QuantitativeValueBuilder.quantity(variant), + ) + end + + def self.import(supplied_product) + Spree::Product.new( + name: supplied_product.name, + description: supplied_product.description, + price: 0, # will be in DFC Offer + primary_taxon: Spree::Taxon.first, # dummy value until we have a mapping + shipping_category: DefaultShippingCategory.find_or_create, + ).tap do |product| + QuantitativeValueBuilder.apply(supplied_product.quantity, product) + end + end +end diff --git a/engines/dfc_provider/config/routes.rb b/engines/dfc_provider/config/routes.rb index f8fde9365b..a016a9f5b9 100644 --- a/engines/dfc_provider/config/routes.rb +++ b/engines/dfc_provider/config/routes.rb @@ -3,7 +3,7 @@ DfcProvider::Engine.routes.draw do resources :enterprises, only: [:show] do resources :catalog_items, only: [:index, :show, :update] - resources :supplied_products, only: [:show, :update] + resources :supplied_products, only: [:create, :show, :update] end resources :persons, only: [:show] end diff --git a/engines/dfc_provider/spec/requests/supplied_products_spec.rb b/engines/dfc_provider/spec/requests/supplied_products_spec.rb index f48d761561..6a2b736f91 100644 --- a/engines/dfc_provider/spec/requests/supplied_products_spec.rb +++ b/engines/dfc_provider/spec/requests/supplied_products_spec.rb @@ -8,6 +8,40 @@ describe "SuppliedProducts", type: :request do let!(:product) { create(:simple_product, supplier: enterprise ) } let!(:variant) { product.variants.first } + describe :create do + let(:endpoint) do + enterprise_supplied_products_path(enterprise_id: enterprise.id) + end + let(:supplied_product) do + SuppliedProductBuilder.supplied_product(new_variant) + end + let(:new_variant) do + # We need an id to generate a URL as semantic id when exporting. + build(:variant, id: 0, name: "Apple", unit_value: 3) + end + + it "flags a bad request" do + post endpoint, headers: auth_header(user.uid) + + expect(response).to have_http_status :bad_request + end + + it "creates a variant" do + request_body = DfcLoader.connector.export(supplied_product) + + expect do + post endpoint, + params: request_body, + headers: auth_header(user.uid) + end + .to change { enterprise.supplied_products.count }.by(1) + + variant = Spree::Variant.last + expect(variant.name).to eq "Apple" + expect(variant.unit_value).to eq 3 + end + end + describe :show do it "returns variants" do get enterprise_supplied_product_path( diff --git a/engines/dfc_provider/spec/services/enterprise_builder_spec.rb b/engines/dfc_provider/spec/services/enterprise_builder_spec.rb new file mode 100644 index 0000000000..d6c7c2f3c8 --- /dev/null +++ b/engines/dfc_provider/spec/services/enterprise_builder_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require DfcProvider::Engine.root.join("spec/spec_helper") + +describe EnterpriseBuilder do + subject(:builder) { described_class } + let(:enterprise) { variant.product.supplier } + let(:variant) { create(:product, name: "Apple").variants.first } + + describe ".enterprise" do + it "assigns a semantic id" do + result = builder.enterprise(enterprise) + + expect(result.semanticId).to eq( + "http://test.host/api/dfc-v1.7/enterprises/#{enterprise.id}" + ) + end + + it "assignes products" do + result = builder.enterprise(enterprise) + + expect(result.suppliedProducts.count).to eq 1 + expect(result.suppliedProducts[0].name).to eq "Apple" + end + end +end diff --git a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb index f93235e786..b52aa1e737 100644 --- a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb +++ b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb @@ -2,14 +2,15 @@ require DfcProvider::Engine.root.join("spec/spec_helper") -describe DfcBuilder do +describe SuppliedProductBuilder do + subject(:builder) { described_class } let(:variant) { build(:variant, id: 5).tap { |v| v.product.supplier_id = 7 } } describe ".supplied_product" do it "assigns a semantic id" do - product = DfcBuilder.supplied_product(variant) + product = builder.supplied_product(variant) expect(product.semanticId).to eq( "http://test.host/api/dfc-v1.7/enterprises/7/supplied_products/5" @@ -17,7 +18,7 @@ describe DfcBuilder do end it "assigns a quantity" do - product = DfcBuilder.supplied_product(variant) + product = builder.supplied_product(variant) expect(product.quantity.value).to eq 1.0 expect(product.quantity.unit.semanticId).to eq "dfc-m:Gram" @@ -25,7 +26,7 @@ describe DfcBuilder do it "assigns the product name by default" do variant.product.name = "Apple" - product = DfcBuilder.supplied_product(variant) + product = builder.supplied_product(variant) expect(product.name).to eq "Apple" end @@ -33,13 +34,13 @@ describe DfcBuilder do it "assigns the variant name if present" do variant.product.name = "Apple" variant.display_name = "Granny Smith" - product = DfcBuilder.supplied_product(variant) + product = builder.supplied_product(variant) expect(product.name).to eq "Granny Smith" end it "assigns a product type" do - product = DfcBuilder.supplied_product(variant) + product = builder.supplied_product(variant) vegetable = DfcLoader.connector.PRODUCT_TYPES.VEGETABLE.NON_LOCAL_VEGETABLE expect(product.productType).to eq vegetable diff --git a/engines/dfc_provider/spec/spec_helper.rb b/engines/dfc_provider/spec/spec_helper.rb index 2f805bc2b2..3e363b392e 100644 --- a/engines/dfc_provider/spec/spec_helper.rb +++ b/engines/dfc_provider/spec/spec_helper.rb @@ -8,11 +8,4 @@ RSpec.configure do |config| config.include AuthorizationHelper, type: :request config.include DfcProvider::Engine.routes.url_helpers, type: :request config.include Warden::Test::Helpers, type: :request - - config.around(:each) do |example| - # The DFC Connector fetches the context when loaded. - VCR.use_cassette("dfc-context") do - example.run - end - end end diff --git a/lib/data_food_consortium/connector/connector.rb b/lib/data_food_consortium/connector/connector.rb new file mode 100644 index 0000000000..b93080c3ad --- /dev/null +++ b/lib/data_food_consortium/connector/connector.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "importer" + +module DataFoodConsortium + module Connector + class Connector + def import(json_string_or_io) + Importer.new.import(json_string_or_io) + end + end + end +end diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb new file mode 100644 index 0000000000..606e302835 --- /dev/null +++ b/lib/data_food_consortium/connector/importer.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require_relative "skos_parser" + +module DataFoodConsortium + module Connector + class Importer + TYPES = [ + DataFoodConsortium::Connector::CatalogItem, + DataFoodConsortium::Connector::Enterprise, + DataFoodConsortium::Connector::Offer, + DataFoodConsortium::Connector::Person, + DataFoodConsortium::Connector::QuantitativeValue, + DataFoodConsortium::Connector::SuppliedProduct, + ].freeze + + def self.type_map + @type_map ||= TYPES.each_with_object({}) do |clazz, result| + # Methods with variable arguments have a negative arity of -n-1 + # where n is the number of required arguments. + number_of_required_args = -1 * (clazz.instance_method(:initialize).arity + 1) + args = Array.new(number_of_required_args) + type_uri = clazz.new(*args).semanticType + result[type_uri] = clazz + end + end + + def import(json_string_or_io) + @subjects = {} + + graph = parse_rdf(json_string_or_io) + build_subjects(graph) + apply_statements(graph) + + if @subjects.size > 1 + @subjects.values + else + @subjects.values.first + end + end + + private + + # The `io` parameter can be a String or an IO instance. + def parse_rdf(io) + io = StringIO.new(io) if io.is_a?(String) + RDF::Graph.new << JSON::LD::API.toRdf(io) + end + + def build_subjects(graph) + graph.query({ predicate: RDF.type }).each do |statement| + @subjects[statement.subject] = build_subject(statement) + end + end + + def build_subject(type_statement) + # Not all subjects have an id, some are anonymous. + id = type_statement.subject.try(:value) + type = type_statement.object.value + clazz = self.class.type_map[type] + + clazz.new(*[id].compact) + end + + def apply_statements(statements) + statements.each do |statement| + apply_statement(statement) + end + end + + def apply_statement(statement) + subject = subject_of(statement) + property_id = statement.predicate.value + value = resolve_object(statement.object) + + return unless subject.hasSemanticProperty?(property_id) + + property = subject.__send__(:findSemanticProperty, property_id) + + if property.value.is_a?(Enumerable) + property.value << value + else + setter = guess_setter_name(statement.predicate) + subject.try(setter, value) if setter + end + end + + def subject_of(statement) + @subjects[statement.subject] + end + + def resolve_object(object) + @subjects[object] || skos_concept(object) || object.object + end + + def skos_concept(object) + return unless object.uri? + + id = object.value.sub( + "http://static.datafoodconsortium.org/data/measures.rdf#", "dfc-m:" + ) + SKOSParser.concepts[id] + end + + def guess_setter_name(predicate) + fragment = predicate.fragment + + # Some predicates are named like `hasQuantity` + # but the attribute name would be `quantity`. + name = fragment.sub(/^has/, "").camelize(:lower) + + "#{name}=" + end + end + end +end diff --git a/lib/data_food_consortium/connector/skos_parser.rb b/lib/data_food_consortium/connector/skos_parser.rb new file mode 100644 index 0000000000..7e3e02fcfa --- /dev/null +++ b/lib/data_food_consortium/connector/skos_parser.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Overriding the current implementation to store all parsed concepts for +# lookup later. Otherwise the importer can't associate these. +# This is just a workaround and needs to be solved upstream. +module DataFoodConsortium + module Connector + class SKOSParser + def self.concepts + @concepts ||= {} + end + + protected + + def createSKOSConcept(element) # rubocop:disable Naming/MethodName + concept = DataFoodConsortium::Connector::SKOSConcept.new + concept.semanticId = element.id + concept.semanticType = element.type + self.class.concepts[element.id] = concept + concept + end + end + end +end diff --git a/spec/base_spec_helper.rb b/spec/base_spec_helper.rb index f8b6bad42f..a691951bc8 100644 --- a/spec/base_spec_helper.rb +++ b/spec/base_spec_helper.rb @@ -91,6 +91,13 @@ RSpec.configure do |config| expectations.syntax = :expect end + config.around(:each) do |example| + # The DFC Connector fetches the context when loaded. + VCR.use_cassette("dfc-context") do + example.run + end + end + # Enable caching in any specs tagged with `caching: true`. config.around(:each, :caching) do |example| caching = ActionController::Base.perform_caching diff --git a/spec/lib/data_food_consortium/connector/connector_spec.rb b/spec/lib/data_food_consortium/connector/connector_spec.rb new file mode 100644 index 0000000000..26031e60ad --- /dev/null +++ b/spec/lib/data_food_consortium/connector/connector_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('lib/data_food_consortium/connector/connector') + +describe DataFoodConsortium::Connector::Connector, vcr: true do + subject(:connector) { described_class.instance } + let(:product) do + DataFoodConsortium::Connector::SuppliedProduct.new( + "https://example.net/tomato", + name: "Tomato", + description: "Awesome tomato" + ) + end + + it "exports" do + json = connector.export(product) + expect(json).to match '"dfc-b:name":"Tomato"' + end + + it "imports" do + json = connector.export(product) + result = connector.import(json) + expect(result.class).to eq product.class + expect(result.semanticType).to eq product.semanticType + expect(result.semanticId).to eq "https://example.net/tomato" + expect(result.name).to eq "Tomato" + end + + it "imports from IO like Rails supplies it" do + json = connector.export(product) + io = StringIO.new(json) + result = connector.import(io) + + expect(result.class).to eq product.class + expect(result.semanticType).to eq product.semanticType + expect(result.semanticId).to eq "https://example.net/tomato" + expect(result.name).to eq "Tomato" + end +end diff --git a/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb new file mode 100644 index 0000000000..3ac97a7f1f --- /dev/null +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('lib/data_food_consortium/connector/connector') + +describe DataFoodConsortium::Connector::Importer, vcr: true do + let(:connector) { DataFoodConsortium::Connector::Connector.instance } + let(:enterprise) do + DataFoodConsortium::Connector::Enterprise.new( + "https://example.net/foo-food-inc", + suppliedProducts: [product, second_product], + ) + end + let(:catalog_item) do + DataFoodConsortium::Connector::CatalogItem.new( + "https://example.net/tomatoItem", + product:, + ) + end + let(:product) do + DataFoodConsortium::Connector::SuppliedProduct.new( + "https://example.net/tomato", + name: "Tomato", + description: "Awesome tomato", + totalTheoreticalStock: 3, + ) + end + let(:second_product) do + DataFoodConsortium::Connector::SuppliedProduct.new( + "https://example.net/ocra", + name: "Ocra", + ) + end + let(:quantity) do + DataFoodConsortium::Connector::QuantitativeValue.new( + unit: piece, + value: 5, + ) + end + let(:piece) do + unless connector.MEASURES.respond_to?(:UNIT) + connector.loadMeasures(read_file("measures")) + end + connector.MEASURES.UNIT.QUANTITYUNIT.PIECE + end + + it "imports a single object with simple properties" do + result = import(product) + + expect(result.class).to eq product.class + expect(result.semanticType).to eq product.semanticType + expect(result.semanticId).to eq "https://example.net/tomato" + expect(result.name).to eq "Tomato" + expect(result.description).to eq "Awesome tomato" + expect(result.totalTheoreticalStock).to eq 3 + end + + it "imports a graph with multiple objects" do + result = import(catalog_item, product) + + expect(result).to be_a Array + expect(result.size).to eq 2 + + item, tomato = result + + expect(item.class).to eq catalog_item.class + expect(item.semanticType).to eq catalog_item.semanticType + expect(item.semanticId).to eq "https://example.net/tomatoItem" + expect(tomato.name).to eq "Tomato" + expect(tomato.description).to eq "Awesome tomato" + expect(tomato.totalTheoreticalStock).to eq 3 + end + + it "imports a graph including anonymous objects" do + product.quantity = quantity + + tomato, items = import(product) + + expect(tomato.name).to eq "Tomato" + expect(tomato.quantity).to eq items + expect(items.value).to eq 5 + expect(items.unit).to eq piece + end + + it "imports properties with lists" do + result = import(enterprise, product, second_product) + + expect(result.size).to eq 3 + + enterprise, tomato, ocra = result + + expect(enterprise.suppliedProducts).to eq [tomato, ocra] + end + + def import(*args) + json = connector.export(*args) + connector.import(json) + end + + def read_file(name) + JSON.parse( + Rails.root.join("engines/dfc_provider/vendor/#{name}.json").read + ) + end +end