Skip to content

Writing a service plugin

jrochkind edited this page Sep 9, 2014 · 1 revision

An Umlaut service plugin (sometimes called a 'service adapter' or just a 'service') is a plugin that, based on the current citation (the Request's Referent), (usually) consults an external third party service of some sort (usually over an HTTP api), and then does one or both of:

  • Enhance the citation metadata (Referent) for the current request, with additional metadata from the third party.
  • Add a ServiceResponse, representing a unit of content to deliver to the user.

Examples of service plugins bundled with Umlaut:

  • Amazon: Consult Amazon for isbn in Referent, enhance local citation with author/title if needed, add ServiceResponses for links to 'search inside', 'limited preview', and/or link to Amazon page.
  • SFX: Send incoming OpenURL on to SFX, look at SFX XML api response in order to enhance umlaut referent and add ServiceResponses for some or all of SFX responses.
  • Scopus: Look up doi, or author/article/journal citation info in Scopus API, add ServiceResponse for "X articles cite this", linking to Scopus "cited by" page, with "X" filled in to proper number.

A service plugin is just a ruby class in a file somewhere, that implements the right api for a service plugin. Umlaut itself comes with a bunch of service plugins in ./lib/service_adapters in Umlaut source. When you're developing a new plugin, it can simply be in your local app, perhaps in ./lib. Although unless it's really of purely local interest, we hope you contribute it back to Umlaut core when it's finished enough to be useful. Alternately, service plugins could even in theory be distributed in their own separate gems, although we haven't done that yet.

Please be sure to see Developing wiki page for more info.

So let's get to what a Service plugin looks like.

Service apparatus

At the moment, a Service plugin must sub-class Service. It should implement a #service_types_generated method returning an array of string ServiceTypeValues (see Umlaut service_types.yml for list of service types) for the types of ServiceResponses this plugin might return.

The config parameters set in the umlaut_services.yml file will be set as instance variables in the instantiated object of your class. You can implement #initialize(), and set default values before or after calling #super. Document what config keys your service takes and what they mean, you can use any arbitrary keys you want (or no keys at all).

You can also use the required_config_params class method to mark certain umlaut_services.yml config parameters as required -- Umlaut will error on startup if they're not given.

The service implements a #handle method to do it's actual work. The #handle method must return a value indicating successful completion (or not), using request.dispatched. Returning true means successful completion (doesn't necessarily mean the service did anything, it may have successfully decided there wasn't enough information in the Referent to do anything). true is actually a shortcut for

class WidgetService < Service
   required_config_param :api_key, :other_api_key

   def service_types_generated
      return [ ServiceTypeValue['fulltext'], ServiceTypeValue['table_of_contents'], ServiceTypeValue['highlighted_link']]
   end

   def initialize(config)
     @app_name = "Some University Widget Factory" # default
     super  # will over-write defaults
     # normalization or validity checking of all config values could
     # be done after super
   end

   # The argument is an Umlaut Request object representing an OpenURL request
   # to Umlaut. It also captures things like the complete original HTTP env
   # the request was made under. 
   # http://rubydoc.info/github/team-umlaut/umlaut/master/Request
   def handle(request)
     # no-op for now
     
     # and return 'success'
     return request.dispatched(self, true)
   end
end

A new instance of your Service class will be instantiated for every execution, so you can feel free to store state information unique to the request/execution in iVars.

You are welcome to look at existing services as models, but don't assume they are best practices, many of them are hacky old code, as Umlaut has evolved. If you see a better way to structure the code, don't feel bound to copy what's there.

Looking at current Referent

You'll generally need to look at the existing Referent to get elements of the current citation, to build a query to your external service, or even to decide if there's enough information avail to bother querying the external service. (Maybe you need an ISBN, and will do nothing without one, no prob).

The handle(request) invokation will pass in an Umlaut request object.

Look at request.referent.metadata to get a Hash of key/values for the Referent citation. The keys are generally keys from the OpenURL book/journal/dissertation kev formats.

At the moment, the #metadata call is slightly expensive, so call it once and store it in a var:

  metadata = request.referent.metadata
  metadata['atitle']
  metadata['jtitle']
  metadata['au']
  metadata['aufirst']
  metadata['spage']
  etc

Values passed in an OpenURL as URI identifiers are instead available from request.referent.identifiers.

Umlaut's got a MetadataHelper helper mix-in modules to make it easier to extract what you want from a Referent, where certain things can be stored in several possible places, especially when one of them is a URI identifier. Take a look at the source (for some reason not included in generated docs as I write this).

  class WidgetService < Service
    include MetadataHelper

    #....

    def handle(request)
      #...

      isbn = get_isbn(request.referent)
      issn = get_issn(request.referent)
      doi  = get_doi(request.referent)
      year = get_year(request.referent)
      pmid = get_pmid(request.referent)
      title_is_serial = title_is_serial?(request.referent)

      # and a couple useful for getting possibly useful normalized
      # query terms for external searches
      author_query = get_search_creator(request.referent)
      title_query = get_normalized_title(request.referent) # this method has a buncha options
    
      #...
   end
 end

Enhancing Referent

One thing you might want to do is enhance Umlaut's Referent citation with info you get from third party service. For one example, maybe the incoming OpenURL only had an ISSN. SFX will generally provide journal title in it's response, it would be useful to enhance the Umlaut Referent with this journal title.

The #enhance_referent method on Umlaut's Referent can be used to add a key/value. By default, it will only add if no existing value exists for that key, it won't overwrite:

request.referent.enhance_referent("jtitle", sfx_metadata["journal_title"])

You can pass an option to overwrite existing values too, although it ends up being something you usually don't want to do. (sorry for the weird method signature, there's some intervening params that aren't really useful, so we just pad em with nil):

request.referent.enhance_referent("jtitle", sfx_metadata["journal_title"], nil, nil, :overwrite => true)

If you're going to be calling #enhance_referent a bunch of times, some idiosyncracies with Umlaut's internal implementation interactions with multi-threaded concurrency and ActiveRecord probably make it more efficient to wrap in an ActiveRecord with_connection:

ActiveRecord::Base.connection_pool.with_connection do
   request.referent.enhance_referent("title", title)
   request.referent.enhance_referent("au", author)
   request.referent.enhance_referent("date", date)
   #...
end

You can also use request.referent.add_identifier(uri) to add URI identifiers such as are sent in OpenURL rft_id.

Adding ServiceResponses

A ServiceResponse represents, generally, an element of content to be displayed to the user. Each ServiceResponse has a type associated with it ('fulltext', 'table_of_contents', 'holding', etc). The possible types are held in service_type_values.yml in umlaut source, and also generally referred to in the resolve_sections config declaring the page layout.

A Service plugin when executed can potentially create many ServiceResponses, of different types even.

The method used to add a ServiceResponse is request.add_service_response which takes a hash of key/values.

Required keys are :service_type_value with a service type, and :service, which you can pass self into, in a Service plugin.

There are some other standard keys, and some others conventional to particular service types. See the ServiceResponse generated documentation for conventional key listing.

def handle(request)
#... Note could also be in another method that has access to the request
# passed in in #handle, you don't NEED to shove everything into one big method
  request.add_service_response(
    :service => self,
    :service_type_value => 'fulltext',
    :display_text => "Link to magic fulltext",
    :notes => "Magic fulltext is magic.",
    :url => "http://magic.example.com/magic/12345"
  )
#...
end

Clickable links

Fulltext ServiceResponses, and many other types too, are associated with a single clickable link.

The easiest way to provide this link is to pass it in as a :url key when creating the ServiceResponse, as above.

However, you can also delay calculation of the destination URL until after the user has actually clicked on it. This can be used for expensive to calculate URLs (maybe require a seperate API call to some external service), or sometimes for URLs where you need some info from the submit action to calculate the destination.

For this situation, you can instead define a #response_url method in your Service plugin that will be called by Umlaut after a click on the ServiceResponse. You could put a base URL in :url in the ServiceResponse to be added onto dynamically later, although you don't have to.

For instance, here's how the HathiTrust service calculates the 'deep link' into a set of 'search inside' results, for a term entered by the user in Umlaut.

 # Handle search_inside
  def response_url(service_response, submitted_params)
    if ( ! (service_response.service_type_value.name == "search_inside" ))
      return super(service_response, submitted_params)
    else
      base = service_response[:url]      
      query = CGI.escape(submitted_params["query"] || "")
      url = base + "&q1=#{query}"

      return url
    end
  end

Note how the implementation calls to #super for default handling of any ServiceResponse that's of a type other than :search_inside. The default implementation simply sends the user to service_response[:url].

Dependencies/Conventional gems

Use nokogiri for XML parsing. (already included as Umlaut dependency)

Use multi_json MultiJson.decode for JSON parsing. (Already included as Umlaut dependency)

For making HTTP calls... there's too many options in ruby, I have yet to pick one as Umlaut reccommended. Do what works best for you. A library based on Net::HTTP (or raw Net::HTTP itself) is preferred, in case we eventually want to use VCR for testing. HTTPClient is my own current recommendation, although it's not currently included as an Umlaut dependency.

If you use additional gems not already included as an Umlaut dependency:

  • For a service plugin that lives in your local app, just add those gems to your Gemfile!
  • If/when you eventually want to contribute your service back to Umlaut distro, we'd need to add those gems to Umlaut's dependencies (listed in umlaut.gemspec in umlaut source) -- or decide to distribute your service in a gem of it's own instead.

Writing a Service Plugin for your ILS/OPAC

Something you will want to do to maximize value of Umlaut to your users. Something that is sadly kind of complicated to do. More tips and advice coming TODO. Ask on the listserv to encourage jrochkind to write something.