001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2014 ForgeRock AS.
015 */
016package org.forgerock.openig.script;
017
018import static org.forgerock.openig.config.Environment.*;
019import static org.forgerock.openig.http.HttpClient.*;
020
021import java.io.IOException;
022import java.util.HashMap;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.concurrent.ConcurrentHashMap;
026
027import javax.script.ScriptException;
028
029import org.forgerock.json.fluent.JsonValueException;
030import org.forgerock.openig.config.Environment;
031import org.forgerock.openig.handler.Handler;
032import org.forgerock.openig.handler.HandlerException;
033import org.forgerock.openig.heap.GenericHeapObject;
034import org.forgerock.openig.heap.GenericHeaplet;
035import org.forgerock.openig.heap.HeapException;
036import org.forgerock.openig.http.Exchange;
037import org.forgerock.openig.http.HttpClient;
038import org.forgerock.openig.ldap.LdapClient;
039
040/**
041 * An abstract scriptable heap object which should be used as the base class for
042 * implementing {@link org.forgerock.openig.filter.Filter filters} and {@link Handler handlers}. This heap
043 * object acts as a simple wrapper around the scripting engine. Scripts are
044 * provided with the following variable bindings:
045 * <ul>
046 * <li>{@link Map globals} - the Map of global variables which persist across
047 * successive invocations of the script
048 * <li>{@link Exchange exchange} - the HTTP exchange
049 * <li>{@link HttpClient http} - an OpenIG HTTP client which may be used for
050 * performing outbound HTTP requests
051 * <li>{@link LdapClient ldap} - an OpenIG LDAP client which may be used for
052 * performing LDAP requests such as LDAP authentication
053 * <li>{@link org.forgerock.openig.log.Logger logger} - the OpenIG logger
054 * <li>{@link Handler next} - if the heap object is a filter then this variable
055 * will contain the next handler in the filter chain.
056 * </ul>
057 * <p>
058 * <b>NOTE:</b> at the moment only Groovy is supported.
059 */
060public abstract class AbstractScriptableHeapObject extends GenericHeapObject {
061
062    /** Creates and initializes a capture filter in a heap environment. */
063    protected static abstract class AbstractScriptableHeaplet extends GenericHeaplet {
064        private static final String CONFIG_OPTION_FILE = "file";
065        private static final String CONFIG_OPTION_SOURCE = "source";
066        private static final String CONFIG_OPTION_TYPE = "type";
067        private static final String CONFIG_OPTION_ARGS = "args";
068
069        @Override
070        public Object create() throws HeapException {
071            final Script script = compileScript();
072            final AbstractScriptableHeapObject component = newInstance(script);
073            HttpClient httpClient = heap.resolve(config.get("httpClient")
074                                                                 .defaultTo(HTTP_CLIENT_HEAP_KEY),
075                                                           HttpClient.class);
076            component.setHttpClient(httpClient);
077            if (config.isDefined(CONFIG_OPTION_ARGS)) {
078                component.setArgs(config.get(CONFIG_OPTION_ARGS).asMap());
079            }
080            return component;
081        }
082
083        /**
084         * Creates the new heap object instance using the provided script.
085         *
086         * @param script The compiled script.
087         * @return The new heap object instance using the provided script.
088         * @throws HeapException if an exception occurred during creation of the heap
089         * object or any of its dependencies.
090         * @throws JsonValueException if the heaplet (or one of its dependencies) has a
091         * malformed configuration.
092         */
093        protected abstract AbstractScriptableHeapObject newInstance(final Script script)
094                throws HeapException;
095
096        private final Script compileScript() throws HeapException {
097            final Environment environment = heap.get(ENVIRONMENT_HEAP_KEY, Environment.class);
098
099            if (!config.isDefined(CONFIG_OPTION_TYPE)) {
100                throw new JsonValueException(config, "The configuration option '"
101                        + CONFIG_OPTION_TYPE
102                        + "' is required and must specify the script mime-type");
103            }
104            final String mimeType = config.get(CONFIG_OPTION_TYPE).asString();
105            if (config.isDefined(CONFIG_OPTION_SOURCE)) {
106                if (config.isDefined(CONFIG_OPTION_FILE)) {
107                    throw new JsonValueException(config, "Both configuration options '"
108                            + CONFIG_OPTION_SOURCE + "' and '" + CONFIG_OPTION_FILE
109                            + "' were specified, when at most one is allowed");
110                }
111                final String source = config.get(CONFIG_OPTION_SOURCE).asString();
112                try {
113                    return Script.fromSource(environment, mimeType, source);
114                } catch (final ScriptException e) {
115                    throw new JsonValueException(config,
116                            "Unable to compile the script defined in '" + CONFIG_OPTION_SOURCE
117                                    + "'", e
118                    );
119                }
120            } else if (config.isDefined(CONFIG_OPTION_FILE)) {
121                final String script = config.get(CONFIG_OPTION_FILE).asString();
122                try {
123                    return Script.fromFile(environment, mimeType, script);
124                } catch (final ScriptException e) {
125                    throw new JsonValueException(config, "Unable to compile the script in file '"
126                            + script + "'", e);
127                }
128            } else {
129                throw new JsonValueException(config, "Neither of the configuration options '"
130                        + CONFIG_OPTION_SOURCE + "' and '" + CONFIG_OPTION_FILE
131                        + "' were specified");
132            }
133        }
134
135    }
136
137    // TODO: add support for periodically refreshing the Groovy script file.
138    // TODO: json/xml/sql/crest bindings.
139
140    private final Script compiledScript;
141    private HttpClient httpClient;
142    private final LdapClient ldapClient = LdapClient.getInstance();
143    private final Map<String, Object> scriptGlobals = new ConcurrentHashMap<String, Object>();
144    private Map<String, Object> args;
145
146    /**
147     * Creates a new scriptable heap object using the provided compiled script.
148     *
149     * @param compiledScript The compiled script.
150     */
151    protected AbstractScriptableHeapObject(final Script compiledScript) {
152        this.compiledScript = compiledScript;
153    }
154
155    /**
156     * Sets the HTTP client which should be made available to scripts.
157     *
158     * @param client The HTTP client which should be made available to scripts.
159     */
160    public void setHttpClient(final HttpClient client) {
161        this.httpClient = client;
162    }
163
164    /**
165     * Sets the parameters which should be made available to scripts.
166     *
167     * @param args The parameters which should be made available to scripts.
168     */
169    public void setArgs(final Map<String, Object> args) {
170        this.args = args;
171    }
172
173    /**
174     * Runs the compiled script using the provided exchange and optional
175     * forwarding handler.
176     *
177     * @param exchange The HTTP exchange.
178     * @param next The next handler in the chain if applicable, may be
179     * {@code null}.
180     * @throws HandlerException If an error occurred while evaluating the script.
181     * @throws IOException If an I/O exception occurs.
182     */
183    protected final void runScript(final Exchange exchange, final Handler next)
184            throws HandlerException, IOException {
185
186        try {
187            compiledScript.run(createBindings(exchange, next));
188        } catch (final ScriptException e) {
189            if (e.getCause() instanceof HandlerException) {
190                /*
191                 * This may result from invoking the next handler (for filters),
192                 * or it may have been generated intentionally by the script.
193                 * Either way, just pass it back up the chain.
194                 */
195                throw (HandlerException) e.getCause();
196            }
197
198            /*
199             * The exception was unintentional: we could throw the cause or the
200             * script exception. Let's throw the script exception because it may
201             * contain useful line number information.
202             */
203            throw new HandlerException("Script failed unexpectedly", e);
204        }
205    }
206
207    private Map<String, Object> createBindings(final Exchange exchange, final Handler next) {
208        // Set engine bindings.
209        final Map<String, Object> bindings = new HashMap<String, Object>();
210        bindings.put("exchange", exchange);
211        bindings.put("logger", logger);
212        bindings.put("globals", scriptGlobals);
213        if (httpClient != null) {
214            bindings.put("http", httpClient);
215        }
216        bindings.put("ldap", ldapClient);
217        if (next != null) {
218            bindings.put("next", next);
219        }
220        if (args != null) {
221            for (final Entry<String, Object> entry : args.entrySet()) {
222                bindings.put(entry.getKey(), entry.getValue());
223            }
224        }
225
226        // Redirect streams? E.g. in = request entity, out = response entity?
227        return bindings;
228    }
229}