This version of the manual refers to an earlier version of the software.

Delegate Script

The delegate script mechanism enables the use of custom code to customize the image server's behavior. It is designed for ease-of-use, with a simple interface and shallow learning curve. The language in which the code is written—Ruby—is easy to learn and work with, and usually, only a small amount of code is needed.

Delegate methods are invoked by a JRuby interpreter bundled into the application. There is no need to install an external Ruby environment and no need to write Java or interact with the application's internal API.

The JRuby interpreter is compatible with Ruby version 2.3.


Enabling

The delegate script is disabled by default. To enable it, follow these steps:

  1. Copy the sample delegate script, delegates.rb.sample, included in the distribution, to delegates.rb.
  2. Reference it from the delegate_script.pathname configuration option.
  3. Set delegate_script.enabled to true.

How It Works

The delegate script is a file containing a delegate class written in Ruby. The class is instantiated per-request, early in the request cycle, and disposed of at the end of the request cycle. At various points in the request cycle, its methods are called by the application to obtain custom information needed to service the request.

Before any other methods are called, the application will set the request context, which is a hash of request properties with perhaps some other helpful information mixed in. See the documentation of the context attribute (attr_accessor :context) in the sample delegate script file for information about the keys that may be present in the context hash.

You can also use code like context.each{ |k,v| puts "#{k}: #{v}" } in any method to print the context to the console.

The delegate script is reloaded whenever the script file changes. Be aware, though, that code that has already been loaded into the JRuby runtime cannot be unloaded. For example, when a class is changed, the new version will replace the old version; but constants within the class cannot be redefined.

Generally, neither method arguments nor return values are sanitized or validated. Be careful to write defensive, injection-safe code.

Migrating From the 3.x Script to the 4.x Script

In version 4, the delegate script was redesigned to address several limitations of the original design. A comparison of the two follows:

3.x script 4.x script
Design Static methods using Ruby modules as namespaces Class instantiated upon each request; no modules
Arguments Provided to methods; methods can only see their arguments Methods have no (or few) arguments and instead can access a request context hash containing a superset of all of the 3.x methods' arguments
State Methods are stateless and cannot share information Methods may use the instance state to share information
Sharing information across requests Methods can share state globally, across requests, if they are careful to do it thread-safely Same
Initialization No per-request initialization Override initialize() or context=()
Old Method New Method(s) Notes
Cantaloupe::authorized? CustomDelegate.authorized?() Method has been split into two.
CustomDelegate.redirect()
Cantaloupe::extra_iiif2_information_response_keys() CustomDelegate.extra_iiif2_information_response_keys()
Cantaloupe::get_resolver() CustomDelegate.source()
Cantaloupe::AmazonS3Resolver::get_object_key() CustomDelegate.s3source_object_info() The old version can return either a string object key, or a hash containing bucket and key keys; the new version must return the latter.
Cantaloupe::AzureStorageResolver::get_blob_key() CustomDelegate.azurestoragesource_blob_key()
Cantaloupe::FilesystemResolver::get_pathname() CustomDelegate.filesystemsource_pathname()
Cantaloupe::HttpResolver::get_url() CustomDelegate.httpsource_resource_info()
Cantaloupe::JdbcResolver::get_database_identifier() CustomDelegate.jdbcsource_database_identifier()
Cantaloupe::JdbcResolver::get_media_type() CustomDelegate.jdbcsource_media_type()
Cantaloupe::JdbcResolver::get_lookup_sql() CustomDelegate.jdbcsource_lookup_sql()
Cantaloupe::overlay() CustomDelegate.overlay() The old version returns false for no overlay. The new version returns nil.
Cantaloupe::redactions() CustomDelegate.redactions()

Gems

JRuby can load most Ruby gems, except those that have been built with native extensions. To import a gem, use a require statement:

require 'mygem'

require searches for gems based on the $GEM_PATH environment variable, falling back to $GEM_HOME if that is not defined. If JRuby fails to find your gem, check your $GEM_PATH. If you installed the gem using gem install, check the output of gem env (particularly the "gem paths" section) to see where it might have been installed, and ensure that those locations are present in $GEM_PATH.


Calling Java Code

This example uses URLConnection, which is part of the JDK, to execute an HTTP request, as an alternative to other examples which use Ruby's Net::HTTP library.

require 'java'

java_import java.net.HttpURLConnection
java_import java.net.URL
java_import java.io.BufferedReader
java_import java.io.FileNotFoundException
java_import java.io.InputStreamReader
java_import java.util.stream.Collectors

class CustomDelegate
  def do_something
    url = URL.new('http://example.org/')
    conn, is, reader = nil
    begin
      conn = url.openConnection
      conn.setRequestMethod 'GET'
      conn.setReadTimeout 30 * 1000
      conn.connect
      is = conn.getInputStream
      status = conn.getResponseCode

      if status == 200
        content_type = conn.getHeaderField 'Content-Type'
        if content_type.include? 'text/plain'
          reader = BufferedReader.new(InputStreamReader.new(is))
          entity = reader.lines.collect(Collectors.joining("\n"))
          puts entity
        else
          raise IOError, "Unexpected Content-Type: #{content_type}"
        end
      else
        raise IOError, "Unexpected status: #{status}"
      end
    rescue FileNotFoundException => e
      return nil
    rescue => e
      Java::edu.illinois.library.cantaloupe.script.Logger.error("#{e}", e)
    ensure
      reader&.close
      is&.close
      conn&.disconnect
    end
  end
end

See also: CallingJavaFromJRuby

There's nothing to stop you from using third-party JARs and accessing their API from JRuby. This is not recommended, though, as JARs may contain code that conflicts with the application's dependencies—different versions of the same library, for example.


Improving Efficiency

Several delegate methods will be called over the course of a single request, and making them as efficient as possible will improve response times. A couple of techniques for improving efficiency are:

Sharing Information

Some methods may need to do similar work. For example, authorized?() and redirect() may have to trigger the same expensive call to an authorization service. To avoid having to do this twice, a useful technique is to cache the result. So, rather than doing this:

class CustomDelegate
  def redirect(options = {})
    # perform an expensive query and return the result
  end

  def authorized?(options = {})
    # perform an expensive query and return the result
  end
end

You could do this:

class CustomDelegate
  def redirect(options = {})
    result = perform_expensive_query
  end

  def authorized?(options = {})
    result = perform_expensive_query
  end

  # Performs an expensive query only once, caching the result.
  def perform_expensive_query
    unless @result
      # perform the query
      @result = ... # save the result in an instance variable
    end
    @result
  end
end

Connection Pooling

Most HTTP clients maintain an internal connection pool automatically, but JDBC adapters do not. Any code that accesses a database via JDBC should use a connection pool to improve performance. As of version 4.0, there is no "official" provision for this. Options include:

  1. Use the built-in HikariCP pool used by JdbcSource and JdbcCache, noting that HikariCP is not part of the delegate script contract and may change or go away at some point (see here for an example that may or may not work);
  2. Supply a third-party connection pool JAR;
  3. Write your own connection pool

Caching

The delegate_script.cache.enabled option is available to cache the results of delegate method invocations. The cache is an in-memory least-recently-used (LRU) cache with infinite time-to-live and a maximum size auto-computed based on the maximum JVM heap size. When the limit is approached, the oldest invocations will be purged automatically.

The invocation cache can also be purged manually using the HTTP API.

The cache is not persisted. It will be lost when the application is stopped.

Cached invocations are not purged when the script file is auto-reloaded, such as in response to a change. If, while the application is running, you modify the script file in a way that would cause a return value to be different based on the same request context, you should either purge the invocation cache or restart.


Logging

Delegate methods may access a logger that writes to the application log:

require 'java'

logger = Java::edu.illinois.library.cantaloupe.script.Logger
logger.trace 'Hello world'
logger.debug 'Hello world'
logger.info 'Hello world'
logger.warn 'Hello world'
logger.error 'Hello world'

Error stack traces may also be logged:

require 'java'

logger = Java::edu.illinois.library.cantaloupe.script.Logger

begin
  raise 'Goodbye world'
rescue => e
  logger.error "#{e}", e
end

Testing Delegate Methods

Delegate methods can be tested by creating an instance of the CustomDelegate class, setting its context to be similar to what the application would set it to, and calling a method:

# This file is named `test.rb`, in the same folder as `delegates.rb`
require './delegates'

obj = CustomDelegate.new
obj.context = {
  'identifier' => 'identifier-to-test',
  'client_ip' => '127.0.0.1',
  'request_headers' => ...
}

puts obj.filesystemsource_pathname

This script can then be run on the command line with a command like: ruby test.rb.

The ruby command will normally invoke the standard ("MRI") Ruby interpreter, and not the JRuby interpreter. While they mostly work the same, gems with platform-native extensions won't work in JRuby. Consider installing a standalone JRuby interpreter and test with that instead. (Something like RVM can make it easier to switch between different versions of the Ruby interpreter.)