From 15dd38d6c6313c81b8191e88b8f6f532d2277dee Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 19 May 2023 16:48:07 +1000 Subject: [PATCH 01/11] Import simple data from DFC --- engines/dfc_provider/spec/spec_helper.rb | 7 -- .../connector/connector.rb | 13 ++++ .../connector/importer.rb | 64 +++++++++++++++++++ spec/base_spec_helper.rb | 7 ++ .../connector/connector_spec.rb | 29 +++++++++ .../connector/importer_spec.rb | 32 ++++++++++ 6 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 lib/data_food_consortium/connector/connector.rb create mode 100644 lib/data_food_consortium/connector/importer.rb create mode 100644 spec/lib/data_food_consortium/connector/connector_spec.rb create mode 100644 spec/lib/data_food_consortium/connector/importer_spec.rb 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..bb8d73adc6 --- /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) + Importer.new.import(json_string) + 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..bc9e6ab765 --- /dev/null +++ b/lib/data_food_consortium/connector/importer.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +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| + type_uri = clazz.new(nil).semanticType + result[type_uri] = clazz + end + end + + def import(json_string) + graph = parse_rdf(json_string) + head, *tail = graph.to_a + subject = build_subject(head) + apply_statements(subject, tail) + subject + end + + private + + def parse_rdf(json_string) + json_file = StringIO.new(json_string) + RDF::Graph.new << JSON::LD::API.toRdf(json_file) + end + + def build_subject(type_statement) + id = type_statement.subject.value + type = type_statement.object.value + clazz = self.class.type_map[type] + + clazz.new(id) + end + + def apply_statements(subject, statements) + statements.each do |statement| + apply_statement(subject, statement) + end + end + + def apply_statement(subject, statement) + return unless subject.hasSemanticProperty?(statement.predicate.value) + + prop_name = statement.predicate.fragment + setter_name = "#{prop_name}=" + + return unless subject.respond_to?(setter_name) + + value = statement.object.object + subject.public_send(setter_name, value) + 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..07f8bd7beb --- /dev/null +++ b/spec/lib/data_food_consortium/connector/connector_spec.rb @@ -0,0 +1,29 @@ +# 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 +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..6669a7f4fc --- /dev/null +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -0,0 +1,32 @@ +# 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(:product) do + DataFoodConsortium::Connector::SuppliedProduct.new( + "https://example.net/tomato", + name: "Tomato", + description: "Awesome tomato", + totalTheoreticalStock: 3, + ) + 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 + + def import(*args) + json = connector.export(*args) + connector.import(json) + end +end From 2abad60511dd8d9f561abb159a67c07540b57af4 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 22 May 2023 14:52:57 +1000 Subject: [PATCH 02/11] Import DFC graphs with multiple objects --- .../connector/importer.rb | 25 +++++++++++++------ .../connector/importer_spec.rb | 22 ++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index bc9e6ab765..c0e0f39ffe 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -20,11 +20,16 @@ module DataFoodConsortium end def import(json_string) + @subjects = {} + graph = parse_rdf(json_string) - head, *tail = graph.to_a - subject = build_subject(head) - apply_statements(subject, tail) - subject + apply_statements(graph) + + if @subjects.size > 1 + @subjects.values + else + @subjects.values.first + end end private @@ -42,13 +47,15 @@ module DataFoodConsortium clazz.new(id) end - def apply_statements(subject, statements) + def apply_statements(statements) statements.each do |statement| - apply_statement(subject, statement) + apply_statement(statement) end end - def apply_statement(subject, statement) + def apply_statement(statement) + subject = subject_of(statement) + return unless subject.hasSemanticProperty?(statement.predicate.value) prop_name = statement.predicate.fragment @@ -59,6 +66,10 @@ module DataFoodConsortium value = statement.object.object subject.public_send(setter_name, value) end + + def subject_of(statement) + @subjects[statement.subject] ||= build_subject(statement) + end end end end diff --git a/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index 6669a7f4fc..226ee9f65e 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -5,6 +5,12 @@ require Rails.root.join('lib/data_food_consortium/connector/connector') describe DataFoodConsortium::Connector::Importer, vcr: true do let(:connector) { DataFoodConsortium::Connector::Connector.instance } + 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", @@ -25,6 +31,22 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do 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 + def import(*args) json = connector.export(*args) connector.import(json) From 12d56d725b2423569ab55bfa5c885c9bf0926fe0 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 22 May 2023 16:53:59 +1000 Subject: [PATCH 03/11] Import properties which contains lists Not just single values. --- .../connector/importer.rb | 23 +++++++++------- .../connector/importer_spec.rb | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index c0e0f39ffe..b184872f86 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -55,16 +55,21 @@ module DataFoodConsortium def apply_statement(statement) subject = subject_of(statement) - - return unless subject.hasSemanticProperty?(statement.predicate.value) - - prop_name = statement.predicate.fragment - setter_name = "#{prop_name}=" - - return unless subject.respond_to?(setter_name) - + property_id = statement.predicate.value value = statement.object.object - subject.public_send(setter_name, value) + + return unless subject.hasSemanticProperty?(property_id) + + property = subject.__send__(:findSemanticProperty, property_id) + + # Some properties have a one-to-one match to the method name. + setter_name = "#{statement.predicate.fragment}=" + + if property.value.is_a?(Enumerable) + property.value << value + elsif subject.respond_to?(setter_name) + subject.public_send(setter_name, value) + end end def subject_of(statement) diff --git a/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index 226ee9f65e..457829d0d5 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -5,6 +5,12 @@ 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", @@ -19,6 +25,12 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do totalTheoreticalStock: 3, ) end + let(:second_product) do + DataFoodConsortium::Connector::SuppliedProduct.new( + "https://example.net/ocra", + name: "Ocra", + ) + end it "imports a single object with simple properties" do result = import(product) @@ -47,6 +59,21 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do expect(tomato.totalTheoreticalStock).to eq 3 end + it "imports properties with lists" do + result = import(enterprise, product, second_product) + + expect(result.size).to eq 3 + + enterprise, tomato, ocra = result + + # Work in progress, we get only the URLs as hashes: + expect(enterprise.suppliedProducts[0][:path]).to eq "/tomato" + expect(enterprise.suppliedProducts[1][:path]).to eq "/ocra" + + # We would actually like to resolve the objects: + #expect(enterprise.suppliedProducts).to eq [tomato, ocra] + end + def import(*args) json = connector.export(*args) connector.import(json) From f5f8c349e16c2cb34faba7613ab23e59b649d104 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 22 May 2023 17:38:57 +1000 Subject: [PATCH 04/11] Resolve DFC Connector objects when importing graph --- lib/data_food_consortium/connector/importer.rb | 15 +++++++++++++-- .../connector/importer_spec.rb | 7 +------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index b184872f86..2536e1a5fc 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -23,6 +23,7 @@ module DataFoodConsortium @subjects = {} graph = parse_rdf(json_string) + build_subjects(graph) apply_statements(graph) if @subjects.size > 1 @@ -39,6 +40,12 @@ module DataFoodConsortium RDF::Graph.new << JSON::LD::API.toRdf(json_file) 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) id = type_statement.subject.value type = type_statement.object.value @@ -56,7 +63,7 @@ module DataFoodConsortium def apply_statement(statement) subject = subject_of(statement) property_id = statement.predicate.value - value = statement.object.object + value = resolve_object(statement.object) return unless subject.hasSemanticProperty?(property_id) @@ -73,7 +80,11 @@ module DataFoodConsortium end def subject_of(statement) - @subjects[statement.subject] ||= build_subject(statement) + @subjects[statement.subject] + end + + def resolve_object(object) + @subjects[object] || object.object end end end diff --git a/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index 457829d0d5..73d3225fa5 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -66,12 +66,7 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do enterprise, tomato, ocra = result - # Work in progress, we get only the URLs as hashes: - expect(enterprise.suppliedProducts[0][:path]).to eq "/tomato" - expect(enterprise.suppliedProducts[1][:path]).to eq "/ocra" - - # We would actually like to resolve the objects: - #expect(enterprise.suppliedProducts).to eq [tomato, ocra] + expect(enterprise.suppliedProducts).to eq [tomato, ocra] end def import(*args) From 3c6f90dbb3f9493cc8230162c0f5d5eaea6a4ba7 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 24 May 2023 15:35:21 +1000 Subject: [PATCH 05/11] Import from IO objects for convenience, efficiency The Connector exports to a String but Rails' `request.body` is an IO object. Since the used JSON-LD parser needs an IO object anyway and it can lower the memory usage we take IO objects as well and handle in-memory Strings only when given. --- lib/data_food_consortium/connector/connector.rb | 4 ++-- lib/data_food_consortium/connector/importer.rb | 11 ++++++----- .../data_food_consortium/connector/connector_spec.rb | 11 +++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/data_food_consortium/connector/connector.rb b/lib/data_food_consortium/connector/connector.rb index bb8d73adc6..b93080c3ad 100644 --- a/lib/data_food_consortium/connector/connector.rb +++ b/lib/data_food_consortium/connector/connector.rb @@ -5,8 +5,8 @@ require_relative "importer" module DataFoodConsortium module Connector class Connector - def import(json_string) - Importer.new.import(json_string) + def import(json_string_or_io) + Importer.new.import(json_string_or_io) end end end diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index 2536e1a5fc..61544e04e2 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -19,10 +19,10 @@ module DataFoodConsortium end end - def import(json_string) + def import(json_string_or_io) @subjects = {} - graph = parse_rdf(json_string) + graph = parse_rdf(json_string_or_io) build_subjects(graph) apply_statements(graph) @@ -35,9 +35,10 @@ module DataFoodConsortium private - def parse_rdf(json_string) - json_file = StringIO.new(json_string) - RDF::Graph.new << JSON::LD::API.toRdf(json_file) + # 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) diff --git a/spec/lib/data_food_consortium/connector/connector_spec.rb b/spec/lib/data_food_consortium/connector/connector_spec.rb index 07f8bd7beb..26031e60ad 100644 --- a/spec/lib/data_food_consortium/connector/connector_spec.rb +++ b/spec/lib/data_food_consortium/connector/connector_spec.rb @@ -26,4 +26,15 @@ describe DataFoodConsortium::Connector::Connector, vcr: true do 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 From bfd084d9b6ae4cab607e5d86e6a44137b2b27955 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 24 May 2023 17:48:59 +1000 Subject: [PATCH 06/11] Import graphs including anonymous objects --- .../connector/importer.rb | 11 +++++-- .../connector/importer_spec.rb | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index 61544e04e2..3a89486104 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -14,7 +14,11 @@ module DataFoodConsortium def self.type_map @type_map ||= TYPES.each_with_object({}) do |clazz, result| - type_uri = clazz.new(nil).semanticType + # 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 @@ -48,11 +52,12 @@ module DataFoodConsortium end def build_subject(type_statement) - id = type_statement.subject.value + # 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) + clazz.new(*[id].compact) end def apply_statements(statements) diff --git a/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index 73d3225fa5..bccc0984ad 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -31,6 +31,18 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do 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) @@ -59,6 +71,18 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do 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(items.value).to eq 5 + + # Pending matching concepts: + #expect(items.unit).to eq piece + end + it "imports properties with lists" do result = import(enterprise, product, second_product) @@ -73,4 +97,10 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do 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 From 949019a27739c86a441c7b1a14cf44c5cf1ceffc Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 25 May 2023 12:35:29 +1000 Subject: [PATCH 07/11] Map has* predicates to Connector attributes --- lib/data_food_consortium/connector/importer.rb | 18 +++++++++++++----- .../connector/importer_spec.rb | 2 ++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index 3a89486104..1ac146873a 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -75,13 +75,11 @@ module DataFoodConsortium property = subject.__send__(:findSemanticProperty, property_id) - # Some properties have a one-to-one match to the method name. - setter_name = "#{statement.predicate.fragment}=" - if property.value.is_a?(Enumerable) property.value << value - elsif subject.respond_to?(setter_name) - subject.public_send(setter_name, value) + else + setter = guess_setter_name(statement.predicate) + subject.try(setter, value) if setter end end @@ -92,6 +90,16 @@ module DataFoodConsortium def resolve_object(object) @subjects[object] || object.object 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/spec/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index bccc0984ad..b04a3237fb 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -77,7 +77,9 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do 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[:fragment]).to eq "Piece" # Pending matching concepts: #expect(items.unit).to eq piece From 1adfb5463a5fbd474ac072aed6053980f0e43339 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 25 May 2023 12:49:05 +1000 Subject: [PATCH 08/11] Import DFC measures --- .../connector/importer.rb | 13 +++++++++- .../connector/skos_parser.rb | 24 +++++++++++++++++++ .../connector/importer_spec.rb | 5 +--- 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 lib/data_food_consortium/connector/skos_parser.rb diff --git a/lib/data_food_consortium/connector/importer.rb b/lib/data_food_consortium/connector/importer.rb index 1ac146873a..606e302835 100644 --- a/lib/data_food_consortium/connector/importer.rb +++ b/lib/data_food_consortium/connector/importer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "skos_parser" + module DataFoodConsortium module Connector class Importer @@ -88,7 +90,16 @@ module DataFoodConsortium end def resolve_object(object) - @subjects[object] || 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) 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/lib/data_food_consortium/connector/importer_spec.rb b/spec/lib/data_food_consortium/connector/importer_spec.rb index b04a3237fb..3ac97a7f1f 100644 --- a/spec/lib/data_food_consortium/connector/importer_spec.rb +++ b/spec/lib/data_food_consortium/connector/importer_spec.rb @@ -79,10 +79,7 @@ describe DataFoodConsortium::Connector::Importer, vcr: true do expect(tomato.name).to eq "Tomato" expect(tomato.quantity).to eq items expect(items.value).to eq 5 - expect(items.unit[:fragment]).to eq "Piece" - - # Pending matching concepts: - #expect(items.unit).to eq piece + expect(items.unit).to eq piece end it "imports properties with lists" do From df9e1ac2b45d2542dbe9b0f58b2ac98deb9c0c30 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 25 May 2023 15:20:58 +1000 Subject: [PATCH 09/11] Encapsulate SuppliedProductBuilder And improve EnterpriseBuilder. It was builder products twice. --- .../supplied_products_controller.rb | 2 +- .../dfc_provider/app/services/dfc_builder.rb | 17 +----------- .../app/services/enterprise_builder.rb | 2 +- .../app/services/supplied_product_builder.rb | 18 +++++++++++++ .../spec/services/enterprise_builder_spec.rb | 26 +++++++++++++++++++ .../services/supplied_product_builder_spec.rb | 13 +++++----- 6 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 engines/dfc_provider/app/services/supplied_product_builder.rb create mode 100644 engines/dfc_provider/spec/services/enterprise_builder_spec.rb 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..215f634ee0 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 @@ -7,7 +7,7 @@ module DfcProvider before_action :check_enterprise def show - product = DfcBuilder.supplied_product(variant) + product = SuppliedProductBuilder.supplied_product(variant) render json: DfcLoader.connector.export(product) end 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/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb new file mode 100644 index 0000000000..2c4639042d --- /dev/null +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -0,0 +1,18 @@ +# 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 +end 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 From ec8c710e3a2676f4fa3d258342fa977e670b46b3 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Thu, 25 May 2023 17:20:20 +1000 Subject: [PATCH 10/11] Import simple DFC SuppliedProduct OFN products and variants need more data like a price but the DFC stores that in a different object. We may get a larger graph containing that information but we don't have any test data yet. --- .../supplied_products_controller.rb | 16 +++++++++ .../services/quantitative_value_builder.rb | 15 ++++++++ .../app/services/supplied_product_builder.rb | 12 +++++++ engines/dfc_provider/config/routes.rb | 2 +- .../spec/requests/supplied_products_spec.rb | 34 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) 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 215f634ee0..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,10 +1,22 @@ # 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 = SuppliedProductBuilder.supplied_product(variant) @@ -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/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 index 2c4639042d..40dfe0c186 100644 --- a/engines/dfc_provider/app/services/supplied_product_builder.rb +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -15,4 +15,16 @@ class SuppliedProductBuilder < DfcBuilder 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( From a9f8c7c4abb590d5538f09bd3f17e11a8bfa09d9 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 19 Jun 2023 16:11:16 +1000 Subject: [PATCH 11/11] Update DFC API docs, describe all endpoints --- engines/dfc_provider/README.md | 45 ++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 8 deletions(-) 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 +```