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

tallison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git

commit 68a4272e4de38f6eb02a93e5bcce76b72dcc5eca
Author: tballison <[email protected]>
AuthorDate: Thu Sep 2 14:39:02 2021 -0400

    TIKA-3543 -- improve robustness and feature set of HttpFetcher
---
 CHANGES.txt                                        |   2 +
 .../tika/pipes/emitter/fs/FileSystemEmitter.java   |   7 +
 tika-pipes/tika-fetchers/tika-fetcher-http/pom.xml |   7 +
 .../tika/pipes/fetcher/http/HttpFetcher.java       | 257 +++++++++++++++++++--
 .../tika/pipes/fetcher/http/HttpFetcherTest.java   |  19 +-
 .../src/test/resources/tika-config-http.xml        |   5 +
 6 files changed, 279 insertions(+), 18 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 9adb889..37990f1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
 Release 2.1.1 - ???
 
+   * Improve robustness and features of the httpfetcher (TIKA-3543)
+   
    * Add optional fetch ranges to FetchEmitTuple to allow range fetching from,
         e.g. http or s3 (TIKA-3542).
 
diff --git 
a/tika-pipes/tika-emitters/tika-emitter-fs/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
 
b/tika-pipes/tika-emitters/tika-emitter-fs/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
index cf67509..47c5177 100644
--- 
a/tika-pipes/tika-emitters/tika-emitter-fs/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
+++ 
b/tika-pipes/tika-emitters/tika-emitter-fs/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
@@ -108,6 +108,13 @@ public class FileSystemEmitter extends AbstractEmitter 
implements StreamEmitter
         this.fileExtension = fileExtension;
     }
 
+    /**
+     * What to do if the target file already exists.  NOTE: if more than one
+     * thread is trying write to the same file and {@link ON_EXISTS#REPLACE} 
is chosen,
+     * you still might get a {@link FileAlreadyExistsException}.
+     *
+     * @param onExists
+     */
     @Field
     public void setOnExists(String onExists) {
         switch (onExists) {
diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/pom.xml 
b/tika-pipes/tika-fetchers/tika-fetcher-http/pom.xml
index 34af101..85d2e06 100644
--- a/tika-pipes/tika-fetchers/tika-fetcher-http/pom.xml
+++ b/tika-pipes/tika-fetchers/tika-fetcher-http/pom.xml
@@ -45,6 +45,13 @@
       <artifactId>tika-httpclient-commons</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>tika-core</artifactId>
+      <version>${project.version}</version>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/HttpFetcher.java
 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/HttpFetcher.java
index 55d9f18..ff0ab36 100644
--- 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/HttpFetcher.java
+++ 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/HttpFetcher.java
@@ -17,16 +17,37 @@
 package org.apache.tika.pipes.fetcher.http;
 
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.http.Header;
+import org.apache.http.HttpConnection;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpInetConnection;
 import org.apache.http.HttpResponse;
 import org.apache.http.client.HttpClient;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.util.EntityUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -37,8 +58,11 @@ import org.apache.tika.config.InitializableProblemHandler;
 import org.apache.tika.config.Param;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.exception.TikaTimeoutException;
+import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
 import org.apache.tika.pipes.fetcher.AbstractFetcher;
 import org.apache.tika.pipes.fetcher.RangeFetcher;
 
@@ -47,10 +71,57 @@ import org.apache.tika.pipes.fetcher.RangeFetcher;
  */
 public class HttpFetcher extends AbstractFetcher implements Initializable, 
RangeFetcher {
 
+    public static String HTTP_HEADER_PREFIX = "http-header:";
+
+    public static String HTTP_FETCH_PREFIX = "http-connection:";
+
+    /**
+     * http status code
+     */
+    public static Property HTTP_STATUS_CODE =
+            Property.externalInteger(HTTP_HEADER_PREFIX + "status-code");
+    /**
+     * Number of redirects
+     */
+    public static Property HTTP_NUM_REDIRECTS =
+            Property.externalInteger(HTTP_FETCH_PREFIX + "num-redirects");
+
+    /**
+     * If there were redirects, this captures the final URL visited
+     */
+    public static Property HTTP_TARGET_URL =
+            Property.externalText(HTTP_FETCH_PREFIX + "target-url");
+
+    public static Property HTTP_TARGET_IP_ADDRESS =
+            Property.externalText(HTTP_FETCH_PREFIX + "target-ip-address");
+
+    public static Property HTTP_FETCH_TRUNCATED =
+            Property.externalBoolean(HTTP_FETCH_PREFIX + "fetch-truncated");
+
+    public static Property HTTP_CONTENT_ENCODING =
+            Property.externalText(HTTP_HEADER_PREFIX + "content-encoding");
+
+    public static Property HTTP_CONTENT_TYPE =
+            Property.externalText(HTTP_HEADER_PREFIX + "content-type");
+
+
     Logger LOG = LoggerFactory.getLogger(HttpFetcher.class);
     private HttpClientFactory httpClientFactory;
     private HttpClient httpClient;
 
+    private int maxRedirects = 10;
+    //overall timeout in milliseconds
+    private long overallTimeout = -1;
+
+    private long maxSpoolSize = -1;
+
+    //max string length to read from a result if the
+    //status code was not in the 200 range
+    private int maxErrMsgSize = 10000;
+
+    //httpHeaders to capture in the metadata
+    private Set<String> httpHeaders = new HashSet<>();
+
     public HttpFetcher() throws TikaConfigException {
         httpClientFactory = new HttpClientFactory();
     }
@@ -58,7 +129,12 @@ public class HttpFetcher extends AbstractFetcher implements 
Initializable, Range
     @Override
     public InputStream fetch(String fetchKey, Metadata metadata) throws 
IOException, TikaException {
         HttpGet get = new HttpGet(fetchKey);
-        return get(get);
+        RequestConfig requestConfig =
+                RequestConfig.custom()
+                        .setMaxRedirects(maxRedirects)
+                        .setRedirectsEnabled(true).build();
+        get.setConfig(requestConfig);
+        return get(get, metadata);
     }
 
     @Override
@@ -66,36 +142,137 @@ public class HttpFetcher extends AbstractFetcher 
implements Initializable, Range
             throws IOException, TikaException {
         HttpGet get = new HttpGet(fetchKey);
         get.setHeader("Range", "bytes=" + startRange + "-" + endRange);
-        return get(get);
+        return get(get, metadata);
     }
 
-    private InputStream get(HttpGet get) throws IOException, TikaException {
-        HttpResponse response = httpClient.execute(get);
-        int code = response.getStatusLine().getStatusCode();
-        if (code < 200 || code > 299) {
-            throw new IOException("bad status code: " + code + " :: " +
-                    responseToString(response.getEntity().getContent()));
+    private InputStream get(HttpGet get, Metadata metadata) throws 
IOException, TikaException {
+        HttpClientContext context = HttpClientContext.create();
+        HttpResponse response = null;
+        final AtomicBoolean timeout = new AtomicBoolean(false);
+        Timer timer = null;
+        try {
+            if (overallTimeout > -1) {
+                TimerTask task = new TimerTask() {
+                    @Override
+                    public void run() {
+                        timeout.set(true);
+                        if (get != null) {
+                            get.abort();
+                        }
+
+                    }
+                };
+                timer = new Timer(false);
+                timer.schedule(task, overallTimeout);
+            }
+            response = httpClient.execute(get, context);
+
+            updateMetadata(get.getURI().toString(), response, context, 
metadata);
+
+            int code = response.getStatusLine().getStatusCode();
+            if (code < 200 || code > 299) {
+                throw new IOException("bad status code: " + code + " :: " +
+                        responseToString(response));
+            }
+            try (InputStream is = response.getEntity().getContent()) {
+                return spool(is, metadata);
+            }
+        } catch (IOException e) {
+            if (timeout.get() == true) {
+                throw new TikaTimeoutException("Overall timeout after " + 
overallTimeout + "ms");
+            } else {
+                throw e;
+            }
+        } finally {
+            if (timer != null) {
+                timer.cancel();
+                timer.purge();
+            }
+            if (response != null && response instanceof CloseableHttpResponse) 
{
+                ((CloseableHttpResponse) response).close();
+            }
         }
+    }
 
-        //spool to local
+    private InputStream spool(InputStream content, Metadata metadata) throws 
IOException {
         long start = System.currentTimeMillis();
-        TikaInputStream tis = 
TikaInputStream.get(response.getEntity().getContent());
-        tis.getPath();
-        if (response instanceof CloseableHttpResponse) {
-            ((CloseableHttpResponse) response).close();
+        TemporaryResources tmp = new TemporaryResources();
+        Path tmpFile = tmp.createTempFile();
+        if (maxSpoolSize < 0) {
+            Files.copy(content, tmpFile, StandardCopyOption.REPLACE_EXISTING);
+        } else {
+            try (OutputStream os = Files.newOutputStream(tmpFile)) {
+                long totalRead = IOUtils.copyLarge(content, os, 0, 
maxSpoolSize);
+                if (totalRead == maxSpoolSize && content.read() != -1) {
+                    metadata.set(HTTP_FETCH_TRUNCATED, "true");
+                }
+            }
         }
         long elapsed = System.currentTimeMillis() - start;
         LOG.debug("took {} ms to copy to local tmp file", elapsed);
-        return tis;
+        return TikaInputStream.get(tmpFile, metadata, tmp);
     }
 
-    private String responseToString(InputStream is) {
-        try {
-            return IOUtils.toString(is, StandardCharsets.UTF_8);
+    private void updateMetadata(String url, HttpResponse response, 
HttpClientContext context,
+                                Metadata metadata) {
+
+        metadata.set(HTTP_STATUS_CODE, 
response.getStatusLine().getStatusCode());
+        HttpEntity entity = response.getEntity();
+        if (entity.getContentEncoding() != null) {
+            metadata.set(HTTP_CONTENT_ENCODING, 
entity.getContentEncoding().getValue());
+        }
+        if (entity.getContentType() != null) {
+            metadata.set(HTTP_CONTENT_TYPE, 
entity.getContentType().getValue());
+        }
+
+        //load headers
+        for (String h : httpHeaders) {
+            Header[] headers = response.getHeaders(h);
+            if (headers != null && headers.length > 0) {
+                String name = HTTP_HEADER_PREFIX + h;
+                for (Header header : headers) {
+                    metadata.add(name, header.getValue());
+                }
+            }
+        }
+        List<URI> uriList = context.getRedirectLocations();
+        if (uriList == null) {
+            metadata.set(HTTP_NUM_REDIRECTS, 0);
+            metadata.set(HTTP_TARGET_URL, url);
+        } else {
+            metadata.set(HTTP_NUM_REDIRECTS, uriList.size());
+            try {
+                metadata.set(HTTP_TARGET_URL, uriList.get(uriList.size() - 
1).toURL().toString());
+            } catch (MalformedURLException e) {
+                //swallow
+            }
+        }
+        HttpConnection connection = context.getConnection();
+        if (connection instanceof HttpInetConnection) {
+            InetAddress inetAddress = 
((HttpInetConnection)connection).getRemoteAddress();
+            if (inetAddress != null) {
+                metadata.set(HTTP_TARGET_IP_ADDRESS, 
inetAddress.getHostAddress());
+            }
+        }
+
+    }
+
+    private String responseToString(HttpResponse response) {
+        try (InputStream is = response.getEntity().getContent()) {
+            ByteArrayOutputStream bos = new ByteArrayOutputStream();
+            IOUtils.copyLarge(is, bos, 0, maxErrMsgSize);
+            return new String(bos.toByteArray(), StandardCharsets.UTF_8);
         } catch (IOException e) {
             LOG.warn("IOexception trying to read error message", e);
             return "";
+        } finally {
+            try {
+                EntityUtils.consume(response.getEntity());
+            } catch (IOException e) {
+                //swallow
+            }
         }
+
     }
 
     @Field
@@ -143,6 +320,52 @@ public class HttpFetcher extends AbstractFetcher 
implements Initializable, Range
         httpClientFactory.setSocketTimeout(socketTimeout);
     }
 
+    /**
+     * Set the maximum number of bytes to spool to a temp file.
+     * If this value is <code>-1</code>, the full stream will be spooled to a 
temp file
+     *
+     * Default size is -1.
+     *
+     * @param maxSpoolSize
+     */
+    @Field
+    public void setMaxSpoolSize(long maxSpoolSize) {
+        this.maxSpoolSize = maxSpoolSize;
+    }
+
+    @Field
+    public void setMaxRedirects(int maxRedirects) {
+        this.maxRedirects = maxRedirects;
+    }
+
+    /**
+     * Which http headers should we capture in the metadata.
+     * Keys will be prepended with {@link HttpFetcher#HTTP_HEADER_PREFIX}
+     *
+     * @param headers
+     */
+    @Field
+    public void setHttpHeaders(List<String> headers) {
+        this.httpHeaders.clear();
+        this.httpHeaders.addAll(headers);
+    }
+
+    /**
+     * This sets an overall timeout on the request.  If a server is super slow
+     * or the file is very long, the other timeouts might not be triggered.
+     *
+     * @param overallTimeout
+     */
+    @Field
+    public void setOverallTimeout(long overallTimeout) {
+        this.overallTimeout = overallTimeout;
+    }
+
+    @Field
+    public void setMaxErrMsgSize(int maxErrMsgSize) {
+        this.maxErrMsgSize = maxErrMsgSize;
+    }
+
     @Override
     public void initialize(Map<String, Param> params) throws 
TikaConfigException {
         httpClient = httpClientFactory.build();
diff --git 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherTest.java
 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherTest.java
index 718dde3..aacebb2 100644
--- 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherTest.java
+++ 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.pipes.fetcher.http;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -25,15 +26,31 @@ import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.util.zip.GZIPInputStream;
 
+import org.apache.commons.io.IOUtils;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
+import org.apache.tika.TikaTest;
 import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.pipes.fetcher.FetcherManager;
 
 @Disabled("requires network connectivity")
-public class HttpFetcherTest {
+public class HttpFetcherTest extends TikaTest {
+
+    @Test
+    public void testRedirect() throws Exception {
+        String url = "https://t.co/cvfkWAEIxw?amp=1";;
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        Metadata metadata = new Metadata();
+        HttpFetcher httpFetcher =
+                (HttpFetcher) getFetcherManager("tika-config-http.xml")
+                        .getFetcher("http");
+        try (InputStream is = httpFetcher.fetch(url, metadata)) {
+            IOUtils.copy(is, bos);
+        }
+        //debug(metadata);
+    }
 
     @Test
     public void testRange() throws Exception {
diff --git 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/resources/tika-config-http.xml
 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/resources/tika-config-http.xml
index a4f058a..2336899 100644
--- 
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/resources/tika-config-http.xml
+++ 
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/resources/tika-config-http.xml
@@ -20,6 +20,11 @@
         <fetcher class="org.apache.tika.pipes.fetcher.http.HttpFetcher">
             <params>
                 <name>http</name>
+                <httpHeaders>
+                    <header>Connection</header>
+                    <header>Expires</header>
+                    <header>Content-Length</header>
+                </httpHeaders>
             </params>
         </fetcher>
     </fetchers>

Reply via email to