Skip to content

Commit

Permalink
Introducing derivative locator/applicator concepts
Browse files Browse the repository at this point in the history
Prior to this commit, I was passing an array of strings back from the
locator.  With that pathway, I was ignoring how the existing
`Hyrax::FileSetDerivativesService` would likely be used as a fallback.

With this commit, I'm beginning to account for how that interaction
would be; in part by focusing on a known use case and building the
interactions accordingly.

This commit stencils in the expected behavior in what I think is
adequate to convey intention and design.

I also begin demonstrating how the original configuration can be
leveraged to do the derivative work.  This still assumes a configuration
for derivatives; something that I continue to punt on until I have more
concrete code that I can further build from.

Related to:

- samvera/bulkrax#760
- samvera/bulkrax#761
- notch8/utk-hyku#343
  • Loading branch information
jeremyf committed Mar 23, 2023
1 parent e338428 commit fbfa8b0
Show file tree
Hide file tree
Showing 6 changed files with 607 additions and 0 deletions.
238 changes: 238 additions & 0 deletions lib/samvera/derivatives.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# frozen_string_literal: true

require 'samvera/derivatives/configuration'

##
# Why Samvera and not Hyrax? Because there are folks creating non-Hyrax applications that will
# almost certainly want to leverage this work. And there might be switches for `defined?(Hyrax)`.
#
# Further the following four gems have interest in the interfaces of this module:
#
# - Hyrax
# - Hydra::Derivatives
# - Bulkrax
# - IiifPrint
#
# As such, it makes some sense to isolate the module and begin defining interfaces.
module Samvera
##
# This module separates the finding/creation of a derivative binary (via {FileLocator}) and
# applying that derivative to the FileSet (via {FileApplicator}).
#
# In working on the interface and objects there is an effort to preserve backwards functionality
# while also allowing for a move away from that functionality.
#
# There are three primary concepts to consider:
#
# - Locator :: responsible for knowing where the derivative is
# - Location :: responsible for encapsulating the location
# - Applicator :: responsible for applying the located derivative to the FileSet
#
# The "trick" in this is in the polymorphism of the Location. Let's say we have the following
# desired functionality for the thumbnail derivative:
#
# ```gherkin
# Given a FileSet
# When I provide a thumbnail derivative
# Then I want to add that as the thumbnail for the FileSet
#
# Given a FileSet
# When I do not provide a thumbnail derivative
# Then I want to generate a thumbnail
# And add the generated as the thumbnail for the FileSet
# ```
#
# In the above case we would have two Locator strategies:
#
# - Find Existing One
# - Will Generate One (e.g. Hyrax::FileSetDerivativesService with Hydra::Derivative behavior)
#
# And we would have two Applicator strategies:
#
# - Apply an Existing One
# - Generate One and Apply (e.g. Hyrax::FileSetDerivativesService with Hydra::Derivative behavior)
#
# The Location from the first successful Locator will dictate how the ApplicatorStrategies do
# their work.
module Derivatives
##
# @api public
#
# Responsible for configuration of derivatives.
#
# @example
# Samvera::Derivative.config do |config|
# config.register(type: :thumbnail, applicators: [CustomApplicator], locators: [CustomLocator]) do |file_set|
# file_set.video? || file_set.audio? || file_set.image?
# end
# end
#
# @yield [Configuration]
#
# @return [Configuration]
def self.config
@config ||= Configuration.new
yield(@config) if block_given?
@config
end

##
# @api public
#
# Locate the derivative for the given :file_set and apply it to that :file_set.
#
# @param file_set [FileSet]
# @param derivative [Samvera::Derivatives::Configuration::RegisteredType]
# @param file_path [String]
#
# @note As a concession to existing implementations of creating derivatives, file_path is
# included as a parameter.
def self.locate_and_apply_derivative_for(file_set:, derivative:, file_path:)
return false unless derivative.applicable_for?(file_set: file_set)

from_location = FileLocator.call(
file_set: file_set,
file_path: file_path,
derivative: derivative
)

FileApplicator.call(
from_location: from_location,
file_set: file_set,
derivative: derivative
)
end

##
# The purpose of this module is to find the derivative file path for a FileSet and a given
# derivative type (e.g. :thumbnail).
#
# @see https://github.com/samvera-labs/bulkrax/issues/760 Design Document
#
# @note Ideally, this module would be part of Hyrax or Hydra::Derivatives
# @see https://github.com/samvera/hyrax
# @see https://github.com/samvera/hydra-derivatives
module FileLocator
##
# @api public
#
# This method is responsible for finding the correct file names for the given file set and
# derivative type.
#
# @param file_set [FileSet]
# @param file_path [String]
# @param derivative [Samvera::Derivatives::Configuration::RegisteredType]
#
# @return [Samvera::Derivatives::FromLocation]
#
# @note Why {.call}? This allows for a simple lambda interface, which can greatly ease testing
# and composition.
def self.call(file_set:, file_path:, derivative:)
from_location = nil

derivative.locators.each do |locator|
from_location = locator.locate(
file_set: file_set,
file_path: file_path,
derivative_type: derivative.type
)
break if from_location.present?
end

from_location
end

##
# @abstract
#
# The purpose of this abstract class is to provide the public interface for strategies.
#
# @see {.find}
class Strategy
##
# @api public
# @param file_set [FileSet]
# @param file_path [String]
# @param derivative_type [#to_sym]
#
# @return [Samvera::Derivatives::FromLocation] when this is a valid strategy
# @return [NilClass] when this is not a valid strategy
def self.locate(file_set:, file_path:, derivative_type:)
raise NotImplementedError
end
end
end

module FileApplicator
##
# @api public
#
# @param file_set [FileSet]
# @param from_location [#present?]
# @param derivative [Array<#apply!>]
def self.call(file_set:, from_location:, derivative:)
# rubocop:disable Rails/Blank
return false unless from_location.present?
# rubocop:enable Rails/Blank

derivative.applicators.each do |applicator|
applicator.apply!(file_set: file_set, derivative_type: derivative.type, from_location: from_location)
end
end

##
# @abstract
#
# The purpose of this abstract class is to provide the public interface for strategies.
#
# @see {.find}
class Strategy
# In some cases the FromLocation knows how to write itself; this is the case when we wrap
# the Hyrax::FileSetDerivativesService.
class_attribute :delegate_apply_to_given_from_location, default: false

##
# @param file_set [FileSet]
# @param derivative_type [#to_sym]
# @param from_location [Object]
def self.apply!(file_set:, derivative_type:, from_location:)
new(file_set: file_set, derivative_type: derivative_type, from_location: from_location).apply!
end

def initialize(file_set:, derivative_type:, from_location:)
@file_set = file_set
@derivative_type = derivative_type
@from_location = from_location
end
attr_reader :file_set, :derivative_type, :from_location

# @note What's going on with this logic? To continue to leverage
# Hyrax::FileSetDerivativesService, we want to let that wrapped service (as a
# FromLocation) to do it's original work. However, we might have multiple strategies
# in play for application. That case is when we want to first check for an existing
# thumbnail and failing that generate the thumbnail. The from_location could either
# be the found thumbnail...or it could be the wrapped Hyrax::FileSetDerivativesService
# that will create the thumbnail and write it to the location. The two applicator
# strategies in that case would be the wrapper and logic that will write the found
# file to the correct derivative path.
def apply!
if delegate_apply_to_given_from_location?
return false unless from_location.respond_to?(:apply!)

from_location.apply!(file_set: file_set, derivative_type: derivative_type)
else
return false if from_location.respond_to?(:apply!)

perform_apply!
end
end

private

def perform_apply!
raise NotImplementedError
end
end
end
end
end
83 changes: 83 additions & 0 deletions lib/samvera/derivatives/configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

module Samvera
module Derivatives
##
# The purpose of this class is to contain the explicit derivative generation directives for the
# upstream application.
#
# @note The implicit deriviate types for Hyrax are as follows:
# - type :extracted_text with sources [:pdf, :office_document]
# - type :thumbnail with sources [:pdf, :office_document, :thumbnail, :image]
# - type :mp3 with sources [:audio]
# - type :ogg with sources [:audio]
# - type :webm with sources [:video]
# - type :mp4 with sources [:video]
#
# @note A long-standing practice of Samvera's Hyrax has been to have assumptive and implicit
# derivative generation (see Hyrax::FileSetDerivativesService). In being implicit, a
# challenge arises, namely overriding and configuring. There exists a crease in the code
# to allow for a different derivative approach (see Hyrax::DerivativeService). Yet that
# approach continues the tradition of implicit work.
class Configuration
def initialize
# Favoring a Hash for ease of lookup as well as the concept that there can be only one entry
# per type.
@registered_types = {}
end

# TODO: Consider the appropriate extension
RegisteredType = Struct.new(:type, :locators, :applicators, :applicability, keyword_init: true) do
def applicable_for?(file_set:)
applicability.call(file_set)
end
end

##
# @api pulic
#
# @param type [Symbol] The named type of derivative
# @param locators [Array<Samvera::Derivatives::FileLocator::Strategy>] The strategies that
# we'll attempt in finding the derivative that we will later apply.
# @param applicators [Array<Samvera::Derivatives::FileApplicator::Strategy>] The strategies
# that we'll use to apply the found derivative to the {FileSet}
#
# @yieldparam applicability [#call]
#
# @return [RegisteredType]
#
# @note What is the best mechanism for naming the sources? At present we're doing a lot of
# assumption on the types.
def register(type:, locators:, applicators:, &applicability)
# Should the validator be required?
@registered_types[type.to_sym] = RegisteredType.new(
type: type.to_sym,
locators: Array(locators),
applicators: Array(applicators),
applicability: applicability || default_applicability
)
end

##
# @api public
#
# @param type [Symbol]
#
# @return [RegisteredType]
def registry_for(type:)
@registered_types.fetch(type.to_sym) { empty_registry_for(type: type.to_sym) }
end

private

def empty_registry_for(type:)
RegisteredType.new(type: type, locators: [], applicators: [], applicability: ->(_file_set) { false })
end

# We're going to assume this is true unless configured otherwise.
def default_applicability
->(_file_set) { true }
end
end
end
end
Loading

0 comments on commit fbfa8b0

Please sign in to comment.