Merge pull request #11008 from mkllnk/dfc-connector-import

Prototype: Import products via DFC Connector
This commit is contained in:
David Cook
2023-06-21 12:23:58 +10:00
committed by GitHub
17 changed files with 474 additions and 40 deletions

View File

@@ -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.
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
```

View File

@@ -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])

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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