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-2015 ForgeRock AS.
015 */
016package org.forgerock.openig.script;
017
018import static org.forgerock.http.protocol.Response.newResponsePromise;
019import static org.forgerock.openig.el.Bindings.bindings;
020import static org.forgerock.openig.el.Expressions.evaluate;
021import static org.forgerock.openig.heap.Keys.CLIENT_HANDLER_HEAP_KEY;
022import static org.forgerock.openig.heap.Keys.ENVIRONMENT_HEAP_KEY;
023import static org.forgerock.openig.http.Responses.newInternalServerError;
024import static org.forgerock.openig.util.JsonValues.evaluate;
025
026import java.util.HashMap;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.concurrent.ConcurrentHashMap;
030
031import javax.script.ScriptException;
032
033import org.forgerock.http.Client;
034import org.forgerock.http.Handler;
035import org.forgerock.http.protocol.Request;
036import org.forgerock.http.protocol.Response;
037import org.forgerock.json.JsonValueException;
038import org.forgerock.openig.config.Environment;
039import org.forgerock.openig.el.Bindings;
040import org.forgerock.openig.el.ExpressionException;
041import org.forgerock.openig.heap.GenericHeapObject;
042import org.forgerock.openig.heap.GenericHeaplet;
043import org.forgerock.openig.heap.Heap;
044import org.forgerock.openig.heap.HeapException;
045import org.forgerock.openig.ldap.LdapClient;
046import org.forgerock.services.context.Context;
047import org.forgerock.util.promise.NeverThrowsException;
048import org.forgerock.util.promise.Promise;
049
050/**
051 * An abstract scriptable heap object which should be used as the base class for
052 * implementing {@link org.forgerock.http.Filter filters} and {@link Handler handlers}. This heap
053 * object acts as a simple wrapper around the scripting engine. Scripts are
054 * provided with the following variable bindings:
055 * <ul>
056 * <li>{@link Map globals} - the Map of global variables which persist across
057 * successive invocations of the script
058 * <li>{@link org.forgerock.services.context.Context context} - the associated request context
059 * <li>{@link Map contexts} - the visible contexts, keyed by context's name
060 * <li>{@link Request request} - the HTTP request
061 * <li>{@link Client http} - an HTTP client which may be used for performing outbound HTTP requests
062 * <li>{@link LdapClient ldap} - an OpenIG LDAP client which may be used for
063 * performing LDAP requests such as LDAP authentication
064 * <li>{@link org.forgerock.openig.log.Logger logger} - the OpenIG logger
065 * <li>{@link Handler next} - if the heap object is a filter then this variable
066 * will contain the next handler in the filter chain.
067 * <li>{@link Heap heap} - the heap.
068 * </ul>
069 * <p>
070 * <b>NOTE:</b> at the moment only Groovy is supported.
071 * <p><b>NOTE:</b> As of OpenIG 4.0, {@code exchange.request} and {@code exchange.response} are not set anymore.
072 */
073public abstract class AbstractScriptableHeapObject extends GenericHeapObject {
074
075    /** Creates and initializes a capture filter in a heap environment. */
076    protected abstract static class AbstractScriptableHeaplet extends GenericHeaplet {
077        private static final String CONFIG_OPTION_FILE = "file";
078        private static final String CONFIG_OPTION_SOURCE = "source";
079        private static final String CONFIG_OPTION_TYPE = "type";
080        private static final String CONFIG_OPTION_ARGS = "args";
081
082        @Override
083        public Object create() throws HeapException {
084            final Script script = compileScript();
085            final AbstractScriptableHeapObject component = newInstance(script, heap);
086            Handler clientHandler = heap.resolve(config.get("clientHandler").defaultTo(CLIENT_HANDLER_HEAP_KEY),
087                                                 Handler.class);
088            component.setClientHandler(clientHandler);
089            if (config.isDefined(CONFIG_OPTION_ARGS)) {
090                component.setArgs(config.get(CONFIG_OPTION_ARGS).asMap());
091            }
092            return component;
093        }
094
095        /**
096         * Creates the new heap object instance using the provided script.
097         *
098         * @param script The compiled script.
099         * @param heap The heap to look for bindings
100         * @return The new heap object instance using the provided script.
101         * @throws HeapException if an exception occurred during creation of the heap
102         * object or any of its dependencies.
103         * @throws JsonValueException if the heaplet (or one of its dependencies) has a
104         * malformed configuration.
105         */
106        protected abstract AbstractScriptableHeapObject newInstance(final Script script, final Heap heap)
107                throws HeapException;
108
109        private Script compileScript() throws HeapException {
110            final Environment environment = heap.get(ENVIRONMENT_HEAP_KEY, Environment.class);
111
112            if (!config.isDefined(CONFIG_OPTION_TYPE)) {
113                throw new JsonValueException(config, "The configuration option '"
114                        + CONFIG_OPTION_TYPE
115                        + "' is required and must specify the script mime-type");
116            }
117            final String mimeType = config.get(CONFIG_OPTION_TYPE).asString();
118            if (config.isDefined(CONFIG_OPTION_SOURCE)) {
119                if (config.isDefined(CONFIG_OPTION_FILE)) {
120                    throw new JsonValueException(config, "Both configuration options '"
121                            + CONFIG_OPTION_SOURCE + "' and '" + CONFIG_OPTION_FILE
122                            + "' were specified, when at most one is allowed");
123                }
124                final String source = config.get(CONFIG_OPTION_SOURCE).asString();
125                try {
126                    return Script.fromSource(environment, mimeType, source);
127                } catch (final ScriptException e) {
128                    throw new JsonValueException(config,
129                            "Unable to compile the script defined in '" + CONFIG_OPTION_SOURCE
130                                    + "'", e
131                    );
132                }
133            } else if (config.isDefined(CONFIG_OPTION_FILE)) {
134                final String script = evaluate(config.get(CONFIG_OPTION_FILE));
135                try {
136                    return Script.fromFile(environment, mimeType, script);
137                } catch (final ScriptException e) {
138                    throw new JsonValueException(config, "Unable to compile the script in file '"
139                            + script + "'", e);
140                }
141            } else {
142                throw new JsonValueException(config, "Neither of the configuration options '"
143                        + CONFIG_OPTION_SOURCE + "' and '" + CONFIG_OPTION_FILE
144                        + "' were specified");
145            }
146        }
147
148    }
149
150    // TODO: add support for periodically refreshing the Groovy script file.
151    // TODO: json/xml/sql/crest bindings.
152
153    private final Script compiledScript;
154    private final Heap heap;
155    private Handler clientHandler;
156    private final LdapClient ldapClient = LdapClient.getInstance();
157    private final Map<String, Object> scriptGlobals = new ConcurrentHashMap<>();
158    private Map<String, Object> args;
159
160    /**
161     * Creates a new scriptable heap object using the provided compiled script.
162     *
163     * @param compiledScript The compiled script.
164     * @param heap The heap to look for bindings
165     */
166    protected AbstractScriptableHeapObject(final Script compiledScript, Heap heap) {
167        this.compiledScript = compiledScript;
168        this.heap = heap;
169    }
170
171    /**
172     * Sets the HTTP client handler which should be made available to scripts.
173     *
174     * @param clientHandler The HTTP client handler which should be made available to scripts.
175     */
176    public void setClientHandler(final Handler clientHandler) {
177        this.clientHandler = clientHandler;
178    }
179
180    /**
181     * Sets the parameters which should be made available to scripts.
182     *
183     * @param args The parameters which should be made available to scripts.
184     */
185    public void setArgs(final Map<String, Object> args) {
186        this.args = args;
187    }
188
189    /**
190     * Runs the compiled script using the provided bindings and optional
191     * forwarding handler.
192     *
193     * @param bindings Base bindings available to the script (will be enriched).
194     * @param next The next handler in the chain if applicable, may be
195     * {@code null}.
196     * @param context request processing context
197     * @return the Promise of a Response produced by the script
198     */
199    @SuppressWarnings("unchecked")
200    protected final Promise<Response, NeverThrowsException> runScript(final Bindings bindings,
201                                                                      final Handler next,
202                                                                      final Context context) {
203        try {
204            Object o = compiledScript.run(enrichBindings(bindings, next, context));
205            if (o instanceof Promise) {
206                return ((Promise<Response, NeverThrowsException>) o);
207            } else if (o instanceof Response) {
208                // Allow to return a response directly from script
209                return newResponsePromise((Response) o);
210            } else {
211                logger.error("Script did not return a Response or a Promise<Response, NeverThrowsException>");
212                return newResponsePromise(newInternalServerError());
213            }
214        } catch (final ScriptException e) {
215            logger.warning("Cannot execute script");
216            logger.warning(e);
217            return newResponsePromise(newInternalServerError().setCause(e));
218        }
219    }
220
221    private Map<String, Object> enrichBindings(final Bindings source, final Handler next, final Context context)
222            throws ScriptException {
223        // Set engine bindings.
224        final Map<String, Object> bindings = new HashMap<>();
225        bindings.putAll(source.asMap());
226        bindings.put("logger", logger);
227        bindings.put("globals", scriptGlobals);
228        if (clientHandler != null) {
229            bindings.put("http", new Client(clientHandler, context));
230        }
231        bindings.put("ldap", ldapClient);
232        if (next != null) {
233            bindings.put("next", next);
234        }
235        if (args != null) {
236            try {
237                final Bindings exprEvalBindings = bindings().bind(source).bind("heap", heap);
238                for (final Entry<String, Object> entry : args.entrySet()) {
239                    checkBindingNotAlreadyUsed(bindings, entry.getKey());
240                    bindings.put(entry.getKey(), evaluate(entry.getValue(), exprEvalBindings));
241                }
242            } catch (ExpressionException ex) {
243                throw new ScriptException(ex);
244            }
245        }
246
247        // Redirect streams? E.g. in = request entity, out = response entity?
248        return bindings;
249    }
250
251    private void checkBindingNotAlreadyUsed(final Map<String, Object> bindings,
252                                            final String key) throws ScriptException {
253        if (bindings.containsKey(key)) {
254            final String errorMsg = "Can't override the binding named " + key;
255            logger.error(errorMsg);
256            throw new ScriptException(errorMsg);
257        }
258    }
259}