You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by mo...@apache.org on 2020/09/04 06:09:45 UTC

[zeppelin] branch master updated: [ZEPPELIN-5019] Enable configurable HTML addons

This is an automated email from the ASF dual-hosted git repository.

moon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new cb06507  [ZEPPELIN-5019] Enable configurable HTML addons
cb06507 is described below

commit cb06507f6323a9bf7a338ad0ffccc1adb9464c1f
Author: Andreas Weise <a....@avm.de>
AuthorDate: Thu Aug 27 17:05:47 2020 +0200

    [ZEPPELIN-5019] Enable configurable HTML addons
    
    ### What is this PR for?
    Currently its hard to integrate certain plotting libraries in one single zeppelin notebook efficiently. efficiently here means in the sense of how often library code needs to be embedded into a single notebook. while few plotting libraries provide APIs to properly enable lazy loading of their JS library (e.g. bokeh), others do not (e.g. plotly), which results in large overhead in notebooks with couple of plots, where each plot embeds the library code again and again. attempts to only  [...]
    
    A general solution for that problem is to enable the loading of 3rd party JS libraries within the root HTML.
    
    Hence this issue will introduce a new optional zeppelin configuration that allows administrators to add global addons to the zeppelin root HTML, so analysts can rely on that the JS library is already loaded for every paragraph.
    
    As there exist two places in HTML where initial loading may be placed, the issue will introduce the following two configuration properties:
    
    - `zeppelin.server.html.head.addon` > addon html code to be placed at the end of the html->head section 
    - `zeppelin.server.html.body.addon` > addon html code to be placed at the end of the html->body section
    
    Each property may contain any valid HTML code, e.g.
    
    ```
    <script defer src="https://url/to/my/lib.min.js" /><script defer src="https://url/to/other/lib.min.js" />
    ```
    
    ### What type of PR is it?
    Improvement
    
    ### What is the Jira issue?
    https://issues.apache.org/jira/browse/ZEPPELIN-5019
    
    ### How should this be tested?
    * Unit Test added for index.html of `zeppelin-web`
    * can also be manually tested by adding config properties mentioned above. Then check the resulting source of the zeppelin web application (old and new ui)
    
    ### Screenshots (if appropriate)
    
    ### Questions:
    * Does the licenses files need update? no
    * Is there breaking changes for older versions? no
    * Does this needs documentation? yes
    
    Author: Andreas Weise <a....@avm.de>
    
    Closes #3892 from weand/ZEPPELIN-5019 and squashes the following commits:
    
    29e7a0222 [Andreas Weise] ZEPPELIN-5019 Enable configurable HTML addons
---
 conf/zeppelin-site.xml.template                    |  15 ++
 .../zeppelin/conf/ZeppelinConfiguration.java       |  11 ++
 .../apache/zeppelin/server/HtmlAddonResource.java  | 188 +++++++++++++++++++++
 .../org/apache/zeppelin/server/ZeppelinServer.java |  44 ++++-
 .../zeppelin/server/HtmlAddonResourceTest.java     |  79 +++++++++
 5 files changed, 336 insertions(+), 1 deletion(-)

diff --git a/conf/zeppelin-site.xml.template b/conf/zeppelin-site.xml.template
index 2552058..b6b162c 100755
--- a/conf/zeppelin-site.xml.template
+++ b/conf/zeppelin-site.xml.template
@@ -660,6 +660,21 @@
 
 <!--
 <property>
+  <name>zeppelin.server.html.body.addon</name>
+  <value><![CDATA[<script defer src="https://url/to/my/lib.min.js" /><script defer src="https://url/to/other/lib.min.js" />]]></value>
+  <description>Addon html code to be placed at the end of the html->body section in index.html delivered by zeppelin server.</description>
+</property>
+
+<property>
+  <name>zeppelin.server.html.head.addon</name>
+  <value></value>
+  <description>Addon html code to be placed at the end of the html->head section in index.html delivered by zeppelin server.</description>
+</property>
+-->
+
+
+<!--
+<property>
   <name>zeppelin.interpreter.callback.portRange</name>
   <value>10000:10010</value>
 </property>
diff --git a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
index 3dc65fe..c4a249f 100644
--- a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
+++ b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
@@ -717,6 +717,14 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     return getString(ConfVars.ZEPPELIN_SERVER_STRICT_TRANSPORT);
   }
 
+  public String getHtmlHeadAddon() {
+    return getString(ConfVars.ZEPPELIN_SERVER_HTML_HEAD_ADDON);
+  }
+
+  public String getHtmlBodyAddon() {
+    return getString(ConfVars.ZEPPELIN_SERVER_HTML_BODY_ADDON);
+  }
+
   public String getLifecycleManagerClass() {
     return getString(ConfVars.ZEPPELIN_INTERPRETER_LIFECYCLE_MANAGER_CLASS);
   }
@@ -1005,6 +1013,9 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     ZEPPELIN_SERVER_X_XSS_PROTECTION("zeppelin.server.xxss.protection", "1; mode=block"),
     ZEPPELIN_SERVER_X_CONTENT_TYPE_OPTIONS("zeppelin.server.xcontent.type.options", "nosniff"),
 
+    ZEPPELIN_SERVER_HTML_HEAD_ADDON("zeppelin.server.html.head.addon", null),
+    ZEPPELIN_SERVER_HTML_BODY_ADDON("zeppelin.server.html.body.addon", null),
+
     ZEPPELIN_SERVER_KERBEROS_KEYTAB("zeppelin.server.kerberos.keytab", ""),
     ZEPPELIN_SERVER_KERBEROS_PRINCIPAL("zeppelin.server.kerberos.principal", ""),
 
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/server/HtmlAddonResource.java b/zeppelin-server/src/main/java/org/apache/zeppelin/server/HtmlAddonResource.java
new file mode 100644
index 0000000..ec61945
--- /dev/null
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/server/HtmlAddonResource.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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 org.apache.zeppelin.server;
+
+import static com.google.common.base.Charsets.UTF_8;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.eclipse.jetty.util.resource.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+import com.google.common.io.CharStreams;
+import com.google.common.io.Files;
+
+/**
+ * Resource for enabling html addons in index.html.
+ */
+public class HtmlAddonResource extends Resource {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(HtmlAddonResource.class);
+
+    private static final String TAG_BODY_OPENING = "<body"; // ignore bracket here to support potential html attributes
+    private static final String TAG_BODY_CLOSING = "</body>";
+    private static final String TAG_HEAD_CLOSING = "</head>";
+    private static final String TAG_HTML_CLOSING = "</html>";
+
+    public static final String HTML_ADDON_IDENTIFIER = "zeppelin-index-with-addon";
+    public static final String INDEX_HTML_PATH = "/index.html";
+
+    private final Resource indexResource;
+    private File alteredTempFile = null;
+    private byte[] alteredContent;
+
+    public HtmlAddonResource(final Resource indexResource, final String bodyAddon, final String headAddon) {
+        LOGGER.info("Enabling html addons in " + indexResource + ": body='{}' head='{}'", bodyAddon, headAddon);
+        this.indexResource = indexResource;
+        try {
+            // read original content from resource
+            String content;
+            try (final Reader reader = new InputStreamReader(indexResource.getInputStream())) {
+                content = CharStreams.toString(reader);
+            }
+
+            // process body addon
+            if (bodyAddon != null) {
+                if (content.contains(TAG_BODY_CLOSING)) {
+                    content = content.replace(TAG_BODY_CLOSING, bodyAddon + TAG_BODY_CLOSING);
+                } else if (content.contains(TAG_HTML_CLOSING)) {
+                    content = content.replace(TAG_HTML_CLOSING, bodyAddon + TAG_HTML_CLOSING);
+                } else {
+                    content = content + bodyAddon;
+                }
+            }
+
+            // process head addon
+            if (headAddon != null) {
+                if (content.contains(TAG_HEAD_CLOSING)) {
+                    content = content.replace(TAG_HEAD_CLOSING, headAddon + TAG_HEAD_CLOSING);
+                } else if (content.contains(TAG_BODY_OPENING)) {
+                    content = content.replace(TAG_BODY_OPENING, headAddon + TAG_BODY_OPENING);
+                } else {
+                    LOGGER.error("Unable to process Head html addon. Could not find proper anchor in index.html.");
+                }
+            }
+
+            this.alteredContent = content.getBytes(UTF_8);
+
+            // only relevant in development mode: create altered temp file (as zeppelin web archives are addressed via local
+            // filesystem folders)
+            if (indexResource.getFile() != null) {
+                this.alteredTempFile = File.createTempFile(HTML_ADDON_IDENTIFIER, ".html");
+                this.alteredTempFile.deleteOnExit();
+                Files.write(this.alteredContent, this.alteredTempFile);
+            }
+
+        } catch (IOException e) {
+            LOGGER.error("Error initializing html addons.", e);
+        }
+
+    }
+
+    @Override
+    public File getFile() throws IOException {
+        return this.alteredTempFile;
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        return new ByteArrayInputStream(this.alteredContent);
+    }
+
+    @Override
+    public String getName() {
+        return indexResource.getName();
+    }
+
+    @Override
+    public boolean isContainedIn(Resource r) throws MalformedURLException {
+        return indexResource.isContainedIn(r);
+    }
+
+    @Override
+    public boolean exists() {
+        return indexResource.exists();
+    }
+
+    @Override
+    public boolean isDirectory() {
+        return indexResource.isDirectory();
+    }
+
+    @Override
+    public long lastModified() {
+        return indexResource.lastModified();
+    }
+
+    @Override
+    public long length() {
+        return alteredContent.length;
+    }
+
+    @Override
+    public URL getURL() {
+        return indexResource.getURL();
+    }
+
+    @Override
+    public ReadableByteChannel getReadableByteChannel() throws IOException {
+        return Channels.newChannel(new ByteArrayInputStream(this.alteredContent));
+    }
+
+    @Override
+    public boolean delete() throws SecurityException {
+        throw new UnsupportedOperationException("Not supported");
+    }
+
+    @Override
+    public boolean renameTo(Resource dest) throws SecurityException {
+        throw new UnsupportedOperationException("Not supported");
+    }
+
+    @Override
+    public String[] list() {
+        throw new UnsupportedOperationException("Not supported");
+    }
+
+    @Override
+    public Resource addPath(String path) throws IOException, MalformedURLException {
+        throw new UnsupportedOperationException("Not supported");
+    }
+
+    @Override
+    public void close() {
+    }
+
+}
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
index f1224c1..296abd5 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
@@ -17,6 +17,9 @@
 package org.apache.zeppelin.server;
 
 import com.google.gson.Gson;
+
+import static org.apache.zeppelin.server.HtmlAddonResource.HTML_ADDON_IDENTIFIER;
+
 import java.io.File;
 import java.io.IOException;
 import java.lang.management.ManagementFactory;
@@ -88,6 +91,7 @@ import org.eclipse.jetty.server.session.SessionHandler;
 import org.eclipse.jetty.servlet.DefaultServlet;
 import org.eclipse.jetty.servlet.FilterHolder;
 import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.resource.Resource;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
@@ -550,7 +554,7 @@ public class ZeppelinServer extends ResourceConfig {
       webApp.setTempDirectory(warTempDirectory);
     }
     // Explicit bind to root
-    webApp.addServlet(new ServletHolder(new DefaultServlet()), "/*");
+    webApp.addServlet(new ServletHolder(setupServlet(webApp, conf)), "/*");
     contexts.addHandler(webApp);
 
     webApp.addFilter(new FilterHolder(CorsFilter.class), "/*", EnumSet.allOf(DispatcherType.class));
@@ -561,6 +565,44 @@ public class ZeppelinServer extends ResourceConfig {
     return webApp;
   }
 
+  private static DefaultServlet setupServlet(
+      WebAppContext webApp,
+      ZeppelinConfiguration conf) {
+
+    // provide DefaultServlet as is in case html addon is not used
+    if (conf.getHtmlBodyAddon()==null && conf.getHtmlHeadAddon()==null) {
+      return new DefaultServlet();
+    }
+
+    // override ResourceFactory interface part of DefaultServlet for intercepting the static index.html properly.
+    return new DefaultServlet() {
+
+        private static final long serialVersionUID = 1L;
+        
+        @Override
+        public Resource getResource(String pathInContext) {
+
+            // proceed for everything but '/index.html'
+            if (!HtmlAddonResource.INDEX_HTML_PATH.equals(pathInContext)) {
+                return super.getResource(pathInContext);
+            }
+
+            // create the altered 'index.html' resource and cache it via webapp attributes
+            if (webApp.getAttribute(HTML_ADDON_IDENTIFIER) == null) {
+                webApp.setAttribute(
+                    HTML_ADDON_IDENTIFIER, 
+                    new HtmlAddonResource(
+                        super.getResource(pathInContext), 
+                        conf.getHtmlBodyAddon(),
+                        conf.getHtmlHeadAddon()));
+            }
+
+            return (Resource) webApp.getAttribute(HTML_ADDON_IDENTIFIER);
+        }
+
+    };
+  }
+
   private static void initWebApp(WebAppContext webApp) {
     webApp.addEventListener(
             new ServletContextListener() {
diff --git a/zeppelin-server/src/test/java/org/apache/zeppelin/server/HtmlAddonResourceTest.java b/zeppelin-server/src/test/java/org/apache/zeppelin/server/HtmlAddonResourceTest.java
new file mode 100644
index 0000000..c69c7cb
--- /dev/null
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/server/HtmlAddonResourceTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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 org.apache.zeppelin.server;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.Assert.assertThat;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+
+import org.eclipse.jetty.util.resource.Resource;
+import org.junit.Ignore;
+import org.junit.Test;
+
+import com.google.common.io.CharStreams;
+
+public class HtmlAddonResourceTest {
+
+    private final static String TEST_BODY_ADDON = "<!-- foo -->";
+    private final static String TEST_HEAD_ADDON = "<!-- bar -->";
+
+    private final static String FILE_PATH_INDEX_HTML_ZEPPELIN_WEB = "../zeppelin-web/dist/index.html";
+    private final static String FILE_PATH_INDEX_HTML_ZEPPELIN_WEB_ANGULAR = "../zeppelin-web-angular/dist/zeppelin/index.html";
+
+    @Test
+    public void testZeppelinWebHtmlAddon() throws IOException {
+        final Resource addonResource = getHtmlAddonResource(FILE_PATH_INDEX_HTML_ZEPPELIN_WEB);
+
+        final String content;
+        try (final Reader reader = new InputStreamReader(addonResource.getInputStream())) {
+            content = CharStreams.toString(reader);
+        }
+
+        assertThat(content, containsString(TEST_BODY_ADDON));
+        assertThat(content, containsString(TEST_HEAD_ADDON));
+
+    }
+
+    @Test
+    @Ignore // ignored due to zeppelin-web-angular not build for core tests
+    public void testZeppelinWebAngularHtmlAddon() throws IOException {
+        final Resource addonResource = getHtmlAddonResource(FILE_PATH_INDEX_HTML_ZEPPELIN_WEB_ANGULAR);
+
+        final String content;
+        try (final Reader reader = new InputStreamReader(addonResource.getInputStream())) {
+            content = CharStreams.toString(reader);
+        }
+
+        assertThat(content, containsString(TEST_BODY_ADDON));
+        assertThat(content, containsString(TEST_HEAD_ADDON));
+
+    }
+
+    private Resource getHtmlAddonResource(final String indexHtmlPath) {
+        return getHtmlAddonResource(indexHtmlPath, TEST_BODY_ADDON, TEST_HEAD_ADDON);
+    }
+
+    private Resource getHtmlAddonResource(final String indexHtmlPath, final String bodyAddon, final String headAddon) {
+        final Resource indexResource = Resource.newResource(new File(indexHtmlPath));
+        return new HtmlAddonResource(indexResource, TEST_BODY_ADDON, TEST_HEAD_ADDON);
+    }
+
+}