How To

How do I migrate OpenIG 4 scripts from using blocking APIs to non-blocking APIs?

Last updated Jul 9, 2018

The purpose of this article is to provide information on migrating existing OpenIG 4 scripts from using blocking APIs to non-blocking APIs. Moving to non-blocking APIs guarantees an efficient use of resources and can prevent deadlock situations. You should also migrate your scripts if you have upgraded to OpenIG 4.5 or later, or are planning to upgrade in the future; otherwise you will likely encounter a "Cannot execute script" error when trying to run an existing script.


2 readers recommend this article

Overview

There is a known issue where existing scriptable filters and handlers stop working after upgrading to OpenIG 4.5 or later. This issue is caused by a class version conflict in the CatalogManager class, which is used by the xml-resolver-1.2.jar and is required by the HTTPBuilder API. This issue affects all scripts that use the CatalogManager class or libraries that depend on it, for example, the Groovy http-builder library. You can resolve this issue by migrating to CHF supported APIs.

You will see an error similar to the following in your logs when you encounter this issue:

TUE MAY 02 17:02:59 CET 2017 WARNING {ScriptableFilter}/handler/config/filters/0 --- Cannot execute script
TUE MAY 02 17:02:59 CET 2017 WARNING {ScriptableFilter}/handler/config/filters/0 --- java.lang.Exception: java.lang.NoSuchMethodError: org.apache.xml.resolver.CatalogManager.setIgnoreMissingProperties(Z)V 
javax.script.ScriptException: java.lang.Exception: java.lang.NoSuchMethodError: org.apache.xml.resolver.CatalogManager.setIgnoreMissingProperties(Z)V
   at org.forgerock.openig.script.Script$GroovyImpl.run(Script.java:62)
   at org.forgerock.openig.script.Script.run(Script.java:245) 
Caused by: java.lang.Exception: java.lang.NoSuchMethodError: org.apache.xml.resolver.CatalogManager.setIgnoreMissingProperties(Z)V
   ... 42 more
Caused by: java.lang.NoSuchMethodError: org.apache.xml.resolver.CatalogManager.setIgnoreMissingProperties(Z)V
   at groovyx.net.http.ParserRegistry.<clinit>(ParserRegistry.java:111)
   at groovyx.net.http.HTTPBuilder.<init>(HTTPBuilder.java:194)
   at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
   at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
   ... 41 more

It is strongly recommended that you migrate to CHF supported APIs and use documented variables even if you are not experiencing issues with your scripts. CHF non-blocking APIs guarantee an efficient use of resources and can prevent deadlock situations; they also ensure you can upgrade to OpenIG 4.5 and later without encountering this issue.

This article details the following two step migration process:

  1. Migrate http-builder APIs to CHF blocking APIs (they are simple to understand and starting using).
  2. Migrate to CHF asynchronous APIs.

Example script

The following example script to log a user out of AM/OpenAM uses the Groovy http-builder library; it fails in OpenIG 4.5 and later because there is a class version conflict on CatalogManager, which is provided by both the AM/OpenAM SAML Fedlet library and the Groovy http-builder library:

@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.7.1')
import groovyx.net.http.RESTClient

def openAMRESTClient = new RESTClient(openamUrl)

// Check if OpenAM session cookie is present
if (null != request.cookies['iPlanetDirectoryPro']) {
   String openAMCookieValue = request.cookies['iPlanetDirectoryPro'][0].value

   // Perform logout
   logger.info("iPlanetDirectoryPro cookie found, performing logout")
   def response = openAMRESTClient.post(path: 'sessions/',
                                        query: ['_action': 'logout'],
                                        headers: ['iplanetDirectoryPro': openAMCookieValue])
   def result = response.getData().get("result")
   logger.info("OpenAM logout response: " + result)
}

return next.handle(context, request)

The logic in this script is quite simple:

  1. If there is a session token in the request, get it.
  2. Call a dedicated AM/OpenAM REST endpoint to revoke the token (which logs the user out).
  3. Continue the execution of the chain (after the response has been received).

Migrating to CHF blocking APIs

This step details removing the dependency on the http-builder library and using the built-in CHF APIs instead. The migrated example script below demonstrates the basics of the CHF API: request creation, obtaining a response and reading its content as JSON (without forgetting to release resources).

Background

In each script’s execution context there is an HTTP binding (Client interface) that provides you with a way to perform HTTP requests from within your script.

From the Client API:

class Client {
   Promise<Response, NeverThrowsException> send(Request request);
   Promise<Response, NeverThrowsException> send(Context context, Request request);
}

There is only one thing that you can obviously do with this interface, that is, send an HTTP request. You do not get back the response directly, but you have the promise of a response. This is comparable to a Future on which you would attach listeners that get notified when the response has been fully received (or an error has happened).

This part of the migration uses the Promise as a Future (blocking on a .get()). In essence, this migration step is all about creating and populating a CHF Request object.

Migrated example script

The following shows the above example script after it has been migrated to use the built-in CHF APIs:

// Check if OpenAM session cookie is present
if (null != request.cookies[ 'iPlanetDirectoryPro' ]) {
   String openAMCookieValue = request.cookies[ 'iPlanetDirectoryPro' ][ 0 ].value

   logger.info("iPlanetDirectoryPro cookie found, performing logout")

   def logout = new Request()
   logout.method = "POST"
   logout.uri = "${openamUrl}/sessions"
   logout.uri.query = "_action=logout"
   logout.headers['iPlanetDirectoryPro'] = openAMCookieValue

   // Block for at most 20 seconds before using the response
   def logoutResponse = http.send(logout)
                            .get(20, SECONDS)

   def result = logoutResponse.entity.json.result
   logger.info("OpenAM logout response: " + result)

   // Don’t forget to release resources associated with the response
   logoutResponse.close()
}

return next.handle(context, request)

The logic in this script is as follows:

  1. Request initialization: the following snippet of this script creates a POST request to the AM/OpenAM sessions endpoint (using Groovy String substitution). It specifies the logout action in the query URL component and places the current request’s SSO Token (the iPlanetDirectoryPro cookie) in a header (as this is where the sessions endpoint expects it):
    def logout = new Request()
    logout.method = "POST"
    logout.uri = "${openamUrl}/sessions"
    logout.uri.query = "_action=logout"
    logout.headers['iPlanetDirectoryPro'] = openAMCookieValue
    
  2. Perform the request and wait for the response: the following snippet of this script uses the get() method. The returned promise can never throw an exception, which means you do not need to use the getOrThrow() variant of the get() method; if something goes wrong when making the request, you will receive a 502 Bad Gateway response. This example also includes a timeout to prevent deadlocks if there is no response. When the timeout is reached, a RuntimeException is returned; ideally the timeout should be configurable and IG/OpenIG provides the args configuration point in the script configuration, which can be used for this purpose:
    // Block for at most 20 seconds before using the response
    def logoutResponse = http.send(logout)
                             .get(20, SECONDS)
    
  3. Read the response's content using the CHF's native JSON support: the following snippet of this script calls logoutResponse.entity.json, which provides the JSON-parsed content of the message, with a focus on the result JSON attribute. The message is then closed to release resources:
    def result = logoutResponse.entity.json.result
    
    // Don’t forget to release resources associated with the response
    logoutResponse.close()
    
    
  4. Call the next element of the chain and continue the request processing:
    return next.handle(context, request)
    

Migrate to CHF asynchronous APIs

This step details moving away from synchronous programming (where we spend valuable CPU time waiting for things to happen) to asynchronous programming (where we register callbacks to notify us when things are ready and act when that happens rather than waiting for something; this allows our valuable CPU time to do other things). It also introduces the http.send() returned Promise object, where it’s the promise of a response that may or may not have been received yet. Put simply, we want to read the response content after it has been received, log the result and then continue the chain execution.

This step demonstrates adding an asynchronous function that calls the next element in the chain, after the response processing has been done.

Migrated example script

The following shows the example script after it has been migrated to use the CHF asynchronous APIs:

// Check if OpenAM session cookie is present
if (null != request.cookies[ 'iPlanetDirectoryPro' ]) {
   String openAMCookieValue = request.cookies[ 'iPlanetDirectoryPro' ][ 0 ].value

   logger.info("iPlanetDirectoryPro cookie found, performing logout")

   def logout = new Request()
   logout.method = "POST"
   logout.uri = "${openamUrl}/sessions"
   logout.uri.query = "_action=logout"
   logout.headers['iPlanetDirectoryPro'] = openAMCookieValue

   // Return the "promise" of a result => non-blocking
   // Processing will happen when a response from AM will be received
   return http.send(logout)
              .then { response ->
                   def result = response.entity.json.result
                   logger.info("OpenAM logout response: " + result)
                   response.close()
                   return response
              }
              .thenAsync({
                   next.handle(context, request)
              } as AsyncFunction)
}

logger.info("iPlanetDirectoryPro cookie not found, continue and ignore logout action")
return next.handle(context, request)

As you can see, most of the changes have occurred towards the end of the script, with the response being processed using callbacks. Additionally, since we no longer block, we do not need a timeout to prevent deadlocks.

The logic in this script is as follows:

  1. Process the response when it’s ready: the following snippet of this script introduces a then(Function) method to process this response. The callback Function provided in this then() method accepts the result of the invocation as a parameter, which can be processed as required; this example reuses the response processing logic from before to return the response (a result is expected from Function callbacks):
    return http.send(logout)
               .then { response ->
                    def result = response.entity.json.result
                    logger.info("OpenAM logout response: " + result)
                    response.close()
                    return response
               }
    
    
    You must include the first return in this section of the script to ensure the promise that is configured with the response processing function is returned when the logout action is triggered. If you exclude the return, for example, the first line is simply:
    http.send(logout)
    
    The script will execute, do an HTTP call, register a callback on the promise and then just continue. This means the logout action will get missed when there is an iPlanetDirectoryPro cookie, which is not the desired behavior of this script.
  2. Continue the execution of the chain after triggering the logout action: the following snippet of this script calls the next element of the chain by performing the call in a then-able fashion (chaining different then() methods together). You can use this approach because you will just see the response of the logout action when a request with an iPlanetDirectoryPro cookie enters the filter, rather than the usual response sent back from the protected application:
               .thenAsync({
                   next.handle(context, request)
               } as AsyncFunction)
    

Groovy specific points

  •  There is no return statement, because Groovy considers the last evaluated expression of a method to be the returned value.
  • The as AsyncFunction is only required for asynchronous functions (ResultHandler, ExceptionHandler and Function don’t need it). The Groovy compiler doesn’t handle all the generic types of AsyncFunction very well.

Further information on the Promise object

There are a number of then-able methods offered by the Promise API that you can try, which will make your code simpler and avoid 'callback hell'. These methods fall into the following categories:

thenOn****(****Handler) These methods are pure notification functions (on successful result, on error). You can’t return anything from within these methods, they are here as a side-effect-free type of handler. No exceptions should be thrown in theses callbacks.
then(Function), with 1, 2 or 3 Function arguments These methods are designed for when you need to return something different to the next handler in the Promise chain (a transformation). They could be parsing an input string and return a JSON, or modifying the result. Throwing exceptions is accepted here and they will be propagated to the next functions in the Promise chain.
thenAsync(AsyncFunction), with 1, 2 or 3 AsyncFunction arguments These methods are quite similar to those in then(Function). The real difference is that they have to return a promise of their result instead of the actual result. These methods are an ideal fit when you need to call another method that itself returns a promise (like returning the result of the filter’s chain).
then***(), the rest of the then-able methods (thenAlways(), thenFinally(), thenCatch(), ...) These methods are really syntactic sugar to make the promise more fluent to read.

Now you can understand why our first then() can be turned into a thenOnResult() instead, which saves the last return statement as the received parameter is returned:

return http.send(logout)
           .thenOnResult { response ->
               def result = response.entity.json.result
               logger.info("OpenAM logout response: " + result)
               response.close()
           }
           .thenAsync({
               next.handle(context, request)
           } as AsyncFunction)

See Also

N/A

Related Training

N/A

Related Issue Tracker IDs

OPENIG-910 (ScriptableFilter : Get error `Cannot execute script` with groovy scripts previously working)



Copyright and TrademarksCopyright © 2018 ForgeRock, all rights reserved.
Loading...