JRubyPlugin.java

271 lines | 10.266 kB Blame History Raw Download
/*
 * Copyright 2010-2013 Ning, Inc.
 *
 * Ning licenses this file to you under the Apache License, version 2.0
 * (the "License"); you may not use this file except in compliance with the
 * License.  You may obtain a copy of the License at:
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

package com.ning.billing.osgi.bundles.jruby;

import java.util.Arrays;
import java.util.Hashtable;
import java.util.Map;

import javax.servlet.http.HttpServlet;

import org.jruby.Ruby;
import org.jruby.RubyObject;
import org.jruby.embed.EvalFailedException;
import org.jruby.embed.ScriptingContainer;
import org.jruby.runtime.builtin.IRubyObject;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.log.LogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.ning.billing.osgi.api.config.PluginRubyConfig;
import com.ning.billing.payment.plugin.api.PaymentPluginApiException;

// Bridge between the OSGI bundle and the ruby plugin
public abstract class JRubyPlugin {

    private final static Logger log = LoggerFactory.getLogger(JRubyPlugin.class);

    // Killbill gem base classes
    private static final String KILLBILL_PLUGIN_BASE = "Killbill::Plugin::PluginBase";
    private static final String KILLBILL_PLUGIN_NOTIFICATION = "Killbill::Plugin::Notification";
    private static final String KILLBILL_PLUGIN_PAYMENT = "Killbill::Plugin::Payment";

    // Magic ruby variables
    private static final String KILLBILL_SERVICES = "java_apis";
    private static final String KILLBILL_PLUGIN_CLASS_NAME = "plugin_class_name";

    protected final LogService logger;
    protected final BundleContext bundleContext;
    protected final String pluginGemName;
    protected final String rubyRequire;
    protected final String pluginMainClass;
    protected final ScriptingContainer container;
    protected final String pluginLibdir;

    protected RubyObject pluginInstance;

    private ServiceRegistration httpServletServiceRegistration = null;
    private String cachedRequireLine = null;

    public JRubyPlugin(final PluginRubyConfig config, final ScriptingContainer container,
                       final BundleContext bundleContext, final LogService logger) {
        this.logger = logger;
        this.bundleContext = bundleContext;
        this.pluginGemName = config.getPluginName();
        this.rubyRequire = config.getRubyRequire();
        this.pluginMainClass = config.getRubyMainClass();
        this.container = container;
        this.pluginLibdir = config.getRubyLoadDir();

        // Path to the gem
        if (pluginLibdir != null) {
            container.setLoadPaths(Arrays.asList(pluginLibdir));
        }
    }

    public String getPluginMainClass() {
        return pluginMainClass;
    }

    public String getPluginLibdir() {
        return pluginLibdir;
    }

    public void instantiatePlugin(final Map<String, Object> killbillApis, final String pluginMain) {
        checkValidPlugin();

        // Register all killbill APIs
        container.put(KILLBILL_SERVICES, killbillApis);
        container.put(KILLBILL_PLUGIN_CLASS_NAME, pluginMainClass);

        // Note that the KILLBILL_SERVICES variable will be available once only!
        // Don't put any code here!

        // Start the plugin
        pluginInstance = (RubyObject) container.runScriptlet(pluginMain + ".new(" + KILLBILL_PLUGIN_CLASS_NAME + "," + KILLBILL_SERVICES + ")");
    }

    public void startPlugin(final BundleContext context) {
        checkPluginIsStopped();
        pluginInstance.callMethod("start_plugin");
        checkPluginIsRunning();
        registerHttpServlet();
    }

    public void stopPlugin(final BundleContext context) {
        checkPluginIsRunning();
        unregisterHttpServlet();
        pluginInstance.callMethod("stop_plugin");
        checkPluginIsStopped();
    }

    private void registerHttpServlet() {
        // Register the rack handler
        final IRubyObject rackHandler = pluginInstance.callMethod("rack_handler");
        if (!rackHandler.isNil()) {
            logger.log(LogService.LOG_INFO, String.format("Using %s as rack handler", rackHandler.getMetaClass()));

            final JRubyHttpServlet jRubyHttpServlet = new JRubyHttpServlet(rackHandler);
            final Hashtable<String, String> properties = new Hashtable<String, String>();
            properties.put("killbill.pluginName", pluginGemName);
            httpServletServiceRegistration = bundleContext.registerService(HttpServlet.class.getName(), jRubyHttpServlet, properties);
        }
    }

    private void unregisterHttpServlet() {
        if (httpServletServiceRegistration != null) {
            httpServletServiceRegistration.unregister();
        }
    }

    protected void checkPluginIsRunning() {
        if (pluginInstance == null || !(Boolean) pluginInstance.callMethod("is_active").toJava(Boolean.class)) {
            throw new IllegalStateException(String.format("Plugin %s didn't start properly", pluginMainClass));
        }
    }

    protected void checkPluginIsStopped() {
        if (pluginInstance == null || (Boolean) pluginInstance.callMethod("is_active").toJava(Boolean.class)) {
            throw new IllegalStateException(String.format("Plugin %s didn't stop properly", pluginMainClass));
        }
    }

    protected void checkValidPlugin() {
        try {
            container.runScriptlet(checkInstanceOfPlugin(KILLBILL_PLUGIN_BASE));
        } catch (EvalFailedException e) {
            throw new IllegalArgumentException(e);
        }
    }

    protected void checkValidNotificationPlugin() throws IllegalArgumentException {
        try {
            container.runScriptlet(checkInstanceOfPlugin(KILLBILL_PLUGIN_NOTIFICATION));
        } catch (EvalFailedException e) {
            throw new IllegalArgumentException(e);
        }
    }

    protected void checkValidPaymentPlugin() throws IllegalArgumentException {
        try {
            container.runScriptlet(checkInstanceOfPlugin(KILLBILL_PLUGIN_PAYMENT));
        } catch (EvalFailedException e) {
            throw new IllegalArgumentException(e);
        }
    }

    protected String checkInstanceOfPlugin(final String baseClass) {
        final StringBuilder builder = new StringBuilder(getRequireLine());
        builder.append("raise ArgumentError.new('Invalid plugin: ")
               .append(pluginMainClass)
               .append(", is not a ")
               .append(baseClass)
               .append("') unless ")
               .append(pluginMainClass)
               .append(" <= ")
               .append(baseClass);
        return builder.toString();
    }

    private String getRequireLine() {
        if (cachedRequireLine == null) {
            final StringBuilder builder = new StringBuilder();
            builder.append("ENV[\"GEM_HOME\"] = \"").append(pluginLibdir).append("\"").append("\n");
            builder.append("ENV[\"GEM_PATH\"] = ENV[\"GEM_HOME\"]\n");
            // Always require the Killbill gem
            builder.append("gem 'killbill'\n");
            builder.append("require 'killbill'\n");
            // Assume the plugin is shipped as a Gem
            builder.append("begin\n")
                   .append("gem '").append(pluginGemName).append("'\n")
                   .append("rescue Gem::LoadError\n")
                   .append("warn \"WARN: unable to load gem ").append(pluginGemName).append("\"\n")
                   .append("end\n");
            builder.append("begin\n")
                   .append("require '").append(pluginGemName).append("'\n")
                    .append("rescue LoadError\n")
                            // Could be useful for debugging
                            //.append("warn \"WARN: unable to require ").append(pluginGemName).append("\"\n")
                    .append("end\n");
            // Load the extra require file, if specified
            if (rubyRequire != null) {
                builder.append("begin\n")
                       .append("require '").append(rubyRequire).append("'\n")
                       .append("rescue LoadError\n")
                       .append("warn \"WARN: unable to require ").append(rubyRequire).append("\"\n")
                       .append("end\n");
            }
            // Require any file directly in the pluginLibdir directory (e.g. /var/tmp/bundles/ruby/foo/1.0/gems/*.rb).
            // Although it is likely that any Killbill plugin will be distributed as a gem, it is still useful to
            // be able to load individual scripts for prototyping/testing/...
            builder.append("Dir.glob(ENV[\"GEM_HOME\"] + \"/*.rb\").each {|x| require x rescue warn \"WARN: unable to load #{x}\"}\n");
            cachedRequireLine = builder.toString();
        }
        return cachedRequireLine;
    }

    protected Ruby getRuntime() {
        return pluginInstance.getMetaClass().getRuntime();
    }

    public enum VALIDATION_PLUGIN_TYPE {
        NOTIFICATION,
        PAYMENT,
        NONE
    }

    protected abstract class PluginCallback {

        private final VALIDATION_PLUGIN_TYPE pluginType;

        public PluginCallback(final VALIDATION_PLUGIN_TYPE pluginType) {
            this.pluginType = pluginType;
        }

        public abstract <T> T doCall(final Ruby runtime) throws PaymentPluginApiException;

        public VALIDATION_PLUGIN_TYPE getPluginType() {
            return pluginType;
        }
    }

    protected <T> T callWithRuntimeAndChecking(final PluginCallback cb) throws PaymentPluginApiException {
        try {
            checkPluginIsRunning();

            switch(cb.getPluginType()) {
                case NOTIFICATION:
                    checkValidNotificationPlugin();
                    break;
                case PAYMENT:
                    checkValidPaymentPlugin();
                    break;
                default:
                    break;
            }

            final Ruby runtime = getRuntime();
            return cb.doCall(runtime);
        } catch (RuntimeException e) {
            log.warn("RuntimeException in jruby plugin ", e);
            throw e;
        }
    }
}