From aa38ee2c97e53bedca6b669a410d0f7205796b87 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 11 Feb 2024 13:34:26 -0600 Subject: [PATCH 01/89] TIKA-4181 - grpc server and client --- .../org/apache/tika/pipes/PipesConfig.java | 7 + .../apache/tika/pipes/PipesClientTest.java | 49 ++++ .../apache/tika/pipes/tika-sample-config.xml | 41 ++++ tika-pipes/pom.xml | 1 + tika-pipes/tika-grpc/README.md | 13 ++ tika-pipes/tika-grpc/pom.xml | 187 +++++++++++++++ .../apache/tika/pipes/grpc/TikaClient.java | 112 +++++++++ .../apache/tika/pipes/grpc/TikaServer.java | 217 ++++++++++++++++++ .../tika-grpc/src/main/proto/tika.proto | 47 ++++ .../tika/pipes/grpc/TikaServerTest.java | 59 +++++ tika-pipes/tika-grpc/tika-config.xml | 35 +++ 11 files changed, 768 insertions(+) create mode 100644 tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java create mode 100644 tika-core/src/test/resources/org/apache/tika/pipes/tika-sample-config.xml create mode 100644 tika-pipes/tika-grpc/README.md create mode 100644 tika-pipes/tika-grpc/pom.xml create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java create mode 100644 tika-pipes/tika-grpc/src/main/proto/tika.proto create mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java create mode 100644 tika-pipes/tika-grpc/tika-config.xml diff --git a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java index 06783d67c1..b0e8649f90 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java @@ -25,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.tika.config.TikaConfig; import org.apache.tika.exception.TikaConfigException; public class PipesConfig extends PipesConfigBase { @@ -46,6 +47,12 @@ public static PipesConfig load(Path tikaConfig) throws IOException, TikaConfigEx return pipesConfig; } + public static PipesConfig load(InputStream tikaConfigInputStream) throws IOException, TikaConfigException { + PipesConfig pipesConfig = new PipesConfig(); + pipesConfig.configure("pipes", tikaConfigInputStream); + return pipesConfig; + } + private PipesConfig() { } diff --git a/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java new file mode 100644 index 0000000000..46c475546d --- /dev/null +++ b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java @@ -0,0 +1,49 @@ +package org.apache.tika.pipes; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import org.apache.tika.exception.TikaConfigException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.emitter.EmitKey; +import org.apache.tika.pipes.fetcher.FetchKey; +import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; + +class PipesClientTest { + String fetcherName = "fs"; + String testPdfFile = "testOverlappingText.pdf"; + + private PipesClient pipesClient; + @BeforeEach + public void init() + throws TikaConfigException, IOException, ParserConfigurationException, SAXException { + Path tikaConfigPath = Paths.get("src", "test", "resources", "org", "apache", "tika", + "pipes", "tika-sample-config.xml"); + PipesConfig pipesConfig = PipesConfig.load(tikaConfigPath); + pipesClient = new PipesClient(pipesConfig); + } + + @Test + void process() throws IOException, InterruptedException { + PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(testPdfFile, + new FetchKey(fetcherName, + testPdfFile), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + Assertions.assertNotNull(pipesResult.getEmitData().getMetadataList()); + Assertions.assertEquals(1, pipesResult.getEmitData().getMetadataList().size()); + Metadata metadata = pipesResult.getEmitData().getMetadataList().get(0); + Assertions.assertEquals("testOverlappingText.pdf", metadata.get("resourceName")); + } +} \ No newline at end of file diff --git a/tika-core/src/test/resources/org/apache/tika/pipes/tika-sample-config.xml b/tika-core/src/test/resources/org/apache/tika/pipes/tika-sample-config.xml new file mode 100644 index 0000000000..c936852d95 --- /dev/null +++ b/tika-core/src/test/resources/org/apache/tika/pipes/tika-sample-config.xml @@ -0,0 +1,41 @@ + + + + + + 2 + + -Xmx1g + -XX:ParallelGCThreads=2 + + 60000 + -1 + + + + + false + + + + + fs + src/test/resources/test-documents + + + \ No newline at end of file diff --git a/tika-pipes/pom.xml b/tika-pipes/pom.xml index 4ef27a191b..61738fcd95 100644 --- a/tika-pipes/pom.xml +++ b/tika-pipes/pom.xml @@ -36,6 +36,7 @@ tika-pipes-iterators tika-pipes-reporters tika-async-cli + tika-grpc diff --git a/tika-pipes/tika-grpc/README.md b/tika-pipes/tika-grpc/README.md new file mode 100644 index 0000000000..7b0d4ccd69 --- /dev/null +++ b/tika-pipes/tika-grpc/README.md @@ -0,0 +1,13 @@ +# Tika Pipes GRPC Server + +The following is the Tika Pipes GRPC Server. + +This server will manage a pool of Tika Pipes clients. + +* Tika Pipes Fetcher CRUD operations + * Create + * Read + * Update + * Delete +* Fetch + Parse a given Fetch Item + diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml new file mode 100644 index 0000000000..121baff413 --- /dev/null +++ b/tika-pipes/tika-grpc/pom.xml @@ -0,0 +1,187 @@ + + 4.0.0 + tika-grpc + jar + + 1.60.0 + Apache Tika Pipes GRPC Server + https://tika.apache.org/ + + + org.apache.tika + tika-pipes + 3.0.0-SNAPSHOT + ../pom.xml + + + + UTF-8 + 1.60.0 + 3.24.0 + 3.24.0 + + 1.8 + 1.8 + + + + + + io.grpc + grpc-bom + ${grpc.version} + pom + import + + + + + + + io.grpc + grpc-netty-shaded + runtime + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-services + + + io.grpc + grpc-stub + + + com.google.protobuf + protobuf-java-util + ${protobuf.version} + + + com.google.code.gson + gson + 2.10.1 + + + com.google.guava + guava + 32.0.1-jre + + + com.google.j2objc + j2objc-annotations + 2.8 + + + + org.apache.tika + tika-async-cli + 2.9.1 + + + org.apache.tika + tika-parsers-standard-package + 2.9.1 + + + org.apache.tika + tika-core + 2.9.1 + + + org.apache.tomcat + annotations-api + 6.0.53 + provided + + + io.grpc + grpc-testing + test + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 3.4.0 + test + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 1.4.1 + + + enforce + + enforce + + + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.5.0 + + + test + generate-sources + + add-source + + + + ${basedir}target/generated-sources/protobuf/grpc-java + ${basedir}target/generated-sources/protobuf/java + + + + + + + + diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java new file mode 100644 index 0000000000..17efe060ff --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java @@ -0,0 +1,112 @@ +/* + * Copyright 2015 The gRPC Authors + * + * Licensed 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.tika.pipes.grpc; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +import io.grpc.Channel; +import io.grpc.Grpc; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; + +import org.apache.tika.CreateFetcherReply; +import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.FetchReply; +import org.apache.tika.FetchRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; + +public class TikaClient { + private static final Logger logger = Logger.getLogger(TikaClient.class.getName()); + + private final TikaGrpc.TikaBlockingStub blockingStub; + + public TikaClient(Channel channel) { + // 'channel' here is a Channel, not a ManagedChannel, so it is not this code's responsibility to + // shut it down. + + // Passing Channels to code makes code easier to test and makes it easier to reuse Channels. + blockingStub = TikaGrpc.newBlockingStub(channel); + } + + public void createFetcher(CreateFetcherRequest createFileSystemFetcherRequest) { + CreateFetcherReply response; + try { + response = blockingStub.createFetcher(createFileSystemFetcherRequest); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return; + } + logger.info("Create fetcher: " + response.getMessage()); + } + + public void fetch(FetchRequest fetchRequest) { + FetchReply fetchReply; + try { + fetchReply = blockingStub.fetch(fetchRequest); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return; + } + logger.info("Fetch reply - tika parsed metadata: " + fetchReply.getFieldsMap()); + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("Expects one command line argument for the base path to use for the crawl."); + System.exit(1); + return; + } + String crawlPath = args[0]; + String target = "localhost:50051"; + // Create a communication channel to the server, known as a Channel. Channels are thread-safe + // and reusable. It is common to create channels at the beginning of your application and reuse + // them until the application shuts down. + // + // For the example we use plaintext insecure credentials to avoid needing TLS certificates. To + // use TLS, use TlsChannelCredentials instead. + ManagedChannel channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()) + .build(); + try { + TikaClient client = new TikaClient(channel); + String fetcherId = "file-system-fetcher-" + UUID.randomUUID(); + + client.createFetcher(CreateFetcherRequest.newBuilder() + .setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", crawlPath) + .putParams("extractFileSystemMetadata", "true") + .build()); + + client.fetch(FetchRequest.newBuilder() + .setFetcherName(fetcherId) + .setFetchKey("000164.pdf") + .build()); + + + } finally { + // ManagedChannels use resources like threads and TCP connections. To prevent leaking these + // resources the channel should be shut down when it will no longer be used. If it may be used + // again leave it running. + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java new file mode 100644 index 0000000000..218f81d2c3 --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java @@ -0,0 +1,217 @@ +/* + * Copyright 2015 The gRPC Authors + * + * Licensed 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.tika.pipes.grpc; + +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import io.grpc.Grpc; +import io.grpc.InsecureServerCredentials; +import io.grpc.Server; +import io.grpc.stub.StreamObserver; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +import org.apache.tika.CreateFetcherReply; +import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.FetchReply; +import org.apache.tika.FetchRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.config.Param; +import org.apache.tika.exception.TikaConfigException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.FetchEmitTuple; +import org.apache.tika.pipes.PipesClient; +import org.apache.tika.pipes.PipesConfig; +import org.apache.tika.pipes.PipesResult; +import org.apache.tika.pipes.emitter.EmitKey; +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.FetchKey; +import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; + +/** + * Server that manages startup/shutdown of a server. + */ +public class TikaServer { + private static final Logger logger = Logger.getLogger(TikaServer.class.getName()); + private Server server; + + private static String tikaConfigPath; + + private void start() throws Exception { + /* The port on which the server should run */ + int port = 50051; + server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) + .addService(new TikaServerImpl()).build().start(); + logger.info("Server started, listening on " + port); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + // Use stderr here since the logger may have been reset by its JVM shutdown hook. + System.err.println("*** shutting down gRPC server since JVM is shutting down"); + try { + TikaServer.this.stop(); + } catch (InterruptedException e) { + e.printStackTrace(System.err); + } + System.err.println("*** server shut down"); + })); + } + + private void stop() throws InterruptedException { + if (server != null) { + server.shutdown().awaitTermination(30, TimeUnit.SECONDS); + } + } + + /** + * Await termination on the main thread since the grpc library uses daemon threads. + */ + private void blockUntilShutdown() throws InterruptedException { + if (server != null) { + server.awaitTermination(); + } + } + + /** + * Main launches the server from the command line. + */ + public static void main(String[] args) throws Exception { + tikaConfigPath = args[0]; + final TikaServer server = new TikaServer(); + server.start(); + server.blockUntilShutdown(); + } + + static class TikaServerImpl extends TikaGrpc.TikaImplBase { + Map fetchers = Collections.synchronizedMap(new HashMap<>()); + PipesConfig pipesConfig = PipesConfig.load(Paths.get("tika-config.xml")); + PipesClient pipesClient = new PipesClient(pipesConfig); + + TikaServerImpl() throws TikaConfigException, IOException { + } + + private void updateTikaConfig() + throws ParserConfigurationException, IOException, SAXException, + TransformerException { + Document tikaConfigDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(tikaConfigPath); + Element fetchersElement = (Element) tikaConfigDoc.getElementsByTagName("fetchers").item(0); + for (int i = 0; i < fetchersElement.getChildNodes().getLength(); ++i) { + fetchersElement.removeChild(fetchersElement.getChildNodes().item(i)); + } + for (Map.Entry fetcherEntry : fetchers.entrySet()) { + Element fetcher = tikaConfigDoc.createElement("fetcher"); + fetcher.setAttribute("class", fetcherEntry.getValue().getClass().getName()); + if (fetcherEntry.getValue() instanceof FileSystemFetcher) { + FileSystemFetcher fileSystemFetcher = (FileSystemFetcher) fetcherEntry.getValue(); + Element fetcherName = tikaConfigDoc.createElement("name"); + fetcherName.setTextContent(fileSystemFetcher.getName()); + fetcher.appendChild(fetcherName); + Element basePath = tikaConfigDoc.createElement("basePath"); + fetcher.appendChild(basePath); + basePath.setTextContent(fileSystemFetcher.getBasePath().toAbsolutePath().toString()); + } + fetchersElement.appendChild(fetcher); + } + DOMSource source = new DOMSource(tikaConfigDoc); + FileWriter writer = new FileWriter(tikaConfigPath, StandardCharsets.UTF_8); + StreamResult result = new StreamResult(writer); + + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.transform(source, result); + } + + @Override + public void createFetcher(CreateFetcherRequest request, + StreamObserver responseObserver) { + CreateFetcherReply reply = + CreateFetcherReply.newBuilder().setMessage(request.getName()).build(); + if (FileSystemFetcher.class.getName().equals(request.getFetcherClass())) { + FileSystemFetcher fileSystemFetcher = new FileSystemFetcher(); + fileSystemFetcher.setName(request.getName()); + fileSystemFetcher.setBasePath(request.getParamsOrDefault("basePath", ".")); + fileSystemFetcher.setExtractFileSystemMetadata(Boolean.parseBoolean(request.getParamsOrDefault("extractFileSystemMetadata", "false"))); + Map paramsMap = request.getParamsMap(); + Map tikaParamsMap = new HashMap<>(); + for (Map.Entry entry : paramsMap.entrySet()) { + tikaParamsMap.put(entry.getKey(), + new Param<>(entry.getKey(), entry.getValue())); + } + try { + fileSystemFetcher.initialize(tikaParamsMap); + } catch (TikaConfigException e) { + throw new RuntimeException(e); + } + fetchers.put(request.getName(), fileSystemFetcher); + } + try { + updateTikaConfig(); + } catch (Exception e) { + throw new RuntimeException(e); + } + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void fetch(FetchRequest request, StreamObserver responseObserver) { + AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); + if (fetcher == null) { + throw new RuntimeException("Could not find fetcher with name " + request.getFetcherName()); + } + Metadata tikaMetadata = new Metadata(); + for (Map.Entry entry : request.getMetadataMap().entrySet()) { + tikaMetadata.add(entry.getKey(), entry.getValue()); + } + try { + PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { + FetchReply.Builder fetchReplyBuilder = FetchReply.newBuilder() + .setFetchKey(request.getFetchKey()); + for (String name : metadata.names()) { + String value = metadata.get(name); + if (value != null) { + fetchReplyBuilder.putFields(name, value); + } + } + responseObserver.onNext(fetchReplyBuilder.build()); + } + responseObserver.onCompleted(); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto new file mode 100644 index 0000000000..f2b350f5e9 --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -0,0 +1,47 @@ +// Copyright 2015 The gRPC Authors +// +// Licensed 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. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "org.apache.tika"; +option java_outer_classname = "TikaProto"; +option objc_class_prefix = "HLW"; + +package tika; + +service Tika { + rpc CreateFetcher (CreateFetcherRequest) returns (CreateFetcherReply) {} + rpc Fetch (FetchRequest) returns (FetchReply) {} +} + +message CreateFetcherRequest { + string name = 1; + string fetcherClass = 2; + map params = 3; +} + +message CreateFetcherReply { + string message = 1; +} + +message FetchRequest { + string fetcherName = 1; + string fetchKey = 2; + map metadata = 3; +} + +message FetchReply { + string fetchKey = 1; + map fields = 2; +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java new file mode 100644 index 0000000000..f100f676f2 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2016 The gRPC Authors + * + * Licensed 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.tika.pipes.grpc; + +import static org.junit.Assert.assertEquals; + +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.testing.GrpcCleanupRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import org.apache.tika.CreateFetcherReply; +import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.grpc.TikaServer; + +@RunWith(JUnit4.class) +public class TikaServerTest { + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + @Test + public void greeterImpl_replyMessage() throws Exception { + // Generate a unique in-process server name. + String serverName = InProcessServerBuilder.generateName(); + + // Create a server, add service, start, and register for automatic graceful shutdown. + grpcCleanup.register(InProcessServerBuilder + .forName(serverName).directExecutor().addService(new TikaServer.TikaServerImpl()).build().start()); + + TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub( + // Create a client channel and register for automatic graceful shutdown. + grpcCleanup.register(InProcessChannelBuilder.forName(serverName).directExecutor().build())); + + + String testName = "test name"; + CreateFetcherReply reply = + blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(testName).build()); + + assertEquals(testName, reply.getMessage()); + } +} diff --git a/tika-pipes/tika-grpc/tika-config.xml b/tika-pipes/tika-grpc/tika-config.xml new file mode 100644 index 0000000000..b7f8c535c7 --- /dev/null +++ b/tika-pipes/tika-grpc/tika-config.xml @@ -0,0 +1,35 @@ + + + + + 2 + + -Xmx1g + -XX:ParallelGCThreads=2 + + 60000 + -1 + + + + + file-system-fetcher-fabd51ef-51c1-447c-818c-96af18b2a893 + C:\Users\nicho\Downloads\000 + + + \ No newline at end of file From c59be22723238c860e58773ee47de2c5295d2833 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sat, 2 Mar 2024 09:04:56 -0600 Subject: [PATCH 02/89] latest updates - wip --- .../pipes/fetcher/config/AbstractConfig.java | 4 + .../pipes/fetcher/fs/FileSystemFetcher.java | 8 + .../fs/config/FileSystemFetcherConfig.java | 26 ++ .../pipes/fetcher/azblob/AZBlobFetcher.java | 11 + .../azblob/config/AZBlobFetcherConfig.java | 56 ++++ .../tika/pipes/fetcher/gcs/GCSFetcher.java | 9 + .../fetcher/gcs/config/GCSFetcherConfig.java | 46 +++ .../tika/pipes/fetcher/http/HttpFetcher.java | 26 ++ .../http/config/HttpFetcherConfig.java | 178 +++++++++++ .../tika/pipes/fetcher/s3/S3Fetcher.java | 24 ++ .../fetcher/s3/config/S3FetcherConfig.java | 156 ++++++++++ tika-pipes/tika-grpc/pom.xml | 38 ++- .../tika/pipes/grpc/TikaGrpcServer.java | 77 +++++ .../tika/pipes/grpc/TikaGrpcServerImpl.java | 278 ++++++++++++++++++ .../apache/tika/pipes/grpc/TikaServer.java | 217 -------------- .../tika-grpc/src/main/proto/tika.proto | 49 ++- .../apache/tika/pipes/grpc/TikaClient.java | 12 +- .../tika/pipes/grpc/TikaGrpcServerTest.java | 59 ++++ .../tika/pipes/grpc/TikaServerTest.java | 59 ---- tika-pipes/tika-grpc/tika-config.xml | 4 - 20 files changed, 1038 insertions(+), 299 deletions(-) create mode 100644 tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java create mode 100644 tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java delete mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java rename tika-pipes/tika-grpc/src/{main => test}/java/org/apache/tika/pipes/grpc/TikaClient.java (92%) create mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java delete mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java new file mode 100644 index 0000000000..536fc44b10 --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java @@ -0,0 +1,4 @@ +package org.apache.tika.pipes.fetcher.config; + +public abstract class AbstractConfig { +} diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/FileSystemFetcher.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/FileSystemFetcher.java index 7188999767..bc3c4cddd3 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/FileSystemFetcher.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/FileSystemFetcher.java @@ -43,8 +43,16 @@ import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.fs.config.FileSystemFetcherConfig; public class FileSystemFetcher extends AbstractFetcher implements Initializable { + public FileSystemFetcher() { + } + + public FileSystemFetcher(FileSystemFetcherConfig fileSystemFetcherConfig) { + setBasePath(fileSystemFetcherConfig.getBasePath()); + setExtractFileSystemMetadata(fileSystemFetcherConfig.isExtractFileSystemMetadata()); + } private static final Logger LOG = LoggerFactory.getLogger(FileSystemFetcher.class); diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java new file mode 100644 index 0000000000..aa02cccae1 --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java @@ -0,0 +1,26 @@ +package org.apache.tika.pipes.fetcher.fs.config; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class FileSystemFetcherConfig extends AbstractConfig { + private String basePath; + private boolean extractFileSystemMetadata; + + public String getBasePath() { + return basePath; + } + + public FileSystemFetcherConfig setBasePath(String basePath) { + this.basePath = basePath; + return this; + } + + public boolean isExtractFileSystemMetadata() { + return extractFileSystemMetadata; + } + + public FileSystemFetcherConfig setExtractFileSystemMetadata(boolean extractFileSystemMetadata) { + this.extractFileSystemMetadata = extractFileSystemMetadata; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/AZBlobFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/AZBlobFetcher.java index d1f9e80d64..e38b71d9d9 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/AZBlobFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/AZBlobFetcher.java @@ -45,6 +45,7 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.azblob.config.AZBlobFetcherConfig; import org.apache.tika.utils.StringUtils; /** @@ -58,6 +59,16 @@ * your requests, your fetchKey will be the complete SAS url pointing to the blob. */ public class AZBlobFetcher extends AbstractFetcher implements Initializable { + public AZBlobFetcher() { + + } + public AZBlobFetcher(AZBlobFetcherConfig azBlobFetcherConfig) { + setContainer(azBlobFetcherConfig.getContainer()); + setEndpoint(azBlobFetcherConfig.getEndpoint()); + setSasToken(azBlobFetcherConfig.getSasToken()); + setSpoolToTemp(azBlobFetcherConfig.isSpoolToTemp()); + setExtractUserMetadata(azBlobFetcherConfig.isExtractUserMetadata()); + } private static final Logger LOGGER = LoggerFactory.getLogger(AZBlobFetcher.class); private static String PREFIX = "az-blob"; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java new file mode 100644 index 0000000000..5e64d85d08 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java @@ -0,0 +1,56 @@ +package org.apache.tika.pipes.fetcher.azblob.config; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class AZBlobFetcherConfig extends AbstractConfig { + private boolean spoolToTemp; + private String sasToken; + private String endpoint; + private String container; + private boolean extractUserMetadata; + + public boolean isSpoolToTemp() { + return spoolToTemp; + } + + public AZBlobFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + return this; + } + + public String getSasToken() { + return sasToken; + } + + public AZBlobFetcherConfig setSasToken(String sasToken) { + this.sasToken = sasToken; + return this; + } + + public String getEndpoint() { + return endpoint; + } + + public AZBlobFetcherConfig setEndpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + public String getContainer() { + return container; + } + + public AZBlobFetcherConfig setContainer(String container) { + this.container = container; + return this; + } + + public boolean isExtractUserMetadata() { + return extractUserMetadata; + } + + public AZBlobFetcherConfig setExtractUserMetadata(boolean extractUserMetadata) { + this.extractUserMetadata = extractUserMetadata; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/GCSFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/GCSFetcher.java index 661d5f30db..75f89527e8 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/GCSFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/GCSFetcher.java @@ -41,12 +41,21 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.gcs.config.GCSFetcherConfig; /** * Fetches files from google cloud storage. Must set projectId and bucket via the config. */ public class GCSFetcher extends AbstractFetcher implements Initializable { + public GCSFetcher() { + } + public GCSFetcher(GCSFetcherConfig gcsFetcherConfig) { + setBucket(gcsFetcherConfig.getBucket()); + setProjectId(gcsFetcherConfig.getProjectId()); + setSpoolToTemp(gcsFetcherConfig.isSpoolToTemp()); + setExtractUserMetadata(gcsFetcherConfig.isExtractUserMetadata()); + } private static String PREFIX = "gcs"; private static final Logger LOGGER = LoggerFactory.getLogger(GCSFetcher.class); private String projectId; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java new file mode 100644 index 0000000000..9988ceedd3 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java @@ -0,0 +1,46 @@ +package org.apache.tika.pipes.fetcher.gcs.config; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class GCSFetcherConfig extends AbstractConfig { + private boolean spoolToTemp; + private String projectId; + private String bucket; + private boolean extractUserMetadata; + + public boolean isSpoolToTemp() { + return spoolToTemp; + } + + public GCSFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + return this; + } + + public String getProjectId() { + return projectId; + } + + public GCSFetcherConfig setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + public String getBucket() { + return bucket; + } + + public GCSFetcherConfig setBucket(String bucket) { + this.bucket = bucket; + return this; + } + + public boolean isExtractUserMetadata() { + return extractUserMetadata; + } + + public GCSFetcherConfig setExtractUserMetadata(boolean extractUserMetadata) { + this.extractUserMetadata = extractUserMetadata; + return this; + } +} 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 f9a4ebe0df..398ace7ed3 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 @@ -70,13 +70,39 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; +import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; import org.apache.tika.utils.StringUtils; /** * Based on Apache httpclient */ public class HttpFetcher extends AbstractFetcher implements Initializable, RangeFetcher { + public HttpFetcher() { + } + public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { + setConnectTimeout(httpFetcherConfig.getConnectTimeout()); + setRequestTimeout(httpFetcherConfig.getRequestTimeout()); + setSocketTimeout(httpFetcherConfig.getSocketTimeout()); + setOverallTimeout(httpFetcherConfig.getOverallTimeout()); + + setMaxErrMsgSize(httpFetcherConfig.getMaxErrMsgSize()); + setMaxConnections(httpFetcherConfig.getMaxConnections()); + setMaxConnectionsPerRoute(httpFetcherConfig.getMaxConnectionsPerRoute()); + setMaxRedirects(httpFetcherConfig.getMaxRedirects()); + setMaxSpoolSize(httpFetcherConfig.getMaxSpoolSize()); + + setHttpHeaders(httpFetcherConfig.getHeaders()); + setUserAgent(httpFetcherConfig.getUserAgent()); + + setUserName(httpFetcherConfig.getUserName()); + setPassword(httpFetcherConfig.getPassword()); + setNtDomain(httpFetcherConfig.getNtDomain()); + setAuthScheme(httpFetcherConfig.getAuthScheme()); + + setProxyHost(httpFetcherConfig.getProxyHost()); + setProxyPort(httpFetcherConfig.getProxyPort()); + } public static String HTTP_HEADER_PREFIX = "http-header:"; public static String HTTP_FETCH_PREFIX = "http-connection:"; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java new file mode 100644 index 0000000000..1372f1355e --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -0,0 +1,178 @@ +package org.apache.tika.pipes.fetcher.http.config; + +import java.util.List; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class HttpFetcherConfig extends AbstractConfig { + private String userName; + private String password; + private String ntDomain; + private String authScheme; + private String proxyHost; + private int proxyPort; + private int connectTimeout; + private int requestTimeout; + private int socketTimeout; + private int maxConnections; + int maxConnectionsPerRoute; + private long maxSpoolSize; + private int maxRedirects; + private List headers; + private long overallTimeout; + private int maxErrMsgSize; + private String userAgent; + + public String getUserName() { + return userName; + } + + public HttpFetcherConfig setUserName(String userName) { + this.userName = userName; + return this; + } + + public String getPassword() { + return password; + } + + public HttpFetcherConfig setPassword(String password) { + this.password = password; + return this; + } + + public String getNtDomain() { + return ntDomain; + } + + public HttpFetcherConfig setNtDomain(String ntDomain) { + this.ntDomain = ntDomain; + return this; + } + + public String getAuthScheme() { + return authScheme; + } + + public HttpFetcherConfig setAuthScheme(String authScheme) { + this.authScheme = authScheme; + return this; + } + + public String getProxyHost() { + return proxyHost; + } + + public HttpFetcherConfig setProxyHost(String proxyHost) { + this.proxyHost = proxyHost; + return this; + } + + public int getProxyPort() { + return proxyPort; + } + + public HttpFetcherConfig setProxyPort(int proxyPort) { + this.proxyPort = proxyPort; + return this; + } + + public int getConnectTimeout() { + return connectTimeout; + } + + public HttpFetcherConfig setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + public int getRequestTimeout() { + return requestTimeout; + } + + public HttpFetcherConfig setRequestTimeout(int requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + public int getSocketTimeout() { + return socketTimeout; + } + + public HttpFetcherConfig setSocketTimeout(int socketTimeout) { + this.socketTimeout = socketTimeout; + return this; + } + + public int getMaxConnections() { + return maxConnections; + } + + public HttpFetcherConfig setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + return this; + } + + public int getMaxConnectionsPerRoute() { + return maxConnectionsPerRoute; + } + + public HttpFetcherConfig setMaxConnectionsPerRoute(int maxConnectionsPerRoute) { + this.maxConnectionsPerRoute = maxConnectionsPerRoute; + return this; + } + + public long getMaxSpoolSize() { + return maxSpoolSize; + } + + public HttpFetcherConfig setMaxSpoolSize(long maxSpoolSize) { + this.maxSpoolSize = maxSpoolSize; + return this; + } + + public int getMaxRedirects() { + return maxRedirects; + } + + public HttpFetcherConfig setMaxRedirects(int maxRedirects) { + this.maxRedirects = maxRedirects; + return this; + } + + public List getHeaders() { + return headers; + } + + public HttpFetcherConfig setHeaders(List headers) { + this.headers = headers; + return this; + } + + public long getOverallTimeout() { + return overallTimeout; + } + + public HttpFetcherConfig setOverallTimeout(long overallTimeout) { + this.overallTimeout = overallTimeout; + return this; + } + + public int getMaxErrMsgSize() { + return maxErrMsgSize; + } + + public HttpFetcherConfig setMaxErrMsgSize(int maxErrMsgSize) { + this.maxErrMsgSize = maxErrMsgSize; + return this; + } + + public String getUserAgent() { + return userAgent; + } + + public HttpFetcherConfig setUserAgent(String userAgent) { + this.userAgent = userAgent; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/S3Fetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/S3Fetcher.java index 7283c97273..144f15c016 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/S3Fetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/S3Fetcher.java @@ -58,6 +58,7 @@ import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; +import org.apache.tika.pipes.fetcher.s3.config.S3FetcherConfig; import org.apache.tika.utils.StringUtils; /** @@ -66,6 +67,29 @@ * initialization, and the fetch key is "path/to/my_file.pdf". */ public class S3Fetcher extends AbstractFetcher implements Initializable, RangeFetcher { + public S3Fetcher() { + + } + + public S3Fetcher(S3FetcherConfig s3FetcherConfig) { + setBucket(s3FetcherConfig.getBucket()); + setRegion(s3FetcherConfig.getRegion()); + setProfile(s3FetcherConfig.getProfile()); + setAccessKey(s3FetcherConfig.getAccessKey()); + setSecretKey(s3FetcherConfig.getSecretKey()); + setPrefix(s3FetcherConfig.getPrefix()); + + setCredentialsProvider(s3FetcherConfig.getCredentialsProvider()); + setEndpointConfigurationService(s3FetcherConfig.getEndpointConfigurationService()); + + setMaxConnections(s3FetcherConfig.getMaxConnections()); + setSpoolToTemp(s3FetcherConfig.isSpoolToTemp()); + setThrottleSeconds(s3FetcherConfig.getThrottleSeconds()); + setMaxLength(s3FetcherConfig.getMaxLength()); + + setExtractUserMetadata(s3FetcherConfig.isExtractUserMetadata()); + setPathStyleAccessEnabled(s3FetcherConfig.isPathStyleAccessEnabled()); + } private static final Logger LOGGER = LoggerFactory.getLogger(S3Fetcher.class); private static final String PREFIX = "s3"; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java new file mode 100644 index 0000000000..8a91691e2e --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java @@ -0,0 +1,156 @@ +package org.apache.tika.pipes.fetcher.s3.config; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class S3FetcherConfig extends AbstractConfig { + private boolean spoolToTemp; + private String region; + private String profile; + private String bucket; + private String commaDelimitedLongs; + private String prefix; + private boolean extractUserMetadata; + private int maxConnections; + private String credentialsProvider; + private long maxLength; + private String accessKey; + private String secretKey; + private String endpointConfigurationService; + private boolean pathStyleAccessEnabled; + private long[] throttleSeconds; + + public boolean isSpoolToTemp() { + return spoolToTemp; + } + + public S3FetcherConfig setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + return this; + } + + public String getRegion() { + return region; + } + + public S3FetcherConfig setRegion(String region) { + this.region = region; + return this; + } + + public String getProfile() { + return profile; + } + + public S3FetcherConfig setProfile(String profile) { + this.profile = profile; + return this; + } + + public String getBucket() { + return bucket; + } + + public S3FetcherConfig setBucket(String bucket) { + this.bucket = bucket; + return this; + } + + public String getCommaDelimitedLongs() { + return commaDelimitedLongs; + } + + public S3FetcherConfig setCommaDelimitedLongs(String commaDelimitedLongs) { + this.commaDelimitedLongs = commaDelimitedLongs; + return this; + } + + public String getPrefix() { + return prefix; + } + + public S3FetcherConfig setPrefix(String prefix) { + this.prefix = prefix; + return this; + } + + public boolean isExtractUserMetadata() { + return extractUserMetadata; + } + + public S3FetcherConfig setExtractUserMetadata(boolean extractUserMetadata) { + this.extractUserMetadata = extractUserMetadata; + return this; + } + + public int getMaxConnections() { + return maxConnections; + } + + public S3FetcherConfig setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + return this; + } + + public String getCredentialsProvider() { + return credentialsProvider; + } + + public S3FetcherConfig setCredentialsProvider(String credentialsProvider) { + this.credentialsProvider = credentialsProvider; + return this; + } + + public long getMaxLength() { + return maxLength; + } + + public S3FetcherConfig setMaxLength(long maxLength) { + this.maxLength = maxLength; + return this; + } + + public String getAccessKey() { + return accessKey; + } + + public S3FetcherConfig setAccessKey(String accessKey) { + this.accessKey = accessKey; + return this; + } + + public String getSecretKey() { + return secretKey; + } + + public S3FetcherConfig setSecretKey(String secretKey) { + this.secretKey = secretKey; + return this; + } + + public String getEndpointConfigurationService() { + return endpointConfigurationService; + } + + public S3FetcherConfig setEndpointConfigurationService(String endpointConfigurationService) { + this.endpointConfigurationService = endpointConfigurationService; + return this; + } + + public boolean isPathStyleAccessEnabled() { + return pathStyleAccessEnabled; + } + + public S3FetcherConfig setPathStyleAccessEnabled(boolean pathStyleAccessEnabled) { + this.pathStyleAccessEnabled = pathStyleAccessEnabled; + return this; + } + + public long[] getThrottleSeconds() { + return throttleSeconds; + } + + public S3FetcherConfig setThrottleSeconds(long[] throttleSeconds) { + this.throttleSeconds = throttleSeconds; + return this; + } +} diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 121baff413..a4d7486f8c 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -5,8 +5,7 @@ jar - 1.60.0 - Apache Tika Pipes GRPC Server + Apache Tika pipes gRPC server https://tika.apache.org/ @@ -21,9 +20,7 @@ 1.60.0 3.24.0 3.24.0 - - 1.8 - 1.8 + 11 @@ -76,21 +73,20 @@ j2objc-annotations 2.8 - org.apache.tika tika-async-cli - 2.9.1 + 3.0.0-SNAPSHOT org.apache.tika tika-parsers-standard-package - 2.9.1 + 3.0.0-SNAPSHOT org.apache.tika tika-core - 2.9.1 + 3.0.0-SNAPSHOT org.apache.tomcat @@ -115,6 +111,30 @@ 3.4.0 test + + org.apache.tika + tika-fetcher-http + 3.0.0-SNAPSHOT + test + + + org.apache.tika + tika-fetcher-gcs + 3.0.0-SNAPSHOT + test + + + org.apache.tika + tika-fetcher-az-blob + 3.0.0-SNAPSHOT + test + + + org.apache.tika + tika-fetcher-s3 + 3.0.0-SNAPSHOT + test + diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java new file mode 100644 index 0000000000..26493a065e --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -0,0 +1,77 @@ +/* + * Copyright 2015 The gRPC Authors + * + * Licensed 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.tika.pipes.grpc; + +import java.util.concurrent.TimeUnit; + +import io.grpc.Grpc; +import io.grpc.InsecureServerCredentials; +import io.grpc.Server; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Server that manages startup/shutdown of the GRPC Tika server. + */ +public class TikaGrpcServer { + private static final Logger LOGGER = LoggerFactory.getLogger(TikaGrpcServer.class); + private Server server; + private static String tikaConfigPath; + + private void start() throws Exception { + /* The port on which the server should run */ + int port = Integer.parseInt(System.getProperty("server.port", "50051")); + server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) + .addService(new TikaGrpcServerImpl(tikaConfigPath)).build().start(); + LOGGER.info("Server started, listening on " + port); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + // Use stderr here since the logger may have been reset by its JVM shutdown hook. + System.err.println("*** shutting down gRPC server since JVM is shutting down"); + try { + TikaGrpcServer.this.stop(); + } catch (InterruptedException e) { + e.printStackTrace(System.err); + } + System.err.println("*** server shut down"); + })); + } + + private void stop() throws InterruptedException { + if (server != null) { + server.shutdown().awaitTermination(30, TimeUnit.SECONDS); + } + } + + /** + * Await termination on the main thread since the grpc library uses daemon threads. + */ + private void blockUntilShutdown() throws InterruptedException { + if (server != null) { + server.awaitTermination(); + } + } + + /** + * Main launches the server from the command line. + */ + public static void main(String[] args) throws Exception { + tikaConfigPath = args[0]; + final TikaGrpcServer server = new TikaGrpcServer(); + server.start(); + server.blockUntilShutdown(); + } +} diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java new file mode 100644 index 0000000000..697ebcac5a --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -0,0 +1,278 @@ +package org.apache.tika.pipes.grpc; + +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.grpc.stub.StreamObserver; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +import org.apache.tika.CreateFetcherReply; +import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.DeleteFetcherReply; +import org.apache.tika.DeleteFetcherRequest; +import org.apache.tika.FetchAndParseReply; +import org.apache.tika.FetchAndParseRequest; +import org.apache.tika.GetFetcherReply; +import org.apache.tika.GetFetcherRequest; +import org.apache.tika.ListFetchersReply; +import org.apache.tika.ListFetchersRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.UpdateFetcherReply; +import org.apache.tika.UpdateFetcherRequest; +import org.apache.tika.config.Initializable; +import org.apache.tika.config.Param; +import org.apache.tika.exception.TikaConfigException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.FetchEmitTuple; +import org.apache.tika.pipes.PipesClient; +import org.apache.tika.pipes.PipesConfig; +import org.apache.tika.pipes.PipesResult; +import org.apache.tika.pipes.emitter.EmitKey; +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.FetchKey; +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * FetcherID is key, The pair is the Fetcher object and the Metadata + */ + Map fetchers = Collections.synchronizedMap(new HashMap<>()); + Map fetcherConfigs = Collections.synchronizedMap(new HashMap<>()); + PipesConfig pipesConfig = PipesConfig.load(Paths.get("tika-config.xml")); + PipesClient pipesClient = new PipesClient(pipesConfig); + + String tikaConfigPath; + + TikaGrpcServerImpl(String tikaConfigPath) + throws TikaConfigException, IOException, ParserConfigurationException, + TransformerException, SAXException { + this.tikaConfigPath = tikaConfigPath; + updateTikaConfig(); + } + + private void updateTikaConfig() + throws ParserConfigurationException, IOException, SAXException, TransformerException { + Document tikaConfigDoc = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(tikaConfigPath); + Element fetchersElement = (Element) tikaConfigDoc.getElementsByTagName("fetchers").item(0); + for (int i = 0; i < fetchersElement.getChildNodes().getLength(); ++i) { + fetchersElement.removeChild(fetchersElement.getChildNodes().item(i)); + } + for (var fetcherEntry : fetchers.entrySet()) { + AbstractFetcher fetcherObject = fetcherEntry.getValue(); + Map fetcherConfigParams = + OBJECT_MAPPER.convertValue(fetcherConfigs.get(fetcherEntry.getKey()), new TypeReference<>() { + }); + Element fetcher = tikaConfigDoc.createElement("fetcher"); + fetcher.setAttribute("class", fetcherEntry.getValue().getClass().getName()); + Element fetcherName = tikaConfigDoc.createElement("name"); + fetcherName.setTextContent(fetcherObject.getName()); + fetcher.appendChild(fetcherName); + populateFetcherConfigs(fetcherConfigParams, tikaConfigDoc, fetcher); + fetchersElement.appendChild(fetcher); + } + DOMSource source = new DOMSource(tikaConfigDoc); + FileWriter writer = new FileWriter(tikaConfigPath, StandardCharsets.UTF_8); + StreamResult result = new StreamResult(writer); + + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.transform(source, result); + } + + private void populateFetcherConfigs(Map fetcherConfigParams, + Document tikaConfigDoc, Element fetcher) { + for (var configParam : fetcherConfigParams.entrySet()) { + Element configElm = tikaConfigDoc.createElement(configParam.getKey()); + fetcher.appendChild(configElm); + configElm.setTextContent(Objects.toString(configParam.getValue())); + } + } + + @SuppressWarnings("raw") + @Override + public void createFetcher(CreateFetcherRequest request, + StreamObserver responseObserver) { + CreateFetcherReply reply = + CreateFetcherReply.newBuilder().setMessage(request.getName()).build(); + Map tikaParamsMap = createTikaParamMap(request.getParamsMap()); + try { + createFetcher(request.getName(), request.getFetcherClass(), request.getParamsMap(), + tikaParamsMap); + updateTikaConfig(); + } catch (Exception e) { + throw new RuntimeException(e); + } + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + private void createFetcher(String name, String fetcherClassName, Map paramsMap, + Map tikaParamsMap) { + try { + Class fetcherClass = + (Class) Class.forName(fetcherClassName); + String configClassName = + fetcherClass.getPackageName() + ".config." + fetcherClass.getSimpleName() + + "Config"; + Class configClass = + (Class) Class.forName(configClassName); + AbstractConfig configObject = OBJECT_MAPPER.convertValue(paramsMap, configClass); + AbstractFetcher abstractFetcher = + fetcherClass.getDeclaredConstructor(configClass).newInstance(configObject); + abstractFetcher.setName(name); + if (Initializable.class.isAssignableFrom(fetcherClass)) { + Initializable initializable = (Initializable) abstractFetcher; + initializable.initialize(tikaParamsMap); + } + fetchers.put(name, abstractFetcher); + fetcherConfigs.put(name, configObject); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } catch (TikaConfigException e) { + throw new RuntimeException(e); + } + } + + private static Map createTikaParamMap(Map paramsMap) { + Map tikaParamsMap = new HashMap<>(); + for (Map.Entry entry : paramsMap.entrySet()) { + tikaParamsMap.put(entry.getKey(), new Param<>(entry.getKey(), entry.getValue())); + } + return tikaParamsMap; + } + + @Override + public void fetchAndParse(FetchAndParseRequest request, + StreamObserver responseObserver) { + AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); + if (fetcher == null) { + throw new RuntimeException( + "Could not find fetcher with name " + request.getFetcherName()); + } + Metadata tikaMetadata = new Metadata(); + for (Map.Entry entry : request.getMetadataMap().entrySet()) { + tikaMetadata.add(entry.getKey(), entry.getValue()); + } + try { + PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), + FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { + FetchAndParseReply.Builder fetchReplyBuilder = + FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); + for (String name : metadata.names()) { + String value = metadata.get(name); + if (value != null) { + fetchReplyBuilder.putFields(name, value); + } + } + responseObserver.onNext(fetchReplyBuilder.build()); + } + responseObserver.onCompleted(); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void updateFetcher(UpdateFetcherRequest request, + StreamObserver responseObserver) { + UpdateFetcherReply reply = + UpdateFetcherReply.newBuilder().setMessage(request.getName()).build(); + Map tikaParamsMap = createTikaParamMap(request.getParamsMap()); + try { + deleteFetcher(request.getName()); + createFetcher(request.getName(), request.getFetcherClass(), request.getParamsMap(), + tikaParamsMap); + updateTikaConfig(); + } catch (Exception e) { + throw new RuntimeException(e); + } + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void getFetcher(GetFetcherRequest request, + StreamObserver responseObserver) { + GetFetcherReply.Builder getFetcherReply = GetFetcherReply.newBuilder(); + AbstractConfig abstractConfig = fetcherConfigs.get(request.getName()); + Map paramMap = + OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { + }); + paramMap.forEach((k, v) -> getFetcherReply.putParams(Objects.toString(k), Objects.toString(v))); + responseObserver.onNext(getFetcherReply.build()); + responseObserver.onCompleted(); + } + + @Override + public void listFetchers(ListFetchersRequest request, + StreamObserver responseObserver) { + ListFetchersReply.Builder listFetchersReplyBuilder = ListFetchersReply.newBuilder(); + for (Map.Entry fetcherConfig : fetcherConfigs.entrySet()) { + GetFetcherReply.Builder replyBuilder = createFetcherReply(fetcherConfig); + listFetchersReplyBuilder.addGetFetcherReply(replyBuilder.build()); + } + responseObserver.onNext(listFetchersReplyBuilder.build()); + responseObserver.onCompleted(); + } + + private GetFetcherReply.Builder createFetcherReply(Map.Entry fetcherConfig) { + AbstractFetcher abstractFetcher = fetchers.get(fetcherConfig.getKey()); + AbstractConfig abstractConfig = fetcherConfigs.get(fetcherConfig.getKey()); + GetFetcherReply.Builder replyBuilder = GetFetcherReply.newBuilder() + .setFetcherClass(abstractFetcher.getClass().getName()) + .setName(abstractFetcher.getName()); + Map paramMap = + OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { + }); + paramMap.forEach((k, v) -> replyBuilder.putParams(Objects.toString(k), Objects.toString(v))); + return replyBuilder; + } + + @Override + public void deleteFetcher(DeleteFetcherRequest request, + StreamObserver responseObserver) { + deleteFetcher(request.getName()); + try { + updateTikaConfig(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void deleteFetcher(String name) { + fetcherConfigs.remove(name); + fetchers.remove(name); + } +} \ No newline at end of file diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java deleted file mode 100644 index 218f81d2c3..0000000000 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaServer.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2015 The gRPC Authors - * - * Licensed 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.tika.pipes.grpc; - -import java.io.FileWriter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.logging.Logger; - -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerException; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; - -import io.grpc.Grpc; -import io.grpc.InsecureServerCredentials; -import io.grpc.Server; -import io.grpc.stub.StreamObserver; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.xml.sax.SAXException; - -import org.apache.tika.CreateFetcherReply; -import org.apache.tika.CreateFetcherRequest; -import org.apache.tika.FetchReply; -import org.apache.tika.FetchRequest; -import org.apache.tika.TikaGrpc; -import org.apache.tika.config.Param; -import org.apache.tika.exception.TikaConfigException; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.pipes.FetchEmitTuple; -import org.apache.tika.pipes.PipesClient; -import org.apache.tika.pipes.PipesConfig; -import org.apache.tika.pipes.PipesResult; -import org.apache.tika.pipes.emitter.EmitKey; -import org.apache.tika.pipes.fetcher.AbstractFetcher; -import org.apache.tika.pipes.fetcher.FetchKey; -import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; - -/** - * Server that manages startup/shutdown of a server. - */ -public class TikaServer { - private static final Logger logger = Logger.getLogger(TikaServer.class.getName()); - private Server server; - - private static String tikaConfigPath; - - private void start() throws Exception { - /* The port on which the server should run */ - int port = 50051; - server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) - .addService(new TikaServerImpl()).build().start(); - logger.info("Server started, listening on " + port); - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - // Use stderr here since the logger may have been reset by its JVM shutdown hook. - System.err.println("*** shutting down gRPC server since JVM is shutting down"); - try { - TikaServer.this.stop(); - } catch (InterruptedException e) { - e.printStackTrace(System.err); - } - System.err.println("*** server shut down"); - })); - } - - private void stop() throws InterruptedException { - if (server != null) { - server.shutdown().awaitTermination(30, TimeUnit.SECONDS); - } - } - - /** - * Await termination on the main thread since the grpc library uses daemon threads. - */ - private void blockUntilShutdown() throws InterruptedException { - if (server != null) { - server.awaitTermination(); - } - } - - /** - * Main launches the server from the command line. - */ - public static void main(String[] args) throws Exception { - tikaConfigPath = args[0]; - final TikaServer server = new TikaServer(); - server.start(); - server.blockUntilShutdown(); - } - - static class TikaServerImpl extends TikaGrpc.TikaImplBase { - Map fetchers = Collections.synchronizedMap(new HashMap<>()); - PipesConfig pipesConfig = PipesConfig.load(Paths.get("tika-config.xml")); - PipesClient pipesClient = new PipesClient(pipesConfig); - - TikaServerImpl() throws TikaConfigException, IOException { - } - - private void updateTikaConfig() - throws ParserConfigurationException, IOException, SAXException, - TransformerException { - Document tikaConfigDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(tikaConfigPath); - Element fetchersElement = (Element) tikaConfigDoc.getElementsByTagName("fetchers").item(0); - for (int i = 0; i < fetchersElement.getChildNodes().getLength(); ++i) { - fetchersElement.removeChild(fetchersElement.getChildNodes().item(i)); - } - for (Map.Entry fetcherEntry : fetchers.entrySet()) { - Element fetcher = tikaConfigDoc.createElement("fetcher"); - fetcher.setAttribute("class", fetcherEntry.getValue().getClass().getName()); - if (fetcherEntry.getValue() instanceof FileSystemFetcher) { - FileSystemFetcher fileSystemFetcher = (FileSystemFetcher) fetcherEntry.getValue(); - Element fetcherName = tikaConfigDoc.createElement("name"); - fetcherName.setTextContent(fileSystemFetcher.getName()); - fetcher.appendChild(fetcherName); - Element basePath = tikaConfigDoc.createElement("basePath"); - fetcher.appendChild(basePath); - basePath.setTextContent(fileSystemFetcher.getBasePath().toAbsolutePath().toString()); - } - fetchersElement.appendChild(fetcher); - } - DOMSource source = new DOMSource(tikaConfigDoc); - FileWriter writer = new FileWriter(tikaConfigPath, StandardCharsets.UTF_8); - StreamResult result = new StreamResult(writer); - - TransformerFactory transformerFactory = TransformerFactory.newInstance(); - Transformer transformer = transformerFactory.newTransformer(); - transformer.transform(source, result); - } - - @Override - public void createFetcher(CreateFetcherRequest request, - StreamObserver responseObserver) { - CreateFetcherReply reply = - CreateFetcherReply.newBuilder().setMessage(request.getName()).build(); - if (FileSystemFetcher.class.getName().equals(request.getFetcherClass())) { - FileSystemFetcher fileSystemFetcher = new FileSystemFetcher(); - fileSystemFetcher.setName(request.getName()); - fileSystemFetcher.setBasePath(request.getParamsOrDefault("basePath", ".")); - fileSystemFetcher.setExtractFileSystemMetadata(Boolean.parseBoolean(request.getParamsOrDefault("extractFileSystemMetadata", "false"))); - Map paramsMap = request.getParamsMap(); - Map tikaParamsMap = new HashMap<>(); - for (Map.Entry entry : paramsMap.entrySet()) { - tikaParamsMap.put(entry.getKey(), - new Param<>(entry.getKey(), entry.getValue())); - } - try { - fileSystemFetcher.initialize(tikaParamsMap); - } catch (TikaConfigException e) { - throw new RuntimeException(e); - } - fetchers.put(request.getName(), fileSystemFetcher); - } - try { - updateTikaConfig(); - } catch (Exception e) { - throw new RuntimeException(e); - } - responseObserver.onNext(reply); - responseObserver.onCompleted(); - } - - @Override - public void fetch(FetchRequest request, StreamObserver responseObserver) { - AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); - if (fetcher == null) { - throw new RuntimeException("Could not find fetcher with name " + request.getFetcherName()); - } - Metadata tikaMetadata = new Metadata(); - for (Map.Entry entry : request.getMetadataMap().entrySet()) { - tikaMetadata.add(entry.getKey(), entry.getValue()); - } - try { - PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); - for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { - FetchReply.Builder fetchReplyBuilder = FetchReply.newBuilder() - .setFetchKey(request.getFetchKey()); - for (String name : metadata.names()) { - String value = metadata.get(name); - if (value != null) { - fetchReplyBuilder.putFields(name, value); - } - } - responseObserver.onNext(fetchReplyBuilder.build()); - } - responseObserver.onCompleted(); - } catch (IOException e) { - throw new RuntimeException(e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } -} diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index f2b350f5e9..c23c678d42 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -21,8 +21,12 @@ option objc_class_prefix = "HLW"; package tika; service Tika { - rpc CreateFetcher (CreateFetcherRequest) returns (CreateFetcherReply) {} - rpc Fetch (FetchRequest) returns (FetchReply) {} + rpc CreateFetcher(CreateFetcherRequest) returns (CreateFetcherReply) {} + rpc UpdateFetcher(UpdateFetcherRequest) returns (UpdateFetcherReply) {} + rpc GetFetcher(GetFetcherRequest) returns (GetFetcherReply) {} + rpc ListFetchers(ListFetchersRequest) returns (ListFetchersReply) {} + rpc DeleteFetcher(DeleteFetcherRequest) returns (DeleteFetcherReply) {} + rpc FetchAndParse(FetchAndParseRequest) returns (FetchAndParseReply) {} } message CreateFetcherRequest { @@ -35,13 +39,50 @@ message CreateFetcherReply { string message = 1; } -message FetchRequest { +message UpdateFetcherRequest { + string name = 1; + string fetcherClass = 2; + map params = 3; +} + +message UpdateFetcherReply { + string message = 1; +} + +message FetchAndParseRequest { string fetcherName = 1; string fetchKey = 2; map metadata = 3; } -message FetchReply { +message FetchAndParseReply { string fetchKey = 1; map fields = 2; } + +message DeleteFetcherRequest { + string name = 1; +} + +message DeleteFetcherReply { + bool success = 1; +} + +message GetFetcherRequest { + string name = 1; +} + +message GetFetcherReply { + string name = 1; + string fetcherClass = 2; + map params = 3; +} + +message ListFetchersRequest { + int32 pageNumber = 1; + int32 numFetchersPerPage = 2; +} + +message ListFetchersReply { + repeated GetFetcherReply getFetcherReply = 1; +} \ No newline at end of file diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java similarity index 92% rename from tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java rename to tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java index 17efe060ff..3326540020 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaClient.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java @@ -29,8 +29,8 @@ import org.apache.tika.CreateFetcherReply; import org.apache.tika.CreateFetcherRequest; -import org.apache.tika.FetchReply; -import org.apache.tika.FetchRequest; +import org.apache.tika.FetchAndParseReply; +import org.apache.tika.FetchAndParseRequest; import org.apache.tika.TikaGrpc; import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; @@ -58,10 +58,10 @@ public void createFetcher(CreateFetcherRequest createFileSystemFetcherRequest) { logger.info("Create fetcher: " + response.getMessage()); } - public void fetch(FetchRequest fetchRequest) { - FetchReply fetchReply; + public void fetchAndParse(FetchAndParseRequest fetchAndParseRequest) { + FetchAndParseReply fetchReply; try { - fetchReply = blockingStub.fetch(fetchRequest); + fetchReply = blockingStub.fetchAndParse(fetchAndParseRequest); } catch (StatusRuntimeException e) { logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); return; @@ -96,7 +96,7 @@ public static void main(String[] args) throws Exception { .putParams("extractFileSystemMetadata", "true") .build()); - client.fetch(FetchRequest.newBuilder() + client.fetchAndParse(FetchAndParseRequest.newBuilder() .setFetcherName(fetcherId) .setFetchKey("000164.pdf") .build()); diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java new file mode 100644 index 0000000000..b657fad9ca --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2016 The gRPC Authors + * + * Licensed 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.tika.pipes.grpc; + +import static org.junit.Assert.assertEquals; + +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.testing.GrpcCleanupRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import org.apache.tika.CreateFetcherReply; +import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.TikaGrpc; + +@RunWith(JUnit4.class) +public class TikaGrpcServerTest { + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + @Test + public void greeterImpl_replyMessage() throws Exception { + // Generate a unique in-process server name. + String serverName = InProcessServerBuilder.generateName(); + + // Create a server, add service, start, and register for automatic graceful shutdown. + grpcCleanup.register(InProcessServerBuilder.forName(serverName).directExecutor() + .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start()); + + TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub( + // Create a client channel and register for automatic graceful shutdown. + grpcCleanup.register( + InProcessChannelBuilder.forName(serverName).directExecutor().build())); + + + String testName = "test name"; + CreateFetcherReply reply = blockingStub.createFetcher( + CreateFetcherRequest.newBuilder().setName(testName).build()); + + assertEquals(testName, reply.getMessage()); + } +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java deleted file mode 100644 index f100f676f2..0000000000 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaServerTest.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2016 The gRPC Authors - * - * Licensed 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.tika.pipes.grpc; - -import static org.junit.Assert.assertEquals; - -import io.grpc.inprocess.InProcessChannelBuilder; -import io.grpc.inprocess.InProcessServerBuilder; -import io.grpc.testing.GrpcCleanupRule; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import org.apache.tika.CreateFetcherReply; -import org.apache.tika.CreateFetcherRequest; -import org.apache.tika.TikaGrpc; -import org.apache.tika.pipes.grpc.TikaServer; - -@RunWith(JUnit4.class) -public class TikaServerTest { - @Rule - public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); - - @Test - public void greeterImpl_replyMessage() throws Exception { - // Generate a unique in-process server name. - String serverName = InProcessServerBuilder.generateName(); - - // Create a server, add service, start, and register for automatic graceful shutdown. - grpcCleanup.register(InProcessServerBuilder - .forName(serverName).directExecutor().addService(new TikaServer.TikaServerImpl()).build().start()); - - TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub( - // Create a client channel and register for automatic graceful shutdown. - grpcCleanup.register(InProcessChannelBuilder.forName(serverName).directExecutor().build())); - - - String testName = "test name"; - CreateFetcherReply reply = - blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(testName).build()); - - assertEquals(testName, reply.getMessage()); - } -} diff --git a/tika-pipes/tika-grpc/tika-config.xml b/tika-pipes/tika-grpc/tika-config.xml index b7f8c535c7..19a03c9223 100644 --- a/tika-pipes/tika-grpc/tika-config.xml +++ b/tika-pipes/tika-grpc/tika-config.xml @@ -27,9 +27,5 @@ - - file-system-fetcher-fabd51ef-51c1-447c-818c-96af18b2a893 - C:\Users\nicho\Downloads\000 - \ No newline at end of file From f1f3d3869f07222812ca66197e9e4b075bcc90c0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 02:29:31 -0500 Subject: [PATCH 03/89] code formatting --- tika-pipes/tika-grpc/README.md | 8 +- tika-pipes/tika-grpc/pom.xml | 2 +- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 20 +-- .../tika-grpc/src/main/proto/tika.proto | 12 +- .../apache/tika/pipes/grpc/TikaClient.java | 119 +++++++++--------- 5 files changed, 81 insertions(+), 80 deletions(-) diff --git a/tika-pipes/tika-grpc/README.md b/tika-pipes/tika-grpc/README.md index 7b0d4ccd69..f2269b22bf 100644 --- a/tika-pipes/tika-grpc/README.md +++ b/tika-pipes/tika-grpc/README.md @@ -5,9 +5,9 @@ The following is the Tika Pipes GRPC Server. This server will manage a pool of Tika Pipes clients. * Tika Pipes Fetcher CRUD operations - * Create - * Read - * Update - * Delete + * Create + * Read + * Update + * Delete * Fetch + Parse a given Fetch Item diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index a4d7486f8c..3daaf7a3e0 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -1,5 +1,5 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 tika-grpc jar diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 697ebcac5a..3098354d88 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -80,7 +80,8 @@ private void updateTikaConfig() for (var fetcherEntry : fetchers.entrySet()) { AbstractFetcher fetcherObject = fetcherEntry.getValue(); Map fetcherConfigParams = - OBJECT_MAPPER.convertValue(fetcherConfigs.get(fetcherEntry.getKey()), new TypeReference<>() { + OBJECT_MAPPER.convertValue(fetcherConfigs.get(fetcherEntry.getKey()), + new TypeReference<>() { }); Element fetcher = tikaConfigDoc.createElement("fetcher"); fetcher.setAttribute("class", fetcherEntry.getValue().getClass().getName()); @@ -230,7 +231,8 @@ public void getFetcher(GetFetcherRequest request, Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { }); - paramMap.forEach((k, v) -> getFetcherReply.putParams(Objects.toString(k), Objects.toString(v))); + paramMap.forEach( + (k, v) -> getFetcherReply.putParams(Objects.toString(k), Objects.toString(v))); responseObserver.onNext(getFetcherReply.build()); responseObserver.onCompleted(); } @@ -247,16 +249,18 @@ public void listFetchers(ListFetchersRequest request, responseObserver.onCompleted(); } - private GetFetcherReply.Builder createFetcherReply(Map.Entry fetcherConfig) { + private GetFetcherReply.Builder createFetcherReply( + Map.Entry fetcherConfig) { AbstractFetcher abstractFetcher = fetchers.get(fetcherConfig.getKey()); AbstractConfig abstractConfig = fetcherConfigs.get(fetcherConfig.getKey()); - GetFetcherReply.Builder replyBuilder = GetFetcherReply.newBuilder() - .setFetcherClass(abstractFetcher.getClass().getName()) - .setName(abstractFetcher.getName()); + GetFetcherReply.Builder replyBuilder = + GetFetcherReply.newBuilder().setFetcherClass(abstractFetcher.getClass().getName()) + .setName(abstractFetcher.getName()); Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { }); - paramMap.forEach((k, v) -> replyBuilder.putParams(Objects.toString(k), Objects.toString(v))); + paramMap.forEach( + (k, v) -> replyBuilder.putParams(Objects.toString(k), Objects.toString(v))); return replyBuilder; } @@ -275,4 +279,4 @@ private void deleteFetcher(String name) { fetcherConfigs.remove(name); fetchers.remove(name); } -} \ No newline at end of file +} diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index c23c678d42..3095a0779f 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -32,7 +32,7 @@ service Tika { message CreateFetcherRequest { string name = 1; string fetcherClass = 2; - map params = 3; + map params = 3; } message CreateFetcherReply { @@ -42,7 +42,7 @@ message CreateFetcherReply { message UpdateFetcherRequest { string name = 1; string fetcherClass = 2; - map params = 3; + map params = 3; } message UpdateFetcherReply { @@ -52,12 +52,12 @@ message UpdateFetcherReply { message FetchAndParseRequest { string fetcherName = 1; string fetchKey = 2; - map metadata = 3; + map metadata = 3; } message FetchAndParseReply { string fetchKey = 1; - map fields = 2; + map fields = 2; } message DeleteFetcherRequest { @@ -75,7 +75,7 @@ message GetFetcherRequest { message GetFetcherReply { string name = 1; string fetcherClass = 2; - map params = 3; + map params = 3; } message ListFetchersRequest { @@ -85,4 +85,4 @@ message ListFetchersRequest { message ListFetchersReply { repeated GetFetcherReply getFetcherReply = 1; -} \ No newline at end of file +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java index 3326540020..2cfc958bdb 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java @@ -35,78 +35,75 @@ import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; public class TikaClient { - private static final Logger logger = Logger.getLogger(TikaClient.class.getName()); + private static final Logger logger = Logger.getLogger(TikaClient.class.getName()); - private final TikaGrpc.TikaBlockingStub blockingStub; + private final TikaGrpc.TikaBlockingStub blockingStub; - public TikaClient(Channel channel) { - // 'channel' here is a Channel, not a ManagedChannel, so it is not this code's responsibility to - // shut it down. + public TikaClient(Channel channel) { + // 'channel' here is a Channel, not a ManagedChannel, so it is not this code's responsibility to + // shut it down. - // Passing Channels to code makes code easier to test and makes it easier to reuse Channels. - blockingStub = TikaGrpc.newBlockingStub(channel); - } - - public void createFetcher(CreateFetcherRequest createFileSystemFetcherRequest) { - CreateFetcherReply response; - try { - response = blockingStub.createFetcher(createFileSystemFetcherRequest); - } catch (StatusRuntimeException e) { - logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); - return; + // Passing Channels to code makes code easier to test and makes it easier to reuse Channels. + blockingStub = TikaGrpc.newBlockingStub(channel); } - logger.info("Create fetcher: " + response.getMessage()); - } - public void fetchAndParse(FetchAndParseRequest fetchAndParseRequest) { - FetchAndParseReply fetchReply; - try { - fetchReply = blockingStub.fetchAndParse(fetchAndParseRequest); - } catch (StatusRuntimeException e) { - logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); - return; + public void createFetcher(CreateFetcherRequest createFileSystemFetcherRequest) { + CreateFetcherReply response; + try { + response = blockingStub.createFetcher(createFileSystemFetcherRequest); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return; + } + logger.info("Create fetcher: " + response.getMessage()); } - logger.info("Fetch reply - tika parsed metadata: " + fetchReply.getFieldsMap()); - } - public static void main(String[] args) throws Exception { - if (args.length != 1) { - System.err.println("Expects one command line argument for the base path to use for the crawl."); - System.exit(1); - return; + public void fetchAndParse(FetchAndParseRequest fetchAndParseRequest) { + FetchAndParseReply fetchReply; + try { + fetchReply = blockingStub.fetchAndParse(fetchAndParseRequest); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return; + } + logger.info("Fetch reply - tika parsed metadata: " + fetchReply.getFieldsMap()); } - String crawlPath = args[0]; - String target = "localhost:50051"; - // Create a communication channel to the server, known as a Channel. Channels are thread-safe - // and reusable. It is common to create channels at the beginning of your application and reuse - // them until the application shuts down. - // - // For the example we use plaintext insecure credentials to avoid needing TLS certificates. To - // use TLS, use TlsChannelCredentials instead. - ManagedChannel channel = Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()) - .build(); - try { - TikaClient client = new TikaClient(channel); - String fetcherId = "file-system-fetcher-" + UUID.randomUUID(); - client.createFetcher(CreateFetcherRequest.newBuilder() - .setName(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", crawlPath) - .putParams("extractFileSystemMetadata", "true") - .build()); + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println( + "Expects one command line argument for the base path to use for the crawl."); + System.exit(1); + return; + } + String crawlPath = args[0]; + String target = "localhost:50051"; + // Create a communication channel to the server, known as a Channel. Channels are thread-safe + // and reusable. It is common to create channels at the beginning of your application and reuse + // them until the application shuts down. + // + // For the example we use plaintext insecure credentials to avoid needing TLS certificates. To + // use TLS, use TlsChannelCredentials instead. + ManagedChannel channel = + Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()).build(); + try { + TikaClient client = new TikaClient(channel); + String fetcherId = "file-system-fetcher-" + UUID.randomUUID(); + + client.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", crawlPath).putParams("extractFileSystemMetadata", "true") + .build()); - client.fetchAndParse(FetchAndParseRequest.newBuilder() - .setFetcherName(fetcherId) - .setFetchKey("000164.pdf") - .build()); + client.fetchAndParse(FetchAndParseRequest.newBuilder().setFetcherName(fetcherId) + .setFetchKey("000164.pdf").build()); - } finally { - // ManagedChannels use resources like threads and TCP connections. To prevent leaking these - // resources the channel should be shut down when it will no longer be used. If it may be used - // again leave it running. - channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } finally { + // ManagedChannels use resources like threads and TCP connections. To prevent leaking these + // resources the channel should be shut down when it will no longer be used. If it may be used + // again leave it running. + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } } - } } From c7cae5fa7a40bc58a9b2616aa5989d9587b072cf Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 03:34:59 -0500 Subject: [PATCH 04/89] fix the issues with deps and such --- .../org/apache/tika/pipes/PipesConfig.java | 1 - .../pipes/fetcher/config/AbstractConfig.java | 17 +++ .../fs/config/FileSystemFetcherConfig.java | 16 +++ .../apache/tika/pipes/PipesClientTest.java | 37 +++-- .../azblob/config/AZBlobFetcherConfig.java | 16 +++ .../fetcher/gcs/config/GCSFetcherConfig.java | 16 +++ .../http/config/HttpFetcherConfig.java | 16 +++ .../fetcher/s3/config/S3FetcherConfig.java | 16 +++ tika-pipes/tika-grpc/pom.xml | 126 +++++++++++++----- .../tika/pipes/grpc/TikaGrpcServer.java | 12 +- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 16 +++ .../apache/tika/pipes/grpc/TikaClient.java | 1 - .../tika/pipes/grpc/TikaGrpcServerTest.java | 48 ++++--- tika-pipes/tika-grpc/tika-config.xml | 6 +- 14 files changed, 262 insertions(+), 82 deletions(-) diff --git a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java index b0e8649f90..132e657a74 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfig.java @@ -25,7 +25,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.apache.tika.config.TikaConfig; import org.apache.tika.exception.TikaConfigException; public class PipesConfig extends PipesConfigBase { diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java index 536fc44b10..1d0fa8e48e 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/config/AbstractConfig.java @@ -1,4 +1,21 @@ +/* + * 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.tika.pipes.fetcher.config; public abstract class AbstractConfig { + // Nothing to do here yet. } diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java index aa02cccae1..b9f155fbd7 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/fs/config/FileSystemFetcherConfig.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.fs.config; import org.apache.tika.pipes.fetcher.config.AbstractConfig; diff --git a/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java index 46c475546d..464e9acbae 100644 --- a/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java +++ b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java @@ -1,49 +1,60 @@ +/* + * 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.tika.pipes; -import static org.junit.jupiter.api.Assertions.*; - import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.w3c.dom.Document; import org.xml.sax.SAXException; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.metadata.Metadata; import org.apache.tika.pipes.emitter.EmitKey; import org.apache.tika.pipes.fetcher.FetchKey; -import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; class PipesClientTest { String fetcherName = "fs"; String testPdfFile = "testOverlappingText.pdf"; private PipesClient pipesClient; + @BeforeEach public void init() throws TikaConfigException, IOException, ParserConfigurationException, SAXException { - Path tikaConfigPath = Paths.get("src", "test", "resources", "org", "apache", "tika", - "pipes", "tika-sample-config.xml"); + Path tikaConfigPath = + Paths.get("src", "test", "resources", "org", "apache", "tika", "pipes", + "tika-sample-config.xml"); PipesConfig pipesConfig = PipesConfig.load(tikaConfigPath); pipesClient = new PipesClient(pipesConfig); } @Test void process() throws IOException, InterruptedException { - PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(testPdfFile, - new FetchKey(fetcherName, - testPdfFile), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + PipesResult pipesResult = pipesClient.process( + new FetchEmitTuple(testPdfFile, new FetchKey(fetcherName, testPdfFile), + new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); Assertions.assertNotNull(pipesResult.getEmitData().getMetadataList()); Assertions.assertEquals(1, pipesResult.getEmitData().getMetadataList().size()); Metadata metadata = pipesResult.getEmitData().getMetadataList().get(0); Assertions.assertEquals("testOverlappingText.pdf", metadata.get("resourceName")); } -} \ No newline at end of file +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java index 5e64d85d08..2bfe61fa79 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-az-blob/src/main/java/org/apache/tika/pipes/fetcher/azblob/config/AZBlobFetcherConfig.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.azblob.config; import org.apache.tika.pipes.fetcher.config.AbstractConfig; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java index 9988ceedd3..a8dad6417d 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-gcs/src/main/java/org/apache/tika/pipes/fetcher/gcs/config/GCSFetcherConfig.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.gcs.config; import org.apache.tika.pipes.fetcher.config.AbstractConfig; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java index 1372f1355e..2683e7f552 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.config; import java.util.List; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java index 8a91691e2e..84a335a2bd 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-s3/src/main/java/org/apache/tika/pipes/fetcher/s3/config/S3FetcherConfig.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.s3.config; import org.apache.tika.pipes.fetcher.config.AbstractConfig; diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 3daaf7a3e0..bbda4f37c5 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -17,9 +17,10 @@ UTF-8 - 1.60.0 + 1.60.0 3.24.0 3.24.0 + 1.2.2 11 @@ -40,53 +41,120 @@ io.grpc grpc-netty-shaded runtime + + + com.google.guava + guava + + + com.google.errorprone + error_prone_annotations + + io.grpc grpc-protobuf + + + com.google.guava + guava + + + com.google.errorprone + error_prone_annotations + + io.grpc grpc-services + + + com.google.guava + guava + + + com.google.errorprone + error_prone_annotations + + io.grpc grpc-stub + + + com.google.guava + guava + + + com.google.errorprone + error_prone_annotations + + com.google.protobuf protobuf-java-util ${protobuf.version} + + + com.google.guava + guava + + + com.google.errorprone + error_prone_annotations + + com.google.code.gson gson 2.10.1 - - com.google.guava - guava - 32.0.1-jre - com.google.j2objc j2objc-annotations 2.8 + + com.google.errorprone + error_prone_annotations + 2.20.0 + + + com.google.guava + guava + + + org.apache.tika tika-async-cli - 3.0.0-SNAPSHOT + ${project.version} org.apache.tika tika-parsers-standard-package - 3.0.0-SNAPSHOT + ${project.version} org.apache.tika tika-core - 3.0.0-SNAPSHOT + ${project.version} + + + com.google.guava + guava + 32.0.1-jre + + + com.google.errorprone + error_prone_annotations + + org.apache.tomcat @@ -94,17 +162,6 @@ 6.0.53 provided - - io.grpc - grpc-testing - test - - - junit - junit - 4.13.2 - test - org.mockito mockito-core @@ -112,31 +169,28 @@ test - org.apache.tika - tika-fetcher-http - 3.0.0-SNAPSHOT - test - - - org.apache.tika - tika-fetcher-gcs - 3.0.0-SNAPSHOT + io.grpc + grpc-testing test + + + com.google.guava + guava + + org.apache.tika - tika-fetcher-az-blob - 3.0.0-SNAPSHOT + tika-fetcher-http + ${project.version} test - org.apache.tika - tika-fetcher-s3 - 3.0.0-SNAPSHOT - test + com.asarkar.grpc + grpc-test + ${asarkar-grpc-test.version} - diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 26493a065e..1e3ac3d722 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -1,9 +1,10 @@ /* - * Copyright 2015 The gRPC Authors - * - * Licensed 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 + * 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 * @@ -13,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.apache.tika.pipes.grpc; import java.util.concurrent.TimeUnit; diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 3098354d88..f162e235b2 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.grpc; import java.io.FileWriter; diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java index 2cfc958bdb..d74c7d23b3 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.apache.tika.pipes.grpc; import java.util.UUID; diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index b657fad9ca..165185ab99 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -13,47 +13,53 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.apache.tika.pipes.grpc; import static org.junit.Assert.assertEquals; +import java.io.File; +import java.time.Duration; + +import com.asarkar.grpc.test.GrpcCleanupExtension; +import com.asarkar.grpc.test.Resources; +import io.grpc.ManagedChannel; +import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; -import io.grpc.testing.GrpcCleanupRule; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.apache.tika.CreateFetcherReply; import org.apache.tika.CreateFetcherRequest; import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; -@RunWith(JUnit4.class) +@ExtendWith(GrpcCleanupExtension.class) public class TikaGrpcServerTest { - @Rule - public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); @Test - public void greeterImpl_replyMessage() throws Exception { + public void testTikaCreateFetcher(Resources resources) throws Exception { // Generate a unique in-process server name. String serverName = InProcessServerBuilder.generateName(); // Create a server, add service, start, and register for automatic graceful shutdown. - grpcCleanup.register(InProcessServerBuilder.forName(serverName).directExecutor() - .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start()); - - TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub( - // Create a client channel and register for automatic graceful shutdown. - grpcCleanup.register( - InProcessChannelBuilder.forName(serverName).directExecutor().build())); + Server server = InProcessServerBuilder + .forName(serverName) + .directExecutor() + .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start(); + resources.register(server, Duration.ofSeconds(10)); + ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + resources.register(channel, Duration.ofSeconds(10)); + TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); - String testName = "test name"; - CreateFetcherReply reply = blockingStub.createFetcher( - CreateFetcherRequest.newBuilder().setName(testName).build()); + String fetcherId = "fetcherIdHere"; + String targetFolder = new File("target").getAbsolutePath(); + CreateFetcherReply reply = blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") + .build()); - assertEquals(testName, reply.getMessage()); + assertEquals(fetcherId, reply.getMessage()); } } diff --git a/tika-pipes/tika-grpc/tika-config.xml b/tika-pipes/tika-grpc/tika-config.xml index 19a03c9223..b90848f062 100644 --- a/tika-pipes/tika-grpc/tika-config.xml +++ b/tika-pipes/tika-grpc/tika-config.xml @@ -13,8 +13,7 @@ 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. ---> - +--> 2 @@ -26,6 +25,5 @@ -1 - - + fetcherIdHere/home/ndipiazza/atolio/tika/tika-pipes/tika-grpc/targettrue \ No newline at end of file From d57a5574a091a8488e3671dedeab7a2bb2361a7f Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 04:57:29 -0500 Subject: [PATCH 05/89] add bidi streaming --- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 120 +++++++++++------- .../tika-grpc/src/main/proto/tika.proto | 2 + .../apache/tika/pipes/grpc/TikaClient.java | 25 +++- .../tika/pipes/grpc/TikaGrpcServerTest.java | 104 ++++++++++++++- .../tika-grpc/src/test/resources/log4j2.xml | 32 +++++ 5 files changed, 225 insertions(+), 58 deletions(-) create mode 100644 tika-pipes/tika-grpc/src/test/resources/log4j2.xml diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index f162e235b2..c8e567460e 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -36,6 +36,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.grpc.stub.StreamObserver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.xml.sax.SAXException; @@ -55,6 +57,7 @@ import org.apache.tika.UpdateFetcherRequest; import org.apache.tika.config.Initializable; import org.apache.tika.config.Param; +import org.apache.tika.config.TikaConfigSerializer; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.metadata.Metadata; import org.apache.tika.pipes.FetchEmitTuple; @@ -67,6 +70,7 @@ import org.apache.tika.pipes.fetcher.config.AbstractConfig; class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { + private static final Logger LOG = LoggerFactory.getLogger(TikaConfigSerializer.class); public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** * FetcherID is key, The pair is the Fetcher object and the Metadata @@ -125,6 +129,74 @@ private void populateFetcherConfigs(Map fetcherConfigParams, } } + @Override + public void fetchAndParseServerSideStreaming(FetchAndParseRequest request, + StreamObserver responseObserver) { + fetchAndParseImpl(request, responseObserver); + } + + @Override + public StreamObserver fetchAndParseBiDirectionalStreaming( + StreamObserver responseObserver) { + return new StreamObserver<>() { + @Override + public void onNext(FetchAndParseRequest fetchAndParseRequest) { + fetchAndParseImpl(fetchAndParseRequest, responseObserver); + } + + @Override + public void onError(Throwable throwable) { + LOG.error("Parse error occurred", throwable); + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + + @Override + public void fetchAndParse(FetchAndParseRequest request, + StreamObserver responseObserver) { + fetchAndParseImpl(request, responseObserver); + responseObserver.onCompleted(); + } + + + private void fetchAndParseImpl(FetchAndParseRequest request, + StreamObserver responseObserver) { + AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); + if (fetcher == null) { + throw new RuntimeException( + "Could not find fetcher with name " + request.getFetcherName()); + } + Metadata tikaMetadata = new Metadata(); + for (Map.Entry entry : request.getMetadataMap().entrySet()) { + tikaMetadata.add(entry.getKey(), entry.getValue()); + } + try { + PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), + FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { + FetchAndParseReply.Builder fetchReplyBuilder = + FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); + for (String name : metadata.names()) { + String value = metadata.get(name); + if (value != null) { + fetchReplyBuilder.putFields(name, value); + } + } + responseObserver.onNext(fetchReplyBuilder.build()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + @SuppressWarnings("raw") @Override public void createFetcher(CreateFetcherRequest request, @@ -163,17 +235,8 @@ private void createFetcher(String name, String fetcherClassName, Map createTikaParamMap(Map paramsM return tikaParamsMap; } - @Override - public void fetchAndParse(FetchAndParseRequest request, - StreamObserver responseObserver) { - AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); - if (fetcher == null) { - throw new RuntimeException( - "Could not find fetcher with name " + request.getFetcherName()); - } - Metadata tikaMetadata = new Metadata(); - for (Map.Entry entry : request.getMetadataMap().entrySet()) { - tikaMetadata.add(entry.getKey(), entry.getValue()); - } - try { - PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), - FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); - for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { - FetchAndParseReply.Builder fetchReplyBuilder = - FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); - for (String name : metadata.names()) { - String value = metadata.get(name); - if (value != null) { - fetchReplyBuilder.putFields(name, value); - } - } - responseObserver.onNext(fetchReplyBuilder.build()); - } - responseObserver.onCompleted(); - } catch (IOException e) { - throw new RuntimeException(e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - @Override public void updateFetcher(UpdateFetcherRequest request, StreamObserver responseObserver) { diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 3095a0779f..3c25ec7ddf 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -27,6 +27,8 @@ service Tika { rpc ListFetchers(ListFetchersRequest) returns (ListFetchersReply) {} rpc DeleteFetcher(DeleteFetcherRequest) returns (DeleteFetcherReply) {} rpc FetchAndParse(FetchAndParseRequest) returns (FetchAndParseReply) {} + rpc FetchAndParseServerSideStreaming(FetchAndParseRequest) returns (stream FetchAndParseReply) {} + rpc FetchAndParseBiDirectionalStreaming(stream FetchAndParseRequest) returns (stream FetchAndParseReply) {} } message CreateFetcherRequest { diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java index d74c7d23b3..32ad81326e 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java @@ -1,9 +1,10 @@ /* - * Copyright 2015 The gRPC Authors - * - * Licensed 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 + * 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 * @@ -15,6 +16,7 @@ */ package org.apache.tika.pipes.grpc; +import java.util.Iterator; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -68,6 +70,19 @@ public void fetchAndParse(FetchAndParseRequest fetchAndParseRequest) { logger.info("Fetch reply - tika parsed metadata: " + fetchReply.getFieldsMap()); } + public void fetchAndParseServerSideStreaming(FetchAndParseRequest fetchAndParseRequest) { + Iterator fetchReply; + try { + fetchReply = blockingStub.fetchAndParseServerSideStreaming(fetchAndParseRequest); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return; + } + while (fetchReply.hasNext()) { + logger.info("Fetch reply - tika parsed metadata: " + fetchReply.next()); + } + } + public static void main(String[] args) throws Exception { if (args.length != 1) { System.err.println( diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 165185ab99..82f7aa038e 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -1,9 +1,10 @@ /* - * Copyright 2016 The gRPC Authors - * - * Licensed 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 + * 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 * @@ -15,10 +16,19 @@ */ package org.apache.tika.pipes.grpc; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; import com.asarkar.grpc.test.GrpcCleanupExtension; import com.asarkar.grpc.test.Resources; @@ -26,16 +36,25 @@ import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.apache.tika.CreateFetcherReply; import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.FetchAndParseReply; +import org.apache.tika.FetchAndParseRequest; import org.apache.tika.TikaGrpc; import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; @ExtendWith(GrpcCleanupExtension.class) public class TikaGrpcServerTest { + private static final Logger LOG = LoggerFactory.getLogger(TikaGrpcServerTest.class); + public static final int NUM_TEST_DOCS = 50; @Test public void testTikaCreateFetcher(Resources resources) throws Exception { @@ -60,6 +79,77 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") .build()); - assertEquals(fetcherId, reply.getMessage()); + Assertions.assertEquals(fetcherId, reply.getMessage()); + } + + @Test + public void testBiStream(Resources resources) throws Exception { + // Generate a unique in-process server name. + String serverName = InProcessServerBuilder.generateName(); + + // Create a server, add service, start, and register for automatic graceful shutdown. + Server server = InProcessServerBuilder + .forName(serverName) + .directExecutor() + .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start(); + resources.register(server, Duration.ofSeconds(10)); + + ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + resources.register(channel, Duration.ofSeconds(10)); + TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); + TikaGrpc.TikaStub tikaStub = TikaGrpc.newStub(channel); + + String fetcherId = "fetcherIdHere"; + String targetFolder = new File("target").getAbsolutePath(); + CreateFetcherReply reply = blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") + .build()); + + Assertions.assertEquals(fetcherId, reply.getMessage()); + + List fetchAndParseReplys = + Collections.synchronizedList(new ArrayList<>()); + + StreamObserver replyStreamObserver = new StreamObserver<>() { + @Override + public void onNext(FetchAndParseReply fetchAndParseReply) { + LOG.info("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), + fetchAndParseReply.getFieldsMap()); + fetchAndParseReplys.add(fetchAndParseReply); + } + + @Override + public void onError(Throwable throwable) { + LOG.error("Fetched error found", throwable); + } + + @Override + public void onCompleted() { + LOG.info("Stream completed"); + } + }; + + StreamObserver requestStreamObserver = + tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); + + File testDocumentFolder = new File("target/" + DateTimeFormatter.ofPattern( + "yyyy_mm_dd_HH_MM_ssSSS").format(LocalDateTime.now()) + "-" + UUID.randomUUID()); + assertTrue(testDocumentFolder.mkdir()); + for (int i = 0; i < NUM_TEST_DOCS; ++i) { + File testFile = new File(testDocumentFolder, "test-" + i + ".html"); + FileUtils.writeStringToFile(testFile, "test " + i + "", StandardCharsets.UTF_8); + } + File[] testDocuments = testDocumentFolder.listFiles(); + assertNotNull(testDocuments); + for (File testDocument : testDocuments) { + requestStreamObserver.onNext(FetchAndParseRequest.newBuilder() + .setFetcherName(fetcherId) + .setFetchKey(testDocument.getAbsolutePath()) + .build()); + } + requestStreamObserver.onCompleted(); + + assertEquals(NUM_TEST_DOCS, fetchAndParseReplys.size()); } } diff --git a/tika-pipes/tika-grpc/src/test/resources/log4j2.xml b/tika-pipes/tika-grpc/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..c88e66e99e --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/log4j2.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file From f80844ee619533bc3248da82e309efdf68ba9684 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 05:10:00 -0500 Subject: [PATCH 06/89] clean up wording --- .../org/apache/tika/pipes/grpc/TikaGrpcServerTest.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 82f7aa038e..d0692ce6ac 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -58,10 +58,8 @@ public class TikaGrpcServerTest { @Test public void testTikaCreateFetcher(Resources resources) throws Exception { - // Generate a unique in-process server name. String serverName = InProcessServerBuilder.generateName(); - // Create a server, add service, start, and register for automatic graceful shutdown. Server server = InProcessServerBuilder .forName(serverName) .directExecutor() @@ -84,10 +82,8 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { @Test public void testBiStream(Resources resources) throws Exception { - // Generate a unique in-process server name. String serverName = InProcessServerBuilder.generateName(); - // Create a server, add service, start, and register for automatic graceful shutdown. Server server = InProcessServerBuilder .forName(serverName) .directExecutor() @@ -114,7 +110,7 @@ public void testBiStream(Resources resources) throws Exception { StreamObserver replyStreamObserver = new StreamObserver<>() { @Override public void onNext(FetchAndParseReply fetchAndParseReply) { - LOG.info("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), + LOG.debug("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), fetchAndParseReply.getFieldsMap()); fetchAndParseReplys.add(fetchAndParseReply); } @@ -134,7 +130,7 @@ public void onCompleted() { tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); File testDocumentFolder = new File("target/" + DateTimeFormatter.ofPattern( - "yyyy_mm_dd_HH_MM_ssSSS").format(LocalDateTime.now()) + "-" + UUID.randomUUID()); + "yyyy_MM_dd_HH_mm_ssSSS").format(LocalDateTime.now()) + "-" + UUID.randomUUID()); assertTrue(testDocumentFolder.mkdir()); for (int i = 0; i < NUM_TEST_DOCS; ++i) { File testFile = new File(testDocumentFolder, "test-" + i + ".html"); From ce8c887ba0db04524158da94974e9d17f5ea82c3 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 06:32:57 -0500 Subject: [PATCH 07/89] make it as a stale connection expiring store --- .../apache/tika/pipes/PipesConfigBase.java | 21 ++++- .../tika/pipes/grpc/ExpiringFetcherStore.java | 81 +++++++++++++++++++ .../tika/pipes/grpc/TikaGrpcServerImpl.java | 30 +++---- .../pipes/grpc/ExpiringFetcherStoreTest.java | 41 ++++++++++ 4 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java create mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java diff --git a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfigBase.java b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfigBase.java index bf6a6bb696..27e6a49fbc 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/PipesConfigBase.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/PipesConfigBase.java @@ -54,7 +54,10 @@ public class PipesConfigBase extends ConfigBase { private int numClients = DEFAULT_NUM_CLIENTS; private int maxFilesProcessedPerProcess = DEFAULT_MAX_FILES_PROCESSED_PER_PROCESS; - + public static final int DEFAULT_STALE_FETCHER_TIMEOUT_SECONDS = 600; + private int staleFetcherTimeoutSeconds = DEFAULT_STALE_FETCHER_TIMEOUT_SECONDS; + public static final int DEFAULT_STALE_FETCHER_DELAY_SECONDS = 60; + private int staleFetcherDelaySeconds = DEFAULT_STALE_FETCHER_DELAY_SECONDS; private List forkedJvmArgs = new ArrayList<>(); private Path tikaConfig; private String javaPath = "java"; @@ -171,4 +174,20 @@ public long getSleepOnStartupTimeoutMillis() { public void setSleepOnStartupTimeoutMillis(long sleepOnStartupTimeoutMillis) { this.sleepOnStartupTimeoutMillis = sleepOnStartupTimeoutMillis; } + + public int getStaleFetcherTimeoutSeconds() { + return staleFetcherTimeoutSeconds; + } + + public void setStaleFetcherTimeoutSeconds(int staleFetcherTimeoutSeconds) { + this.staleFetcherTimeoutSeconds = staleFetcherTimeoutSeconds; + } + + public int getStaleFetcherDelaySeconds() { + return staleFetcherDelaySeconds; + } + + public void setStaleFetcherDelaySeconds(int staleFetcherDelaySeconds) { + this.staleFetcherDelaySeconds = staleFetcherDelaySeconds; + } } diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java new file mode 100644 index 0000000000..e7c67e3d9b --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java @@ -0,0 +1,81 @@ +package org.apache.tika.pipes.grpc; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class ExpiringFetcherStore { + private static final Logger LOG = LoggerFactory.getLogger(ExpiringFetcherStore.class); + public static final long EXPIRE_JOB_INITIAL_DELAY = 1L; + public static final long EXPIRE_JOB_PERIOD = 60L; + + private final Map fetchers = Collections.synchronizedMap(new HashMap<>()); + private final Map fetcherConfigs = Collections.synchronizedMap(new HashMap<>()); + private final Map fetcherLastAccessed = + Collections.synchronizedMap(new HashMap<>()); + + ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + + public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersDelaySeconds) { + executorService.scheduleAtFixedRate(() -> { + Set expired = new HashSet<>(); + for (String fetcherName : fetchers.keySet()) { + Instant lastAccessed = fetcherLastAccessed.get(fetcherName); + if (lastAccessed == null) { + LOG.error("Detected a fetcher with no last access time. FetcherName={}", + fetcherName); + expired.add(fetcherName); + } else if (Instant.now().isAfter(lastAccessed.plusSeconds(expireAfterSeconds))) { + LOG.info("Detected stale fetcher {} hasn't been access in {} seconds. " + + "Deleting.", + fetcherName, Instant.now().getEpochSecond() - lastAccessed.getEpochSecond()); + expired.add(fetcherName); + } + } + for (String expiredFetcherId : expired) { + deleteFetcher(expiredFetcherId); + } + }, EXPIRE_JOB_INITIAL_DELAY, checkForExpiredFetchersDelaySeconds, TimeUnit.SECONDS); + } + + public void deleteFetcher(String fetcherName) { + fetchers.remove(fetcherName); + fetcherConfigs.remove(fetcherName); + fetcherLastAccessed.remove(fetcherName); + } + + public Map getFetchers() { + return fetchers; + } + + public Map getFetcherConfigs() { + return fetcherConfigs; + } + + /** + * This method will get the fetcher, but will also log the access the fetcher as having + * been accessed. This prevents the scheduled job from removing the stale fetcher. + */ + public T getFetcherAndLogAccess(String fetcherName) { + fetcherLastAccessed.put(fetcherName, Instant.now()); + return (T) fetchers.get(fetcherName); + } + + public void createFetcher(T fetcher, C config) { + fetchers.put(fetcher.getName(), fetcher); + fetcherConfigs.put(fetcher.getName(), config); + getFetcherAndLogAccess(fetcher.getName()); + } +} diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index c8e567460e..dc1b0cba2a 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -21,7 +21,6 @@ import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -75,16 +74,18 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { /** * FetcherID is key, The pair is the Fetcher object and the Metadata */ - Map fetchers = Collections.synchronizedMap(new HashMap<>()); - Map fetcherConfigs = Collections.synchronizedMap(new HashMap<>()); PipesConfig pipesConfig = PipesConfig.load(Paths.get("tika-config.xml")); PipesClient pipesClient = new PipesClient(pipesConfig); + ExpiringFetcherStore expiringFetcherStore; String tikaConfigPath; TikaGrpcServerImpl(String tikaConfigPath) throws TikaConfigException, IOException, ParserConfigurationException, TransformerException, SAXException { + expiringFetcherStore = + new ExpiringFetcherStore(pipesConfig.getStaleFetcherTimeoutSeconds(), + pipesConfig.getStaleFetcherDelaySeconds()); this.tikaConfigPath = tikaConfigPath; updateTikaConfig(); } @@ -93,14 +94,15 @@ private void updateTikaConfig() throws ParserConfigurationException, IOException, SAXException, TransformerException { Document tikaConfigDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(tikaConfigPath); + Element fetchersElement = (Element) tikaConfigDoc.getElementsByTagName("fetchers").item(0); for (int i = 0; i < fetchersElement.getChildNodes().getLength(); ++i) { fetchersElement.removeChild(fetchersElement.getChildNodes().item(i)); } - for (var fetcherEntry : fetchers.entrySet()) { + for (var fetcherEntry : expiringFetcherStore.getFetchers().entrySet()) { AbstractFetcher fetcherObject = fetcherEntry.getValue(); Map fetcherConfigParams = - OBJECT_MAPPER.convertValue(fetcherConfigs.get(fetcherEntry.getKey()), + OBJECT_MAPPER.convertValue(expiringFetcherStore.getFetcherConfigs().get(fetcherEntry.getKey()), new TypeReference<>() { }); Element fetcher = tikaConfigDoc.createElement("fetcher"); @@ -166,7 +168,7 @@ public void fetchAndParse(FetchAndParseRequest request, private void fetchAndParseImpl(FetchAndParseRequest request, StreamObserver responseObserver) { - AbstractFetcher fetcher = fetchers.get(request.getFetcherName()); + AbstractFetcher fetcher = expiringFetcherStore.getFetcherAndLogAccess(request.getFetcherName()); if (fetcher == null) { throw new RuntimeException( "Could not find fetcher with name " + request.getFetcherName()); @@ -233,8 +235,7 @@ private void createFetcher(String name, String fetcherClassName, Map responseObserver) { GetFetcherReply.Builder getFetcherReply = GetFetcherReply.newBuilder(); - AbstractConfig abstractConfig = fetcherConfigs.get(request.getName()); + AbstractConfig abstractConfig = expiringFetcherStore.getFetcherConfigs().get(request.getName()); Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { }); @@ -285,7 +286,7 @@ public void getFetcher(GetFetcherRequest request, public void listFetchers(ListFetchersRequest request, StreamObserver responseObserver) { ListFetchersReply.Builder listFetchersReplyBuilder = ListFetchersReply.newBuilder(); - for (Map.Entry fetcherConfig : fetcherConfigs.entrySet()) { + for (Map.Entry fetcherConfig : expiringFetcherStore.getFetcherConfigs().entrySet()) { GetFetcherReply.Builder replyBuilder = createFetcherReply(fetcherConfig); listFetchersReplyBuilder.addGetFetcherReply(replyBuilder.build()); } @@ -295,8 +296,8 @@ public void listFetchers(ListFetchersRequest request, private GetFetcherReply.Builder createFetcherReply( Map.Entry fetcherConfig) { - AbstractFetcher abstractFetcher = fetchers.get(fetcherConfig.getKey()); - AbstractConfig abstractConfig = fetcherConfigs.get(fetcherConfig.getKey()); + AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(fetcherConfig.getKey()); + AbstractConfig abstractConfig = expiringFetcherStore.getFetcherConfigs().get(fetcherConfig.getKey()); GetFetcherReply.Builder replyBuilder = GetFetcherReply.newBuilder().setFetcherClass(abstractFetcher.getClass().getName()) .setName(abstractFetcher.getName()); @@ -319,8 +320,7 @@ public void deleteFetcher(DeleteFetcherRequest request, } } - private void deleteFetcher(String name) { - fetcherConfigs.remove(name); - fetchers.remove(name); + private void deleteFetcher(String fetcherName) { + expiringFetcherStore.deleteFetcher(fetcherName); } } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java new file mode 100644 index 0000000000..f407b77cb8 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java @@ -0,0 +1,41 @@ +package org.apache.tika.pipes.grpc; + +import java.io.InputStream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +class ExpiringFetcherStoreTest { + + @Test + void createFetcher() { + ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 60); + + AbstractFetcher fetcher = new AbstractFetcher() { + @Override + public InputStream fetch(String fetchKey, Metadata metadata) { + return null; + } + }; + fetcher.setName("nick"); + AbstractConfig config = new AbstractConfig() { + + }; + expiringFetcherStore.createFetcher(fetcher, config); + + Assertions.assertNotNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); + + try { + Thread.sleep(2000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + Assertions.assertNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); + Assertions.assertNull(expiringFetcherStore.getFetcherConfigs().get(fetcher.getName())); + } +} From cc144e164a2a62e091857b09928d34686dda9f40 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 06:36:02 -0500 Subject: [PATCH 08/89] clean --- tika-pipes/tika-grpc/tika-config.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tika-pipes/tika-grpc/tika-config.xml b/tika-pipes/tika-grpc/tika-config.xml index b90848f062..07c5c8dd3f 100644 --- a/tika-pipes/tika-grpc/tika-config.xml +++ b/tika-pipes/tika-grpc/tika-config.xml @@ -13,7 +13,8 @@ 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. ---> +--> + 2 @@ -25,5 +26,4 @@ -1 - fetcherIdHere/home/ndipiazza/atolio/tika/tika-pipes/tika-grpc/targettrue - \ No newline at end of file + From 35f803d6c450234aa04b46364dd3ca15440105f7 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 06:43:09 -0500 Subject: [PATCH 09/89] delete dead code --- .../apache/tika/pipes/grpc/TikaClient.java | 123 ------------------ 1 file changed, 123 deletions(-) delete mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java deleted file mode 100644 index 32ad81326e..0000000000 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaClient.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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.tika.pipes.grpc; - -import java.util.Iterator; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; -import java.util.logging.Logger; - -import io.grpc.Channel; -import io.grpc.Grpc; -import io.grpc.InsecureChannelCredentials; -import io.grpc.ManagedChannel; -import io.grpc.StatusRuntimeException; - -import org.apache.tika.CreateFetcherReply; -import org.apache.tika.CreateFetcherRequest; -import org.apache.tika.FetchAndParseReply; -import org.apache.tika.FetchAndParseRequest; -import org.apache.tika.TikaGrpc; -import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; - -public class TikaClient { - private static final Logger logger = Logger.getLogger(TikaClient.class.getName()); - - private final TikaGrpc.TikaBlockingStub blockingStub; - - public TikaClient(Channel channel) { - // 'channel' here is a Channel, not a ManagedChannel, so it is not this code's responsibility to - // shut it down. - - // Passing Channels to code makes code easier to test and makes it easier to reuse Channels. - blockingStub = TikaGrpc.newBlockingStub(channel); - } - - public void createFetcher(CreateFetcherRequest createFileSystemFetcherRequest) { - CreateFetcherReply response; - try { - response = blockingStub.createFetcher(createFileSystemFetcherRequest); - } catch (StatusRuntimeException e) { - logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); - return; - } - logger.info("Create fetcher: " + response.getMessage()); - } - - public void fetchAndParse(FetchAndParseRequest fetchAndParseRequest) { - FetchAndParseReply fetchReply; - try { - fetchReply = blockingStub.fetchAndParse(fetchAndParseRequest); - } catch (StatusRuntimeException e) { - logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); - return; - } - logger.info("Fetch reply - tika parsed metadata: " + fetchReply.getFieldsMap()); - } - - public void fetchAndParseServerSideStreaming(FetchAndParseRequest fetchAndParseRequest) { - Iterator fetchReply; - try { - fetchReply = blockingStub.fetchAndParseServerSideStreaming(fetchAndParseRequest); - } catch (StatusRuntimeException e) { - logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); - return; - } - while (fetchReply.hasNext()) { - logger.info("Fetch reply - tika parsed metadata: " + fetchReply.next()); - } - } - - public static void main(String[] args) throws Exception { - if (args.length != 1) { - System.err.println( - "Expects one command line argument for the base path to use for the crawl."); - System.exit(1); - return; - } - String crawlPath = args[0]; - String target = "localhost:50051"; - // Create a communication channel to the server, known as a Channel. Channels are thread-safe - // and reusable. It is common to create channels at the beginning of your application and reuse - // them until the application shuts down. - // - // For the example we use plaintext insecure credentials to avoid needing TLS certificates. To - // use TLS, use TlsChannelCredentials instead. - ManagedChannel channel = - Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()).build(); - try { - TikaClient client = new TikaClient(channel); - String fetcherId = "file-system-fetcher-" + UUID.randomUUID(); - - client.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", crawlPath).putParams("extractFileSystemMetadata", "true") - .build()); - - client.fetchAndParse(FetchAndParseRequest.newBuilder().setFetcherName(fetcherId) - .setFetchKey("000164.pdf").build()); - - - } finally { - // ManagedChannels use resources like threads and TCP connections. To prevent leaking these - // resources the channel should be shut down when it will no longer be used. If it may be used - // again leave it running. - channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); - } - } -} From 818ad664ea5b80b11e40bd2305d1c5be623b7bd0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 06:47:55 -0500 Subject: [PATCH 10/89] add closeable --- .../apache/tika/pipes/grpc/ExpiringFetcherStore.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java index e7c67e3d9b..eb7e6259ce 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java @@ -16,17 +16,16 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.config.AbstractConfig; -public class ExpiringFetcherStore { +public class ExpiringFetcherStore implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(ExpiringFetcherStore.class); public static final long EXPIRE_JOB_INITIAL_DELAY = 1L; - public static final long EXPIRE_JOB_PERIOD = 60L; - private final Map fetchers = Collections.synchronizedMap(new HashMap<>()); private final Map fetcherConfigs = Collections.synchronizedMap(new HashMap<>()); private final Map fetcherLastAccessed = Collections.synchronizedMap(new HashMap<>()); - ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService executorService = + Executors.newSingleThreadScheduledExecutor(); public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersDelaySeconds) { executorService.scheduleAtFixedRate(() -> { @@ -78,4 +77,9 @@ public void createFetcher( fetcherConfigs.put(fetcher.getName(), config); getFetcherAndLogAccess(fetcher.getName()); } + + @Override + public void close() { + executorService.shutdownNow(); + } } From e35102f68e1cf1332831eb6ee30c7b040c338358 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 06:48:36 -0500 Subject: [PATCH 11/89] add closeable --- .../pipes/grpc/ExpiringFetcherStoreTest.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java index f407b77cb8..a21b025da9 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java @@ -13,29 +13,29 @@ class ExpiringFetcherStoreTest { @Test void createFetcher() { - ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 60); - - AbstractFetcher fetcher = new AbstractFetcher() { - @Override - public InputStream fetch(String fetchKey, Metadata metadata) { - return null; + try (ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 60)) { + AbstractFetcher fetcher = new AbstractFetcher() { + @Override + public InputStream fetch(String fetchKey, Metadata metadata) { + return null; + } + }; + fetcher.setName("nick"); + AbstractConfig config = new AbstractConfig() { + + }; + expiringFetcherStore.createFetcher(fetcher, config); + + Assertions.assertNotNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); + + try { + Thread.sleep(2000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } - }; - fetcher.setName("nick"); - AbstractConfig config = new AbstractConfig() { - - }; - expiringFetcherStore.createFetcher(fetcher, config); - Assertions.assertNotNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); - - try { - Thread.sleep(2000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Assertions.assertNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); + Assertions.assertNull(expiringFetcherStore.getFetcherConfigs().get(fetcher.getName())); } - - Assertions.assertNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); - Assertions.assertNull(expiringFetcherStore.getFetcherConfigs().get(fetcher.getName())); } } From 9ce7aaf14e7fcb218fc60536f4fb585c869939ce Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 29 Mar 2024 07:20:36 -0500 Subject: [PATCH 12/89] add closeable --- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 7 ++- .../tika/pipes/grpc/TikaGrpcServerTest.java | 52 ++++++++++++------- .../resources/tika-pipes-test-config.xml} | 6 +++ 3 files changed, 44 insertions(+), 21 deletions(-) rename tika-pipes/tika-grpc/{tika-config.xml => src/test/resources/tika-pipes-test-config.xml} (87%) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index dc1b0cba2a..44f4cbe467 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -74,8 +74,8 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { /** * FetcherID is key, The pair is the Fetcher object and the Metadata */ - PipesConfig pipesConfig = PipesConfig.load(Paths.get("tika-config.xml")); - PipesClient pipesClient = new PipesClient(pipesConfig); + PipesConfig pipesConfig; + PipesClient pipesClient; ExpiringFetcherStore expiringFetcherStore; String tikaConfigPath; @@ -83,6 +83,9 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { TikaGrpcServerImpl(String tikaConfigPath) throws TikaConfigException, IOException, ParserConfigurationException, TransformerException, SAXException { + pipesConfig = PipesConfig.load(Paths.get(tikaConfigPath)); + pipesClient = new PipesClient(pipesConfig); + expiringFetcherStore = new ExpiringFetcherStore(pipesConfig.getStaleFetcherTimeoutSeconds(), pipesConfig.getStaleFetcherDelaySeconds()); diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index d0692ce6ac..413b658a1c 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -21,7 +21,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -38,7 +40,7 @@ import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.stub.StreamObserver; import org.apache.commons.io.FileUtils; -import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.Logger; @@ -55,6 +57,14 @@ public class TikaGrpcServerTest { private static final Logger LOG = LoggerFactory.getLogger(TikaGrpcServerTest.class); public static final int NUM_TEST_DOCS = 50; + static File tikaConfigXmlTemplate = Paths.get("src", + "test", "resources", "tika-pipes-test-config.xml").toFile(); + static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); + + @BeforeAll + static void init() throws IOException { + FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); + } @Test public void testTikaCreateFetcher(Resources resources) throws Exception { @@ -63,7 +73,7 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { Server server = InProcessServerBuilder .forName(serverName) .directExecutor() - .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start(); + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build().start(); resources.register(server, Duration.ofSeconds(10)); ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); @@ -77,7 +87,7 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") .build()); - Assertions.assertEquals(fetcherId, reply.getMessage()); + assertEquals(fetcherId, reply.getMessage()); } @Test @@ -87,7 +97,7 @@ public void testBiStream(Resources resources) throws Exception { Server server = InProcessServerBuilder .forName(serverName) .directExecutor() - .addService(new TikaGrpcServerImpl("tika-config.xml")).build().start(); + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build().start(); resources.register(server, Duration.ofSeconds(10)); ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); @@ -102,7 +112,7 @@ public void testBiStream(Resources resources) throws Exception { .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") .build()); - Assertions.assertEquals(fetcherId, reply.getMessage()); + assertEquals(fetcherId, reply.getMessage()); List fetchAndParseReplys = Collections.synchronizedList(new ArrayList<>()); @@ -132,20 +142,24 @@ public void onCompleted() { File testDocumentFolder = new File("target/" + DateTimeFormatter.ofPattern( "yyyy_MM_dd_HH_mm_ssSSS").format(LocalDateTime.now()) + "-" + UUID.randomUUID()); assertTrue(testDocumentFolder.mkdir()); - for (int i = 0; i < NUM_TEST_DOCS; ++i) { - File testFile = new File(testDocumentFolder, "test-" + i + ".html"); - FileUtils.writeStringToFile(testFile, "test " + i + "", StandardCharsets.UTF_8); - } - File[] testDocuments = testDocumentFolder.listFiles(); - assertNotNull(testDocuments); - for (File testDocument : testDocuments) { - requestStreamObserver.onNext(FetchAndParseRequest.newBuilder() - .setFetcherName(fetcherId) - .setFetchKey(testDocument.getAbsolutePath()) - .build()); - } - requestStreamObserver.onCompleted(); + try { + for (int i = 0; i < NUM_TEST_DOCS; ++i) { + File testFile = new File(testDocumentFolder, "test-" + i + ".html"); + FileUtils.writeStringToFile(testFile, "test " + i + "", StandardCharsets.UTF_8); + } + File[] testDocuments = testDocumentFolder.listFiles(); + assertNotNull(testDocuments); + for (File testDocument : testDocuments) { + requestStreamObserver.onNext(FetchAndParseRequest.newBuilder() + .setFetcherName(fetcherId) + .setFetchKey(testDocument.getAbsolutePath()) + .build()); + } + requestStreamObserver.onCompleted(); - assertEquals(NUM_TEST_DOCS, fetchAndParseReplys.size()); + assertEquals(NUM_TEST_DOCS, fetchAndParseReplys.size()); + } finally { + FileUtils.deleteDirectory(testDocumentFolder); + } } } diff --git a/tika-pipes/tika-grpc/tika-config.xml b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml similarity index 87% rename from tika-pipes/tika-grpc/tika-config.xml rename to tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml index 07c5c8dd3f..e4006edb35 100644 --- a/tika-pipes/tika-grpc/tika-config.xml +++ b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml @@ -15,6 +15,10 @@ limitations under the License. --> + + 600 + 60 + 2 @@ -26,4 +30,6 @@ -1 + + From 67960791426f99a56c130cd0e81f532c811e6630 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 31 Mar 2024 17:46:13 -0500 Subject: [PATCH 13/89] fixed issues related to proto lint --- tika-pipes/tika-grpc/pom.xml | 4 +- .../tika/pipes/grpc/ExpiringFetcherStore.java | 16 +++++ .../tika/pipes/grpc/TikaGrpcServerImpl.java | 2 +- .../tika-grpc/src/main/proto/tika.proto | 28 +++++---- .../pipes/grpc/ExpiringFetcherStoreTest.java | 16 +++++ .../tika/pipes/grpc/TikaGrpcServerTest.java | 60 ++++++++++--------- 6 files changed, 83 insertions(+), 43 deletions(-) diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index bbda4f37c5..26ca42da20 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -249,8 +249,8 @@ - ${basedir}target/generated-sources/protobuf/grpc-java - ${basedir}target/generated-sources/protobuf/java + ${basedir}/target/generated-sources/protobuf/grpc-java + ${basedir}/target/generated-sources/protobuf/java diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java index eb7e6259ce..9c708f175d 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.grpc; import java.time.Instant; diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 44f4cbe467..9f51770a94 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -291,7 +291,7 @@ public void listFetchers(ListFetchersRequest request, ListFetchersReply.Builder listFetchersReplyBuilder = ListFetchersReply.newBuilder(); for (Map.Entry fetcherConfig : expiringFetcherStore.getFetcherConfigs().entrySet()) { GetFetcherReply.Builder replyBuilder = createFetcherReply(fetcherConfig); - listFetchersReplyBuilder.addGetFetcherReply(replyBuilder.build()); + listFetchersReplyBuilder.addGetFetcherReplies(replyBuilder.build()); } responseObserver.onNext(listFetchersReplyBuilder.build()); responseObserver.onCompleted(); diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 3c25ec7ddf..8e45a829fd 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -12,28 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; +package tika; option java_multiple_files = true; option java_package = "org.apache.tika"; option java_outer_classname = "TikaProto"; option objc_class_prefix = "HLW"; -package tika; -service Tika { +service Tika { rpc CreateFetcher(CreateFetcherRequest) returns (CreateFetcherReply) {} rpc UpdateFetcher(UpdateFetcherRequest) returns (UpdateFetcherReply) {} rpc GetFetcher(GetFetcherRequest) returns (GetFetcherReply) {} rpc ListFetchers(ListFetchersRequest) returns (ListFetchersReply) {} rpc DeleteFetcher(DeleteFetcherRequest) returns (DeleteFetcherReply) {} rpc FetchAndParse(FetchAndParseRequest) returns (FetchAndParseReply) {} - rpc FetchAndParseServerSideStreaming(FetchAndParseRequest) returns (stream FetchAndParseReply) {} - rpc FetchAndParseBiDirectionalStreaming(stream FetchAndParseRequest) returns (stream FetchAndParseReply) {} + rpc FetchAndParseServerSideStreaming(FetchAndParseRequest) + returns (stream FetchAndParseReply) {} + rpc FetchAndParseBiDirectionalStreaming(stream FetchAndParseRequest) + returns (stream FetchAndParseReply) {} } message CreateFetcherRequest { string name = 1; - string fetcherClass = 2; + string fetcher_class = 2; map params = 3; } @@ -43,7 +45,7 @@ message CreateFetcherReply { message UpdateFetcherRequest { string name = 1; - string fetcherClass = 2; + string fetcher_class = 2; map params = 3; } @@ -52,13 +54,13 @@ message UpdateFetcherReply { } message FetchAndParseRequest { - string fetcherName = 1; - string fetchKey = 2; + string fetcher_name = 1; + string fetch_key = 2; map metadata = 3; } message FetchAndParseReply { - string fetchKey = 1; + string fetch_key = 1; map fields = 2; } @@ -76,15 +78,15 @@ message GetFetcherRequest { message GetFetcherReply { string name = 1; - string fetcherClass = 2; + string fetcher_class = 2; map params = 3; } message ListFetchersRequest { - int32 pageNumber = 1; - int32 numFetchersPerPage = 2; + int32 page_number = 1; + int32 num_fetchers_per_page = 2; } message ListFetchersReply { - repeated GetFetcherReply getFetcherReply = 1; + repeated GetFetcherReply get_fetcher_replies = 1; } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java index a21b025da9..72d92da966 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.grpc; import java.io.InputStream; diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 413b658a1c..02ea21ec97 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -26,10 +26,12 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.UUID; import com.asarkar.grpc.test.GrpcCleanupExtension; @@ -57,8 +59,8 @@ public class TikaGrpcServerTest { private static final Logger LOG = LoggerFactory.getLogger(TikaGrpcServerTest.class); public static final int NUM_TEST_DOCS = 50; - static File tikaConfigXmlTemplate = Paths.get("src", - "test", "resources", "tika-pipes-test-config.xml").toFile(); + static File tikaConfigXmlTemplate = + Paths.get("src", "test", "resources", "tika-pipes-test-config.xml").toFile(); static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); @BeforeAll @@ -70,22 +72,23 @@ static void init() throws IOException { public void testTikaCreateFetcher(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); - Server server = InProcessServerBuilder - .forName(serverName) - .directExecutor() - .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build().start(); + Server server = InProcessServerBuilder.forName(serverName).directExecutor() + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build() + .start(); resources.register(server, Duration.ofSeconds(10)); - ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + ManagedChannel channel = + InProcessChannelBuilder.forName(serverName).directExecutor().build(); resources.register(channel, Duration.ofSeconds(10)); TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); String fetcherId = "fetcherIdHere"; String targetFolder = new File("target").getAbsolutePath(); - CreateFetcherReply reply = blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") - .build()); + CreateFetcherReply reply = blockingStub.createFetcher( + CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder) + .putParams("extractFileSystemMetadata", "true").build()); assertEquals(fetcherId, reply.getMessage()); } @@ -94,23 +97,24 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { public void testBiStream(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); - Server server = InProcessServerBuilder - .forName(serverName) - .directExecutor() - .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build().start(); + Server server = InProcessServerBuilder.forName(serverName).directExecutor() + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build() + .start(); resources.register(server, Duration.ofSeconds(10)); - ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + ManagedChannel channel = + InProcessChannelBuilder.forName(serverName).directExecutor().build(); resources.register(channel, Duration.ofSeconds(10)); TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); TikaGrpc.TikaStub tikaStub = TikaGrpc.newStub(channel); String fetcherId = "fetcherIdHere"; String targetFolder = new File("target").getAbsolutePath(); - CreateFetcherReply reply = blockingStub.createFetcher(CreateFetcherRequest.newBuilder().setName(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder).putParams("extractFileSystemMetadata", "true") - .build()); + CreateFetcherReply reply = blockingStub.createFetcher( + CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder) + .putParams("extractFileSystemMetadata", "true").build()); assertEquals(fetcherId, reply.getMessage()); @@ -139,21 +143,23 @@ public void onCompleted() { StreamObserver requestStreamObserver = tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); - File testDocumentFolder = new File("target/" + DateTimeFormatter.ofPattern( - "yyyy_MM_dd_HH_mm_ssSSS").format(LocalDateTime.now()) + "-" + UUID.randomUUID()); + File testDocumentFolder = new File("target/" + + DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ssSSS", Locale.getDefault()).format(LocalDateTime.now( + ZoneId.systemDefault())) + + "-" + UUID.randomUUID()); assertTrue(testDocumentFolder.mkdir()); try { for (int i = 0; i < NUM_TEST_DOCS; ++i) { File testFile = new File(testDocumentFolder, "test-" + i + ".html"); - FileUtils.writeStringToFile(testFile, "test " + i + "", StandardCharsets.UTF_8); + FileUtils.writeStringToFile(testFile, "test " + i + "", + StandardCharsets.UTF_8); } File[] testDocuments = testDocumentFolder.listFiles(); assertNotNull(testDocuments); for (File testDocument : testDocuments) { - requestStreamObserver.onNext(FetchAndParseRequest.newBuilder() - .setFetcherName(fetcherId) - .setFetchKey(testDocument.getAbsolutePath()) - .build()); + requestStreamObserver.onNext( + FetchAndParseRequest.newBuilder().setFetcherName(fetcherId) + .setFetchKey(testDocument.getAbsolutePath()).build()); } requestStreamObserver.onCompleted(); From 03b647727dda044ad09b269d8e73c40ef6667d01 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 3 Apr 2024 21:36:13 -0500 Subject: [PATCH 14/89] example docker file --- .../tika-grpc/example-dockerfile/Dockerfile | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tika-pipes/tika-grpc/example-dockerfile/Dockerfile diff --git a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile new file mode 100644 index 0000000000..9891b6b100 --- /dev/null +++ b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile @@ -0,0 +1,31 @@ +FROM ubuntu:latest +COPY ./docker/ /tika/libs/ +COPY tika-config.xml /tika/config/tika-config.xml +COPY log4j2.xml /tika/config/log4j2.xml +ARG JRE='openjdk-17-jre-headless' +RUN set -eux \ + && apt-get update \ + && apt-get install --yes --no-install-recommends gnupg2 software-properties-common \ + && add-apt-repository -y ppa:alex-p/tesseract-ocr5 \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends $JRE \ + gdal-bin \ + tesseract-ocr \ + tesseract-ocr-eng \ + tesseract-ocr-ita \ + tesseract-ocr-fra \ + tesseract-ocr-spa \ + tesseract-ocr-deu \ + && echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections \ + && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ + xfonts-utils \ + fonts-freefont-ttf \ + fonts-liberation \ + ttf-mscorefonts-installer \ + wget \ + cabextract \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +EXPOSE 50051 +ENTRYPOINT [ "/bin/sh", "-c", "exec java -Dlog4j.configurationFile=/tika/config/log4j2.xml -Dserver.port=50051 -cp \"/tika/libs/*\" org.apache.tika.pipes.grpc.TikaGrpcServer /tika/config/tika-config.xml"] From e9db4917e7fe8b129e7fe1d4ccfa8d199770d934 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 3 Apr 2024 22:06:09 -0500 Subject: [PATCH 15/89] example docker file and install docker file --- .../tika-grpc/example-dockerfile/Dockerfile | 2 +- .../example-dockerfile/docker-build.sh | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tika-pipes/tika-grpc/example-dockerfile/docker-build.sh diff --git a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile index 9891b6b100..8d69343705 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile +++ b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile @@ -1,5 +1,5 @@ FROM ubuntu:latest -COPY ./docker/ /tika/libs/ +COPY docker/ /tika/libs/ COPY tika-config.xml /tika/config/tika-config.xml COPY log4j2.xml /tika/config/log4j2.xml ARG JRE='openjdk-17-jre-headless' diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh new file mode 100644 index 0000000000..5d370dd486 --- /dev/null +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -0,0 +1,24 @@ +TAG_NAME=$1 + +if [ -z "${TAG_NAME}" ]; then + echo "Tag name is required" + exit 1 +fi + +SCRIPT_DIR=$( cd -- $( dirname -- ${BASH_SOURCE[0]} ) &> /dev/null && pwd ) +TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. +DEST_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker + +mvn clean install -DskipTests=true -f ${TIKA_SRC_PATH} +mvn dependency:copy-dependencies -f ${TIKA_SRC_PATH}/tika-pipes/tika-grpc +rm -rf ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/docker +mkdir -p ${DEST_DIR} + +cp -r ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency ${DEST_DIR}/docker +cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-*.jar ${DEST_DIR} +cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml ${DEST_DIR} +cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml ${DEST_DIR}/tika-config.xml +cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile + +cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target +docker build ${DEST_DIR} -t ${TAG_NAME} From adf79647b1bcb5315605a1c2cd73f36366770da5 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 3 Apr 2024 22:09:10 -0500 Subject: [PATCH 16/89] fix the rm --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 5d370dd486..ea63b6164e 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -11,7 +11,7 @@ DEST_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker mvn clean install -DskipTests=true -f ${TIKA_SRC_PATH} mvn dependency:copy-dependencies -f ${TIKA_SRC_PATH}/tika-pipes/tika-grpc -rm -rf ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/docker +rm -rf ${DEST_DIR} mkdir -p ${DEST_DIR} cp -r ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency ${DEST_DIR}/docker From d5f171e9445d4abcece970cfedcc064b90d7ae5b Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 3 Apr 2024 22:10:12 -0500 Subject: [PATCH 17/89] more info --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index ea63b6164e..c005483a24 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -1,7 +1,7 @@ TAG_NAME=$1 if [ -z "${TAG_NAME}" ]; then - echo "Tag name is required" + echo "Single command line argument is required which will be used as the -t parameter of the docker build command" exit 1 fi From 9e02b84322827cfa7dccfe8167af72960d21d311 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 08:03:19 -0500 Subject: [PATCH 18/89] --platform linux/arm64 --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index c005483a24..e666e2e080 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -21,4 +21,4 @@ cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-conf cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target -docker build ${DEST_DIR} -t ${TAG_NAME} +docker build ${DEST_DIR} -t ${TAG_NAME} --platform linux/arm64 From 9027524f709d60dbbc7c514cf00c95a8e1a4d90b Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 08:38:50 -0500 Subject: [PATCH 19/89] --platform linux/arm64 --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index e666e2e080..8100d694fb 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -21,4 +21,6 @@ cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-conf cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target -docker build ${DEST_DIR} -t ${TAG_NAME} --platform linux/arm64 +docker buildx create --name mybuilder +docker buildx build --builder=mybuilder ${DEST_DIR} -t ${TAG_NAME} --platform linux/amd64,linux/arm64 --push +docker buildx stop mybuilder From d1ddb3fc34b23e0660d956e3cab0379529fd1d2d Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 08:39:50 -0500 Subject: [PATCH 20/89] add work around comment for future --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 8100d694fb..ce80630e14 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -21,6 +21,8 @@ cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-conf cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target +# docker run --rm --privileged tonistiigi/binfmt --install amd64 +# docker run --rm --privileged tonistiigi/binfmt --install arm64 docker buildx create --name mybuilder docker buildx build --builder=mybuilder ${DEST_DIR} -t ${TAG_NAME} --platform linux/amd64,linux/arm64 --push docker buildx stop mybuilder From eaff6a788e588ad82c41f253beac25a9056b5e29 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 08:40:10 -0500 Subject: [PATCH 21/89] add comment --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index ce80630e14..5fdbcb78cc 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -21,6 +21,7 @@ cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-conf cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target +# see https://askubuntu.com/questions/1339558/cant-build-dockerfile-for-arm64-due-to-libc-bin-segmentation-fault/1398147#1398147 # docker run --rm --privileged tonistiigi/binfmt --install amd64 # docker run --rm --privileged tonistiigi/binfmt --install arm64 docker buildx create --name mybuilder From ddac790ba612313bdb53f86549c290dc60470d2d Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 10:06:49 -0500 Subject: [PATCH 22/89] fix issues with files in wrong dir --- .../tika-grpc/example-dockerfile/Dockerfile | 2 +- .../example-dockerfile/docker-build.sh | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile index 8d69343705..9d45e8a1eb 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile +++ b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile @@ -1,5 +1,5 @@ FROM ubuntu:latest -COPY docker/ /tika/libs/ +COPY libs/ /tika/libs/ COPY tika-config.xml /tika/config/tika-config.xml COPY log4j2.xml /tika/config/log4j2.xml ARG JRE='openjdk-17-jre-headless' diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 5fdbcb78cc..1a531fda95 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -14,16 +14,21 @@ mvn dependency:copy-dependencies -f ${TIKA_SRC_PATH}/tika-pipes/tika-grpc rm -rf ${DEST_DIR} mkdir -p ${DEST_DIR} -cp -r ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency ${DEST_DIR}/docker -cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-*.jar ${DEST_DIR} +cp -r ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency ${DEST_DIR}/libs +cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-*.jar ${DEST_DIR}/libs cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml ${DEST_DIR} cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml ${DEST_DIR}/tika-config.xml cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile -cd ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target +cd ${DEST_DIR} + +# build single arch +#docker build ${DEST_DIR} -t ${TAG_NAME} + +# Or we can build multi-arch - https://www.docker.com/blog/multi-arch-images/ +docker buildx create --name tikabuilder # see https://askubuntu.com/questions/1339558/cant-build-dockerfile-for-arm64-due-to-libc-bin-segmentation-fault/1398147#1398147 -# docker run --rm --privileged tonistiigi/binfmt --install amd64 -# docker run --rm --privileged tonistiigi/binfmt --install arm64 -docker buildx create --name mybuilder -docker buildx build --builder=mybuilder ${DEST_DIR} -t ${TAG_NAME} --platform linux/amd64,linux/arm64 --push -docker buildx stop mybuilder +docker run --rm --privileged tonistiigi/binfmt --install amd64 +docker run --rm --privileged tonistiigi/binfmt --install arm64 +docker buildx build --builder=tikabuilder ${DEST_DIR} -t ${TAG_NAME} --platform linux/amd64,linux/arm64 --push +docker buildx stop tikabuilder From 1dcea295ad138b2288f4beebe2d78acc643b89bf Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 10:28:07 -0500 Subject: [PATCH 23/89] add quotes --- .../example-dockerfile/docker-build.sh | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 1a531fda95..57b8194c65 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -5,30 +5,30 @@ if [ -z "${TAG_NAME}" ]; then exit 1 fi -SCRIPT_DIR=$( cd -- $( dirname -- ${BASH_SOURCE[0]} ) &> /dev/null && pwd ) +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. DEST_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker -mvn clean install -DskipTests=true -f ${TIKA_SRC_PATH} -mvn dependency:copy-dependencies -f ${TIKA_SRC_PATH}/tika-pipes/tika-grpc -rm -rf ${DEST_DIR} -mkdir -p ${DEST_DIR} +mvn clean install -DskipTests=true -f "${TIKA_SRC_PATH}" +mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" +rm -rf "${DEST_DIR}" +mkdir -p "${DEST_DIR}" -cp -r ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency ${DEST_DIR}/libs -cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-*.jar ${DEST_DIR}/libs -cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml ${DEST_DIR} -cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml ${DEST_DIR}/tika-config.xml -cp ${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile ${DEST_DIR}/Dockerfile +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency" "${DEST_DIR}/libs" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-"*".jar" "${DEST_DIR}/libs" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml" "${DEST_DIR}" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml" "${DEST_DIR}/tika-config.xml" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile" "${DEST_DIR}/Dockerfile" -cd ${DEST_DIR} +cd "${DEST_DIR}" || exit # build single arch -#docker build ${DEST_DIR} -t ${TAG_NAME} +#docker build "${DEST_DIR}" -t "${TAG_NAME}" # Or we can build multi-arch - https://www.docker.com/blog/multi-arch-images/ docker buildx create --name tikabuilder # see https://askubuntu.com/questions/1339558/cant-build-dockerfile-for-arm64-due-to-libc-bin-segmentation-fault/1398147#1398147 docker run --rm --privileged tonistiigi/binfmt --install amd64 docker run --rm --privileged tonistiigi/binfmt --install arm64 -docker buildx build --builder=tikabuilder ${DEST_DIR} -t ${TAG_NAME} --platform linux/amd64,linux/arm64 --push +docker buildx build --builder=tikabuilder "${DEST_DIR}" -t "${TAG_NAME}" --platform linux/amd64,linux/arm64 --push docker buildx stop tikabuilder From 7de1c5239697ba793cd8f4d4e11790ba26ef0446 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 14:25:04 -0500 Subject: [PATCH 24/89] add a few more cleanups --- .../example-dockerfile/docker-build.sh | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 57b8194c65..7f9934759c 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -7,28 +7,28 @@ fi SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. -DEST_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker +OUT_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker mvn clean install -DskipTests=true -f "${TIKA_SRC_PATH}" mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" -rm -rf "${DEST_DIR}" -mkdir -p "${DEST_DIR}" +rm -rf "${OUT_DIR}" +mkdir -p "${OUT_DIR}" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency" "${DEST_DIR}/libs" -cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-"*".jar" "${DEST_DIR}/libs" -cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml" "${DEST_DIR}" -cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml" "${DEST_DIR}/tika-config.xml" -cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile" "${DEST_DIR}/Dockerfile" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency" "${OUT_DIR}/libs" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-"*".jar" "${OUT_DIR}/libs" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml" "${OUT_DIR}" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml" "${OUT_DIR}/tika-config.xml" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile" "${OUT_DIR}/Dockerfile" -cd "${DEST_DIR}" || exit +cd "${OUT_DIR}" || exit # build single arch -#docker build "${DEST_DIR}" -t "${TAG_NAME}" +#docker build "${OUT_DIR}" -t "${TAG_NAME}" # Or we can build multi-arch - https://www.docker.com/blog/multi-arch-images/ docker buildx create --name tikabuilder # see https://askubuntu.com/questions/1339558/cant-build-dockerfile-for-arm64-due-to-libc-bin-segmentation-fault/1398147#1398147 docker run --rm --privileged tonistiigi/binfmt --install amd64 docker run --rm --privileged tonistiigi/binfmt --install arm64 -docker buildx build --builder=tikabuilder "${DEST_DIR}" -t "${TAG_NAME}" --platform linux/amd64,linux/arm64 --push +docker buildx build --builder=tikabuilder "${OUT_DIR}" -t "${TAG_NAME}" --platform linux/amd64,linux/arm64 --push docker buildx stop tikabuilder From 8b65ea283e3e4108fd63db339c0dc2124978dffb Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Tue, 9 Apr 2024 23:59:45 -0500 Subject: [PATCH 25/89] add logging add reflection api for grpc so you can use grpc curl --- tika-pipes/tika-grpc/pom.xml | 13 ++++++++ .../tika/pipes/grpc/TikaGrpcServer.java | 10 +++++- .../tika-grpc/src/main/resources/log4j2.xml | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tika-pipes/tika-grpc/src/main/resources/log4j2.xml diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 26ca42da20..0dda48f302 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -156,6 +156,19 @@ + + + org.apache.logging.log4j + log4j-core + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.slf4j + jcl-over-slf4j + org.apache.tomcat annotations-api diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 1e3ac3d722..626092f19f 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -21,6 +21,7 @@ import io.grpc.Grpc; import io.grpc.InsecureServerCredentials; import io.grpc.Server; +import io.grpc.protobuf.services.ProtoReflectionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +37,10 @@ private void start() throws Exception { /* The port on which the server should run */ int port = Integer.parseInt(System.getProperty("server.port", "50051")); server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) - .addService(new TikaGrpcServerImpl(tikaConfigPath)).build().start(); + .addService(new TikaGrpcServerImpl(tikaConfigPath)) + .addService(ProtoReflectionService.newInstance()) // Enable reflection + .build() + .start(); LOGGER.info("Server started, listening on " + port); Runtime.getRuntime().addShutdownHook(new Thread(() -> { // Use stderr here since the logger may have been reset by its JVM shutdown hook. @@ -69,6 +73,10 @@ private void blockUntilShutdown() throws InterruptedException { * Main launches the server from the command line. */ public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("Usage: TikaGrpcServer {path-to-tika-config-xml-file}"); + System.exit(1); + } tikaConfigPath = args[0]; final TikaGrpcServer server = new TikaGrpcServer(); server.start(); diff --git a/tika-pipes/tika-grpc/src/main/resources/log4j2.xml b/tika-pipes/tika-grpc/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..c88e66e99e --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/resources/log4j2.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file From ddac08396ffb4e9d39cda7f25daa0e41efcbd4bb Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 10 Apr 2024 01:08:25 -0500 Subject: [PATCH 26/89] fix issues and add tests for get, delete --- .../tika/pipes/grpc/ExpiringFetcherStore.java | 5 +- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 81 ++++++++++++++----- .../exception/FetcherNotFoundException.java | 23 ++++++ .../tika/pipes/grpc/TikaGrpcServerTest.java | 54 ++++++++++--- 4 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/exception/FetcherNotFoundException.java diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java index 9c708f175d..0618e18f4c 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java @@ -65,10 +65,11 @@ public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersD }, EXPIRE_JOB_INITIAL_DELAY, checkForExpiredFetchersDelaySeconds, TimeUnit.SECONDS); } - public void deleteFetcher(String fetcherName) { - fetchers.remove(fetcherName); + public boolean deleteFetcher(String fetcherName) { + boolean success = fetchers.remove(fetcherName) != null; fetcherConfigs.remove(fetcherName); fetcherLastAccessed.remove(fetcherName); + return success; } public Map getFetchers() { diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 9f51770a94..bd2c1ee53d 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import javax.xml.parsers.DocumentBuilderFactory; @@ -34,6 +35,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.rpc.Status; +import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +74,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { private static final Logger LOG = LoggerFactory.getLogger(TikaConfigSerializer.class); public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** * FetcherID is key, The pair is the Fetcher object and the Metadata */ @@ -86,9 +90,8 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { pipesConfig = PipesConfig.load(Paths.get(tikaConfigPath)); pipesClient = new PipesClient(pipesConfig); - expiringFetcherStore = - new ExpiringFetcherStore(pipesConfig.getStaleFetcherTimeoutSeconds(), - pipesConfig.getStaleFetcherDelaySeconds()); + expiringFetcherStore = new ExpiringFetcherStore(pipesConfig.getStaleFetcherTimeoutSeconds(), + pipesConfig.getStaleFetcherDelaySeconds()); this.tikaConfigPath = tikaConfigPath; updateTikaConfig(); } @@ -104,10 +107,10 @@ private void updateTikaConfig() } for (var fetcherEntry : expiringFetcherStore.getFetchers().entrySet()) { AbstractFetcher fetcherObject = fetcherEntry.getValue(); - Map fetcherConfigParams = - OBJECT_MAPPER.convertValue(expiringFetcherStore.getFetcherConfigs().get(fetcherEntry.getKey()), - new TypeReference<>() { - }); + Map fetcherConfigParams = OBJECT_MAPPER.convertValue( + expiringFetcherStore.getFetcherConfigs().get(fetcherEntry.getKey()), + new TypeReference<>() { + }); Element fetcher = tikaConfigDoc.createElement("fetcher"); fetcher.setAttribute("class", fetcherEntry.getValue().getClass().getName()); Element fetcherName = tikaConfigDoc.createElement("name"); @@ -171,7 +174,8 @@ public void fetchAndParse(FetchAndParseRequest request, private void fetchAndParseImpl(FetchAndParseRequest request, StreamObserver responseObserver) { - AbstractFetcher fetcher = expiringFetcherStore.getFetcherAndLogAccess(request.getFetcherName()); + AbstractFetcher fetcher = + expiringFetcherStore.getFetcherAndLogAccess(request.getFetcherName()); if (fetcher == null) { throw new RuntimeException( "Could not find fetcher with name " + request.getFetcherName()); @@ -223,6 +227,9 @@ public void createFetcher(CreateFetcherRequest request, private void createFetcher(String name, String fetcherClassName, Map paramsMap, Map tikaParamsMap) { try { + if (paramsMap == null) { + paramsMap = new LinkedHashMap<>(); + } Class fetcherClass = (Class) Class.forName(fetcherClassName); String configClassName = @@ -271,16 +278,32 @@ public void updateFetcher(UpdateFetcherRequest request, responseObserver.onCompleted(); } + static Status notFoundStatus(String fetcherId) { + return Status.newBuilder() + .setCode(io.grpc.Status.Code.NOT_FOUND.value()) + .setMessage("Could not find fetcher with id:" + fetcherId) + .build(); + } + @Override public void getFetcher(GetFetcherRequest request, StreamObserver responseObserver) { GetFetcherReply.Builder getFetcherReply = GetFetcherReply.newBuilder(); - AbstractConfig abstractConfig = expiringFetcherStore.getFetcherConfigs().get(request.getName()); + AbstractConfig abstractConfig = + expiringFetcherStore.getFetcherConfigs().get(request.getName()); + AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(request.getName()); + if (abstractFetcher == null || abstractConfig == null) { + responseObserver.onError(StatusProto.toStatusException(notFoundStatus(request.getName()))); + return; + } + getFetcherReply.setName(request.getName()); + getFetcherReply.setFetcherClass(abstractFetcher.getClass().getName()); Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { }); paramMap.forEach( (k, v) -> getFetcherReply.putParams(Objects.toString(k), Objects.toString(v))); + responseObserver.onNext(getFetcherReply.build()); responseObserver.onCompleted(); } @@ -289,7 +312,8 @@ public void getFetcher(GetFetcherRequest request, public void listFetchers(ListFetchersRequest request, StreamObserver responseObserver) { ListFetchersReply.Builder listFetchersReplyBuilder = ListFetchersReply.newBuilder(); - for (Map.Entry fetcherConfig : expiringFetcherStore.getFetcherConfigs().entrySet()) { + for (Map.Entry fetcherConfig : expiringFetcherStore.getFetcherConfigs() + .entrySet()) { GetFetcherReply.Builder replyBuilder = createFetcherReply(fetcherConfig); listFetchersReplyBuilder.addGetFetcherReplies(replyBuilder.build()); } @@ -299,31 +323,44 @@ public void listFetchers(ListFetchersRequest request, private GetFetcherReply.Builder createFetcherReply( Map.Entry fetcherConfig) { - AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(fetcherConfig.getKey()); - AbstractConfig abstractConfig = expiringFetcherStore.getFetcherConfigs().get(fetcherConfig.getKey()); + AbstractFetcher abstractFetcher = + expiringFetcherStore.getFetchers().get(fetcherConfig.getKey()); + AbstractConfig abstractConfig = + expiringFetcherStore.getFetcherConfigs().get(fetcherConfig.getKey()); GetFetcherReply.Builder replyBuilder = GetFetcherReply.newBuilder().setFetcherClass(abstractFetcher.getClass().getName()) .setName(abstractFetcher.getName()); + loadParamsIntoReply(abstractConfig, replyBuilder); + return replyBuilder; + } + + private static void loadParamsIntoReply(AbstractConfig abstractConfig, + GetFetcherReply.Builder replyBuilder) { Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { }); - paramMap.forEach( - (k, v) -> replyBuilder.putParams(Objects.toString(k), Objects.toString(v))); - return replyBuilder; + if (paramMap != null) { + paramMap.forEach( + (k, v) -> replyBuilder.putParams(Objects.toString(k), Objects.toString(v))); + } } @Override public void deleteFetcher(DeleteFetcherRequest request, StreamObserver responseObserver) { - deleteFetcher(request.getName()); - try { - updateTikaConfig(); - } catch (Exception e) { - throw new RuntimeException(e); + boolean successfulDelete = deleteFetcher(request.getName()); + if (successfulDelete) { + try { + updateTikaConfig(); + } catch (Exception e) { + throw new RuntimeException(e); + } } + responseObserver.onNext(DeleteFetcherReply.newBuilder().setSuccess(successfulDelete).build()); + responseObserver.onCompleted(); } - private void deleteFetcher(String fetcherName) { - expiringFetcherStore.deleteFetcher(fetcherName); + private boolean deleteFetcher(String fetcherName) { + return expiringFetcherStore.deleteFetcher(fetcherName); } } diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/exception/FetcherNotFoundException.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/exception/FetcherNotFoundException.java new file mode 100644 index 0000000000..72116d6ded --- /dev/null +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/exception/FetcherNotFoundException.java @@ -0,0 +1,23 @@ +/* + * 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.tika.pipes.grpc.exception; + +public class FetcherNotFoundException extends Exception { + public FetcherNotFoundException(String message) { + super(message); + } +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 02ea21ec97..2df4db11e2 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -38,10 +38,13 @@ import com.asarkar.grpc.test.Resources; import io.grpc.ManagedChannel; import io.grpc.Server; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.stub.StreamObserver; import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,8 +53,12 @@ import org.apache.tika.CreateFetcherReply; import org.apache.tika.CreateFetcherRequest; +import org.apache.tika.DeleteFetcherReply; +import org.apache.tika.DeleteFetcherRequest; import org.apache.tika.FetchAndParseReply; import org.apache.tika.FetchAndParseRequest; +import org.apache.tika.GetFetcherReply; +import org.apache.tika.GetFetcherRequest; import org.apache.tika.TikaGrpc; import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; @@ -68,8 +75,10 @@ static void init() throws IOException { FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); } + static final int NUM_FETCHERS_TO_CREATE = 10; + @Test - public void testTikaCreateFetcher(Resources resources) throws Exception { + public void testFetcherCrud(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); Server server = InProcessServerBuilder.forName(serverName).directExecutor() @@ -82,15 +91,38 @@ public void testTikaCreateFetcher(Resources resources) throws Exception { resources.register(channel, Duration.ofSeconds(10)); TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); - String fetcherId = "fetcherIdHere"; String targetFolder = new File("target").getAbsolutePath(); - CreateFetcherReply reply = blockingStub.createFetcher( - CreateFetcherRequest.newBuilder().setName(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder) - .putParams("extractFileSystemMetadata", "true").build()); + // create fetchers + for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { + String fetcherId = "fetcherIdHere" + i; + CreateFetcherReply reply = blockingStub.createFetcher( + CreateFetcherRequest.newBuilder().setName(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder) + .putParams("extractFileSystemMetadata", "true").build()); + assertEquals(fetcherId, reply.getMessage()); + } + // get fetchers + for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { + String fetcherId = "fetcherIdHere" + i; + GetFetcherReply getFetcherReply = + blockingStub.getFetcher(GetFetcherRequest.newBuilder().setName(fetcherId).build()); + assertEquals(fetcherId, getFetcherReply.getName()); + assertEquals(FileSystemFetcher.class.getName(), getFetcherReply.getFetcherClass()); + } - assertEquals(fetcherId, reply.getMessage()); + // delete fetchers + for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { + String fetcherId = "fetcherIdHere" + i; + DeleteFetcherReply deleteFetcherReply = + blockingStub.deleteFetcher(DeleteFetcherRequest.newBuilder().setName(fetcherId).build()); + Assertions.assertTrue(deleteFetcherReply.getSuccess()); + StatusRuntimeException statusRuntimeException = + Assertions.assertThrows(StatusRuntimeException.class, () -> + blockingStub.getFetcher(GetFetcherRequest.newBuilder().setName(fetcherId).build())); + Assertions.assertEquals(Status.NOT_FOUND.getCode().value(), + statusRuntimeException.getStatus().getCode().value()); + } } @Test @@ -144,9 +176,9 @@ public void onCompleted() { tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); File testDocumentFolder = new File("target/" + - DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ssSSS", Locale.getDefault()).format(LocalDateTime.now( - ZoneId.systemDefault())) + - "-" + UUID.randomUUID()); + DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ssSSS", Locale.getDefault()) + .format(LocalDateTime.now(ZoneId.systemDefault())) + "-" + + UUID.randomUUID()); assertTrue(testDocumentFolder.mkdir()); try { for (int i = 0; i < NUM_TEST_DOCS; ++i) { From 4f928acda45d28fa8483ca99d09b1741ed09f75f Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 10 Apr 2024 17:08:31 -0500 Subject: [PATCH 27/89] push latest fixes --- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 61 +++++++------------ .../tika-grpc/src/main/proto/tika.proto | 29 +++------ .../tika/pipes/grpc/TikaGrpcServerTest.java | 40 ++++++++---- 3 files changed, 59 insertions(+), 71 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index bd2c1ee53d..2fabf8fd90 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -44,8 +44,6 @@ import org.w3c.dom.Element; import org.xml.sax.SAXException; -import org.apache.tika.CreateFetcherReply; -import org.apache.tika.CreateFetcherRequest; import org.apache.tika.DeleteFetcherReply; import org.apache.tika.DeleteFetcherRequest; import org.apache.tika.FetchAndParseReply; @@ -54,9 +52,9 @@ import org.apache.tika.GetFetcherRequest; import org.apache.tika.ListFetchersReply; import org.apache.tika.ListFetchersRequest; +import org.apache.tika.SaveFetcherReply; +import org.apache.tika.SaveFetcherRequest; import org.apache.tika.TikaGrpc; -import org.apache.tika.UpdateFetcherReply; -import org.apache.tika.UpdateFetcherRequest; import org.apache.tika.config.Initializable; import org.apache.tika.config.Param; import org.apache.tika.config.TikaConfigSerializer; @@ -175,10 +173,10 @@ public void fetchAndParse(FetchAndParseRequest request, private void fetchAndParseImpl(FetchAndParseRequest request, StreamObserver responseObserver) { AbstractFetcher fetcher = - expiringFetcherStore.getFetcherAndLogAccess(request.getFetcherName()); + expiringFetcherStore.getFetcherAndLogAccess(request.getFetcherId()); if (fetcher == null) { throw new RuntimeException( - "Could not find fetcher with name " + request.getFetcherName()); + "Could not find fetcher with name " + request.getFetcherId()); } Metadata tikaMetadata = new Metadata(); for (Map.Entry entry : request.getMetadataMap().entrySet()) { @@ -208,13 +206,13 @@ private void fetchAndParseImpl(FetchAndParseRequest request, @SuppressWarnings("raw") @Override - public void createFetcher(CreateFetcherRequest request, - StreamObserver responseObserver) { - CreateFetcherReply reply = - CreateFetcherReply.newBuilder().setMessage(request.getName()).build(); + public void saveFetcher(SaveFetcherRequest request, + StreamObserver responseObserver) { + SaveFetcherReply reply = + SaveFetcherReply.newBuilder().setFetcherId(request.getFetcherId()).build(); Map tikaParamsMap = createTikaParamMap(request.getParamsMap()); try { - createFetcher(request.getName(), request.getFetcherClass(), request.getParamsMap(), + saveFetcher(request.getFetcherId(), request.getFetcherClass(), request.getParamsMap(), tikaParamsMap); updateTikaConfig(); } catch (Exception e) { @@ -224,7 +222,7 @@ public void createFetcher(CreateFetcherRequest request, responseObserver.onCompleted(); } - private void createFetcher(String name, String fetcherClassName, Map paramsMap, + private void saveFetcher(String name, String fetcherClassName, Map paramsMap, Map tikaParamsMap) { try { if (paramsMap == null) { @@ -245,6 +243,11 @@ private void createFetcher(String name, String fetcherClassName, Map createTikaParamMap(Map paramsM return tikaParamsMap; } - @Override - public void updateFetcher(UpdateFetcherRequest request, - StreamObserver responseObserver) { - UpdateFetcherReply reply = - UpdateFetcherReply.newBuilder().setMessage(request.getName()).build(); - Map tikaParamsMap = createTikaParamMap(request.getParamsMap()); - try { - deleteFetcher(request.getName()); - createFetcher(request.getName(), request.getFetcherClass(), request.getParamsMap(), - tikaParamsMap); - updateTikaConfig(); - } catch (Exception e) { - throw new RuntimeException(e); - } - responseObserver.onNext(reply); - responseObserver.onCompleted(); - } - static Status notFoundStatus(String fetcherId) { return Status.newBuilder() .setCode(io.grpc.Status.Code.NOT_FOUND.value()) @@ -290,13 +275,13 @@ public void getFetcher(GetFetcherRequest request, StreamObserver responseObserver) { GetFetcherReply.Builder getFetcherReply = GetFetcherReply.newBuilder(); AbstractConfig abstractConfig = - expiringFetcherStore.getFetcherConfigs().get(request.getName()); - AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(request.getName()); + expiringFetcherStore.getFetcherConfigs().get(request.getFetcherId()); + AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(request.getFetcherId()); if (abstractFetcher == null || abstractConfig == null) { - responseObserver.onError(StatusProto.toStatusException(notFoundStatus(request.getName()))); + responseObserver.onError(StatusProto.toStatusException(notFoundStatus(request.getFetcherId()))); return; } - getFetcherReply.setName(request.getName()); + getFetcherReply.setFetcherId(request.getFetcherId()); getFetcherReply.setFetcherClass(abstractFetcher.getClass().getName()); Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { @@ -314,14 +299,14 @@ public void listFetchers(ListFetchersRequest request, ListFetchersReply.Builder listFetchersReplyBuilder = ListFetchersReply.newBuilder(); for (Map.Entry fetcherConfig : expiringFetcherStore.getFetcherConfigs() .entrySet()) { - GetFetcherReply.Builder replyBuilder = createFetcherReply(fetcherConfig); + GetFetcherReply.Builder replyBuilder = saveFetcherReply(fetcherConfig); listFetchersReplyBuilder.addGetFetcherReplies(replyBuilder.build()); } responseObserver.onNext(listFetchersReplyBuilder.build()); responseObserver.onCompleted(); } - private GetFetcherReply.Builder createFetcherReply( + private GetFetcherReply.Builder saveFetcherReply( Map.Entry fetcherConfig) { AbstractFetcher abstractFetcher = expiringFetcherStore.getFetchers().get(fetcherConfig.getKey()); @@ -329,7 +314,7 @@ private GetFetcherReply.Builder createFetcherReply( expiringFetcherStore.getFetcherConfigs().get(fetcherConfig.getKey()); GetFetcherReply.Builder replyBuilder = GetFetcherReply.newBuilder().setFetcherClass(abstractFetcher.getClass().getName()) - .setName(abstractFetcher.getName()); + .setFetcherId(abstractFetcher.getName()); loadParamsIntoReply(abstractConfig, replyBuilder); return replyBuilder; } @@ -348,7 +333,7 @@ private static void loadParamsIntoReply(AbstractConfig abstractConfig, @Override public void deleteFetcher(DeleteFetcherRequest request, StreamObserver responseObserver) { - boolean successfulDelete = deleteFetcher(request.getName()); + boolean successfulDelete = deleteFetcher(request.getFetcherId()); if (successfulDelete) { try { updateTikaConfig(); diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 8e45a829fd..66d946adc0 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -21,8 +21,7 @@ option objc_class_prefix = "HLW"; service Tika { - rpc CreateFetcher(CreateFetcherRequest) returns (CreateFetcherReply) {} - rpc UpdateFetcher(UpdateFetcherRequest) returns (UpdateFetcherReply) {} + rpc SaveFetcher(SaveFetcherRequest) returns (SaveFetcherReply) {} rpc GetFetcher(GetFetcherRequest) returns (GetFetcherReply) {} rpc ListFetchers(ListFetchersRequest) returns (ListFetchersReply) {} rpc DeleteFetcher(DeleteFetcherRequest) returns (DeleteFetcherReply) {} @@ -33,28 +32,18 @@ service Tika { returns (stream FetchAndParseReply) {} } -message CreateFetcherRequest { - string name = 1; +message SaveFetcherRequest { + string fetcher_id = 1; string fetcher_class = 2; map params = 3; } -message CreateFetcherReply { - string message = 1; -} - -message UpdateFetcherRequest { - string name = 1; - string fetcher_class = 2; - map params = 3; -} - -message UpdateFetcherReply { - string message = 1; +message SaveFetcherReply { + string fetcher_id = 1; } message FetchAndParseRequest { - string fetcher_name = 1; + string fetcher_id = 1; string fetch_key = 2; map metadata = 3; } @@ -65,7 +54,7 @@ message FetchAndParseReply { } message DeleteFetcherRequest { - string name = 1; + string fetcher_id = 1; } message DeleteFetcherReply { @@ -73,11 +62,11 @@ message DeleteFetcherReply { } message GetFetcherRequest { - string name = 1; + string fetcher_id = 1; } message GetFetcherReply { - string name = 1; + string fetcher_id = 1; string fetcher_class = 2; map params = 3; } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 2df4db11e2..da8a4b5b58 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -51,14 +51,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.apache.tika.CreateFetcherReply; -import org.apache.tika.CreateFetcherRequest; import org.apache.tika.DeleteFetcherReply; import org.apache.tika.DeleteFetcherRequest; import org.apache.tika.FetchAndParseReply; import org.apache.tika.FetchAndParseRequest; import org.apache.tika.GetFetcherReply; import org.apache.tika.GetFetcherRequest; +import org.apache.tika.SaveFetcherReply; +import org.apache.tika.SaveFetcherRequest; import org.apache.tika.TikaGrpc; import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; @@ -95,19 +95,33 @@ public void testFetcherCrud(Resources resources) throws Exception { // create fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; - CreateFetcherReply reply = blockingStub.createFetcher( - CreateFetcherRequest.newBuilder().setName(fetcherId) + SaveFetcherReply reply = blockingStub.saveFetcher( + SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) .setFetcherClass(FileSystemFetcher.class.getName()) .putParams("basePath", targetFolder) .putParams("extractFileSystemMetadata", "true").build()); - assertEquals(fetcherId, reply.getMessage()); + assertEquals(fetcherId, reply.getFetcherId()); } + // update fetchers + for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { + String fetcherId = "fetcherIdHere" + i; + SaveFetcherReply reply = blockingStub.saveFetcher( + SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .putParams("basePath", targetFolder) + .putParams("extractFileSystemMetadata", "false").build()); + assertEquals(fetcherId, reply.getFetcherId()); + GetFetcherReply getFetcherReply = + blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); + assertEquals("false", getFetcherReply.getParamsMap().get("extractFileSystemMetadata")); + } + // get fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; GetFetcherReply getFetcherReply = - blockingStub.getFetcher(GetFetcherRequest.newBuilder().setName(fetcherId).build()); - assertEquals(fetcherId, getFetcherReply.getName()); + blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); + assertEquals(fetcherId, getFetcherReply.getFetcherId()); assertEquals(FileSystemFetcher.class.getName(), getFetcherReply.getFetcherClass()); } @@ -115,11 +129,11 @@ public void testFetcherCrud(Resources resources) throws Exception { for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; DeleteFetcherReply deleteFetcherReply = - blockingStub.deleteFetcher(DeleteFetcherRequest.newBuilder().setName(fetcherId).build()); + blockingStub.deleteFetcher(DeleteFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); Assertions.assertTrue(deleteFetcherReply.getSuccess()); StatusRuntimeException statusRuntimeException = Assertions.assertThrows(StatusRuntimeException.class, () -> - blockingStub.getFetcher(GetFetcherRequest.newBuilder().setName(fetcherId).build())); + blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build())); Assertions.assertEquals(Status.NOT_FOUND.getCode().value(), statusRuntimeException.getStatus().getCode().value()); } @@ -142,13 +156,13 @@ public void testBiStream(Resources resources) throws Exception { String fetcherId = "fetcherIdHere"; String targetFolder = new File("target").getAbsolutePath(); - CreateFetcherReply reply = blockingStub.createFetcher( - CreateFetcherRequest.newBuilder().setName(fetcherId) + SaveFetcherReply reply = blockingStub.saveFetcher( + SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) .setFetcherClass(FileSystemFetcher.class.getName()) .putParams("basePath", targetFolder) .putParams("extractFileSystemMetadata", "true").build()); - assertEquals(fetcherId, reply.getMessage()); + assertEquals(fetcherId, reply.getFetcherId()); List fetchAndParseReplys = Collections.synchronizedList(new ArrayList<>()); @@ -190,7 +204,7 @@ public void onCompleted() { assertNotNull(testDocuments); for (File testDocument : testDocuments) { requestStreamObserver.onNext( - FetchAndParseRequest.newBuilder().setFetcherName(fetcherId) + FetchAndParseRequest.newBuilder().setFetcherId(fetcherId) .setFetchKey(testDocument.getAbsolutePath()).build()); } requestStreamObserver.onCompleted(); From c24be46e5b8d79341911137651158c2b3abd3d37 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 28 Mar 2024 04:04:33 -0500 Subject: [PATCH 28/89] TIKA-4229 initial attempt to add microsoft graph fetcher --- tika-pipes/tika-fetchers/pom.xml | 1 + .../tika-fetcher-microsoft-graph/pom.xml | 151 ++++++++++++++++++ .../microsoftgraph/MicrosoftGraphFetcher.java | 140 ++++++++++++++++ .../config/AadCredentialConfigBase.java | 40 +++++ .../Client2CertificateCredentialsConfig.java | 50 ++++++ .../ClientCertificateCredentialsConfig.java | 40 +++++ .../config/ClientSecretCredentialsConfig.java | 30 ++++ .../config/MsGraphFetcherConfig.java | 65 ++++++++ .../MicrosoftGraphFetcherTest.java | 100 ++++++++++++ .../src/test/resources/log4j2.xml | 32 ++++ 10 files changed, 649 insertions(+) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/resources/log4j2.xml diff --git a/tika-pipes/tika-fetchers/pom.xml b/tika-pipes/tika-fetchers/pom.xml index 7830a74d67..8b957e8cf9 100644 --- a/tika-pipes/tika-fetchers/pom.xml +++ b/tika-pipes/tika-fetchers/pom.xml @@ -36,6 +36,7 @@ tika-fetcher-s3 tika-fetcher-gcs tika-fetcher-az-blob + tika-fetcher-microsoft-graph diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml new file mode 100644 index 0000000000..e40c8354f9 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml @@ -0,0 +1,151 @@ + + + + + tika-fetchers + org.apache.tika + 3.0.0-SNAPSHOT + + 4.0.0 + + tika-fetcher-microsoft-graph + Microsoft Graph Tika Pipes Fetcher + + + 11 + 11 + UTF-8 + 1.11.0 + 6.4.0 + 1.1.1 + 5.9.2 + 3.3.1 + 5.3.1 + + + + + ${project.groupId} + tika-core + ${project.version} + + + com.microsoft.graph + microsoft-graph + ${microsoft-graph.version} + + + com.azure + azure-identity + ${azure-identity.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter-engine.version} + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + org.apache.tika.pipes.fetcher.s3 + + + + + + + test-jar + + + + + + maven-shade-plugin + ${maven.shade.version} + + + package + + shade + + + + false + + + + + *:* + + META-INF/* + LICENSE.txt + NOTICE.txt + + + + + + META-INF/LICENSE + target/classes/META-INF/LICENSE + + + META-INF/NOTICE + target/classes/META-INF/NOTICE + + + META-INF/DEPENDENCIES + target/classes/META-INF/DEPENDENCIES + + + + + + + + + + + + 3.0.0-BETA-rc1 + + \ No newline at end of file diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java new file mode 100644 index 0000000000..771790692e --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -0,0 +1,140 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph; + +import com.azure.identity.ClientCertificateCredentialBuilder; +import com.azure.identity.ClientSecretCredentialBuilder; +import com.microsoft.graph.serviceclient.GraphServiceClient; +import org.apache.tika.config.Field; +import org.apache.tika.config.Initializable; +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.metadata.Metadata; +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientSecretCredentialsConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * Fetches files from Microsoft Graph API. + * Fetch keys are ${siteDriveId},${driveItemId} + */ +public class MicrosoftGraphFetcher extends AbstractFetcher implements Initializable { + private static final Logger LOGGER = LoggerFactory.getLogger(MicrosoftGraphFetcher.class); + private GraphServiceClient graphClient; + private MsGraphFetcherConfig msGraphFetcherConfig; + private long[] throttleSeconds; + + public MicrosoftGraphFetcher() { + + } + + public MicrosoftGraphFetcher(MsGraphFetcherConfig msGraphFetcherConfig) { + this.msGraphFetcherConfig = msGraphFetcherConfig; + } + + /** + * Set seconds to throttle retries as a comma-delimited list, e.g.: 30,60,120,600 + * @param commaDelimitedLongs + * @throws TikaConfigException + */ + @Field + public void setThrottleSeconds(String commaDelimitedLongs) throws TikaConfigException { + String[] longStrings = commaDelimitedLongs.split(","); + long[] seconds = new long[longStrings.length]; + for (int i = 0; i < longStrings.length; i++) { + try { + seconds[i] = Long.parseLong(longStrings[i]); + } catch (NumberFormatException e) { + throw new TikaConfigException(e.getMessage()); + } + } + setThrottleSeconds(seconds); + } + public void setThrottleSeconds(long[] throttleSeconds) { + this.throttleSeconds = throttleSeconds; + } + + @Override + public void initialize(Map map) { + String[] scopes = msGraphFetcherConfig.getScopes().toArray(new String[0]); + if (msGraphFetcherConfig.getCredentials() instanceof ClientCertificateCredentialsConfig) { + ClientCertificateCredentialsConfig credentials = (ClientCertificateCredentialsConfig) msGraphFetcherConfig.getCredentials(); + graphClient = new GraphServiceClient(new ClientCertificateCredentialBuilder() + .clientId(credentials.getClientId()) + .tenantId(credentials.getTenantId()) + .pfxCertificate(new ByteArrayInputStream(credentials.getCertificateBytes())) + .clientCertificatePassword(credentials.getCertificatePassword()) + .build(), scopes); + } else if (msGraphFetcherConfig.getCredentials() instanceof ClientSecretCredentialsConfig) { + ClientSecretCredentialsConfig credentials = + (ClientSecretCredentialsConfig) msGraphFetcherConfig.getCredentials(); + graphClient = new GraphServiceClient( + new ClientSecretCredentialBuilder() + .tenantId(credentials.getTenantId()) + .clientId(credentials.getClientId()) + .clientSecret(credentials.getClientSecret()).build(), scopes); + } + } + + @Override + public void checkInitialization(InitializableProblemHandler initializableProblemHandler) + throws TikaConfigException { + } + + @Override + public InputStream fetch(String fetchKey, Metadata metadata) throws TikaException, IOException { + int tries = 0; + Exception ex; + do { + try { + long start = System.currentTimeMillis(); + String[] fetchKeySplit = fetchKey.split(","); + String siteDriveId = fetchKeySplit[0]; + String driveItemId = fetchKeySplit[1]; + InputStream is = graphClient.drives().byDriveId(siteDriveId) + .items() + .byDriveItemId(driveItemId) + .content() + .get(); + + long elapsed = System.currentTimeMillis() - start; + LOGGER.debug("Total to fetch {}", elapsed); + return is; + } catch (Exception e) { + LOGGER.warn("Exception fetching on retry=" + tries, e); + ex = e; + } + LOGGER.warn("Sleeping for {} seconds before retry", throttleSeconds[tries]); + try { + Thread.sleep(throttleSeconds[tries]); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } while (++tries < throttleSeconds.length); + throw new TikaException("Could not parse " + fetchKey, ex); + } +} \ No newline at end of file diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java new file mode 100644 index 0000000000..e4204739ce --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java @@ -0,0 +1,40 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph.config; + +public abstract class AadCredentialConfigBase { + private String tenantId; + private String clientId; + + public String getTenantId() { + return tenantId; + } + + public AadCredentialConfigBase setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + public String getClientId() { + return clientId; + } + + public AadCredentialConfigBase setClientId(String clientId) { + this.clientId = clientId; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java new file mode 100644 index 0000000000..d9128373e9 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java @@ -0,0 +1,50 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph.config; + +public class Client2CertificateCredentialsConfig { + private String tenantId; + private String clientId; + private String clientSecret; + + public String getTenantId() { + return tenantId; + } + + public Client2CertificateCredentialsConfig setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + public String getClientId() { + return clientId; + } + + public Client2CertificateCredentialsConfig setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public String getClientSecret() { + return clientSecret; + } + + public Client2CertificateCredentialsConfig setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java new file mode 100644 index 0000000000..2927519f1d --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java @@ -0,0 +1,40 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph.config; + +public class ClientCertificateCredentialsConfig extends AadCredentialConfigBase { + private byte[] certificateBytes; + private String certificatePassword; + + public byte[] getCertificateBytes() { + return certificateBytes; + } + + public ClientCertificateCredentialsConfig setCertificateBytes(byte[] certificateBytes) { + this.certificateBytes = certificateBytes; + return this; + } + + public String getCertificatePassword() { + return certificatePassword; + } + + public ClientCertificateCredentialsConfig setCertificatePassword(String certificatePassword) { + this.certificatePassword = certificatePassword; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java new file mode 100644 index 0000000000..2989af9417 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java @@ -0,0 +1,30 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph.config; + +public class ClientSecretCredentialsConfig extends AadCredentialConfigBase { + private String clientSecret; + + public String getClientSecret() { + return clientSecret; + } + + public ClientSecretCredentialsConfig setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java new file mode 100644 index 0000000000..46e3658939 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java @@ -0,0 +1,65 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph.config; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +import java.util.ArrayList; +import java.util.List; + +public class MsGraphFetcherConfig extends AbstractConfig { + private long[] throttleSeconds; + private boolean spoolToTemp; + private AadCredentialConfigBase credentials; + + private List scopes = new ArrayList<>(); + public boolean isSpoolToTemp() { + return spoolToTemp; + } + + public MsGraphFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + return this; + } + + public long[] getThrottleSeconds() { + return throttleSeconds; + } + + public MsGraphFetcherConfig setThrottleSeconds(long[] throttleSeconds) { + this.throttleSeconds = throttleSeconds; + return this; + } + + public AadCredentialConfigBase getCredentials() { + return credentials; + } + + public MsGraphFetcherConfig setCredentials(AadCredentialConfigBase credentials) { + this.credentials = credentials; + return this; + } + + public List getScopes() { + return scopes; + } + + public MsGraphFetcherConfig setScopes(List scopes) { + this.scopes = scopes; + return this; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java new file mode 100644 index 0000000000..059a932651 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java @@ -0,0 +1,100 @@ +/* + * 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.tika.pipes.fetchers.microsoftgraph; + +import com.microsoft.graph.drives.DrivesRequestBuilder; +import com.microsoft.graph.drives.item.DriveItemRequestBuilder; +import com.microsoft.graph.drives.item.items.ItemsRequestBuilder; +import com.microsoft.graph.drives.item.items.item.DriveItemItemRequestBuilder; +import com.microsoft.graph.drives.item.items.item.content.ContentRequestBuilder; +import com.microsoft.graph.serviceclient.GraphServiceClient; +import org.apache.commons.io.IOUtils; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +@ExtendWith(MockitoExtension.class) +class MicrosoftGraphFetcherTest { + private static final Logger LOGGER = LoggerFactory.getLogger(MicrosoftGraphFetcherTest.class); + static byte[] certificateBytes = "test cert file here".getBytes(StandardCharsets.UTF_8); + static String certificatePassword = "somepasswordhere"; + static String clientId = "12312312-1234-1234-1234-112312312313"; + static String tenantId = "32132132-4332-5432-4321-121231231232"; + static String siteDriveId = "99999999-1234-1111-1111-12312312312"; + static String driveItemid = "asfsadfsadfsafdusahdfiuhfdsusadfjuafiagfaigf"; + + @Mock + GraphServiceClient graphClient; + @Spy + @SuppressWarnings("unused") + MsGraphFetcherConfig msGraphFetcherConfig = new MsGraphFetcherConfig() + .setCredentials(new ClientCertificateCredentialsConfig() + .setCertificateBytes(certificateBytes) + .setCertificatePassword(certificatePassword) + .setClientId(clientId) + .setTenantId(tenantId)) + .setScopes(Collections.singletonList(".default")); + + @Mock + DrivesRequestBuilder drivesRequestBuilder; + + @Mock + DriveItemRequestBuilder driveItemRequestBuilder; + + @Mock + ItemsRequestBuilder itemsRequestBuilder; + + @Mock + DriveItemItemRequestBuilder driveItemItemRequestBuilder; + + @Mock + ContentRequestBuilder contentRequestBuilder; + + @InjectMocks + MicrosoftGraphFetcher microsoftGraphFetcher; + + @Test + void fetch() throws Exception { + try (AutoCloseable ignored = MockitoAnnotations.openMocks(this)) { + Mockito.when(graphClient.drives()).thenReturn(drivesRequestBuilder); + Mockito.when(drivesRequestBuilder.byDriveId(siteDriveId)).thenReturn(driveItemRequestBuilder); + Mockito.when(driveItemRequestBuilder.items()).thenReturn(itemsRequestBuilder); + Mockito.when(itemsRequestBuilder.byDriveItemId(driveItemid)).thenReturn(driveItemItemRequestBuilder); + Mockito.when(driveItemItemRequestBuilder.content()).thenReturn(contentRequestBuilder); + String content = "content"; + Mockito.when(contentRequestBuilder.get()).thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + InputStream resultingInputStream = microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata()); + Assertions.assertEquals(content, IOUtils.toString(resultingInputStream, StandardCharsets.UTF_8)); + } + } +} \ No newline at end of file diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/resources/log4j2.xml b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..c88e66e99e --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/resources/log4j2.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file From 786771af337cdff61d5fbe59c1b858b2256503e5 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 28 Mar 2024 16:41:10 -0500 Subject: [PATCH 29/89] TIKA-4229 bump version --- .../tika-fetcher-microsoft-graph/pom.xml | 282 ++++++++++-------- .../microsoftgraph/MicrosoftGraphFetcher.java | 44 +-- .../config/MsGraphFetcherConfig.java | 5 +- .../MicrosoftGraphFetcherTest.java | 43 +-- 4 files changed, 206 insertions(+), 168 deletions(-) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml index e40c8354f9..6169c28582 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml @@ -19,133 +19,167 @@ --> - - tika-fetchers - org.apache.tika - 3.0.0-SNAPSHOT - - 4.0.0 + + tika-fetchers + org.apache.tika + 3.0.0-SNAPSHOT + + 4.0.0 - tika-fetcher-microsoft-graph - Microsoft Graph Tika Pipes Fetcher + tika-fetcher-microsoft-graph + Microsoft Graph Tika Pipes Fetcher - - 11 - 11 - UTF-8 - 1.11.0 - 6.4.0 - 1.1.1 - 5.9.2 - 3.3.1 - 5.3.1 - + + 11 + 11 + UTF-8 + 1.11.0 + 6.4.0 + 1.1.1 + 5.9.2 + 3.3.1 + 5.3.1 + 9.37.3 + - - - ${project.groupId} - tika-core - ${project.version} - - - com.microsoft.graph - microsoft-graph - ${microsoft-graph.version} - - - com.azure - azure-identity - ${azure-identity.version} - - - org.junit.jupiter - junit-jupiter-engine - ${junit-jupiter-engine.version} - test - - - org.mockito - mockito-core - test - - - org.mockito - mockito-junit-jupiter - ${mockito-junit-jupiter.version} - test - - + + + com.azure + azure-identity + ${azure-identity.version} + + + com.nimbusds + nimbus-jose-jwt + + + net.java.dev.jna + jna-platform + + + com.microsoft.azure + msal4j + + + + + ${project.groupId} + tika-core + ${project.version} + + + com.microsoft.graph + microsoft-graph + ${microsoft-graph.version} + + + com.nimbusds + nimbus-jose-jwt + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter-engine.version} + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} + test + + + com.nimbusds + nimbus-jose-jwt + ${nimbus-jose-jwt.version} + + - - - - org.apache.maven.plugins - maven-jar-plugin - - - - org.apache.tika.pipes.fetcher.s3 - - - - - - - test-jar - - - - - - maven-shade-plugin - ${maven.shade.version} - - - package - - shade - - - - false - - - - - *:* - - META-INF/* - LICENSE.txt - NOTICE.txt - - - - - - META-INF/LICENSE - target/classes/META-INF/LICENSE - - - META-INF/NOTICE - target/classes/META-INF/NOTICE - - - META-INF/DEPENDENCIES - target/classes/META-INF/DEPENDENCIES - - - - - - + + + + org.apache.maven.plugins + maven-jar-plugin + + + + org.apache.tika.pipes.fetcher.s3 + + + + + + + test-jar + + + + + + maven-shade-plugin + ${maven.shade.version} + + + package + + shade + + + + false + + + + + *:* + + META-INF/* + LICENSE.txt + NOTICE.txt + + + + + + META-INF/LICENSE + target/classes/META-INF/LICENSE + + + META-INF/NOTICE + target/classes/META-INF/NOTICE + + + META-INF/DEPENDENCIES + target/classes/META-INF/DEPENDENCIES + + + + + + - - + + - - 3.0.0-BETA-rc1 - - \ No newline at end of file + + 3.0.0-BETA-rc1 + + diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java index 771790692e..aae696ad2d 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -16,9 +16,17 @@ */ package org.apache.tika.pipes.fetchers.microsoftgraph; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + import com.azure.identity.ClientCertificateCredentialBuilder; import com.azure.identity.ClientSecretCredentialBuilder; import com.microsoft.graph.serviceclient.GraphServiceClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.apache.tika.config.Field; import org.apache.tika.config.Initializable; import org.apache.tika.config.InitializableProblemHandler; @@ -30,13 +38,6 @@ import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientSecretCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.Map; /** * Fetches files from Microsoft Graph API. @@ -58,6 +59,7 @@ public MicrosoftGraphFetcher(MsGraphFetcherConfig msGraphFetcherConfig) { /** * Set seconds to throttle retries as a comma-delimited list, e.g.: 30,60,120,600 + * * @param commaDelimitedLongs * @throws TikaConfigException */ @@ -74,6 +76,7 @@ public void setThrottleSeconds(String commaDelimitedLongs) throws TikaConfigExce } setThrottleSeconds(seconds); } + public void setThrottleSeconds(long[] throttleSeconds) { this.throttleSeconds = throttleSeconds; } @@ -82,19 +85,19 @@ public void setThrottleSeconds(long[] throttleSeconds) { public void initialize(Map map) { String[] scopes = msGraphFetcherConfig.getScopes().toArray(new String[0]); if (msGraphFetcherConfig.getCredentials() instanceof ClientCertificateCredentialsConfig) { - ClientCertificateCredentialsConfig credentials = (ClientCertificateCredentialsConfig) msGraphFetcherConfig.getCredentials(); - graphClient = new GraphServiceClient(new ClientCertificateCredentialBuilder() - .clientId(credentials.getClientId()) - .tenantId(credentials.getTenantId()) - .pfxCertificate(new ByteArrayInputStream(credentials.getCertificateBytes())) - .clientCertificatePassword(credentials.getCertificatePassword()) - .build(), scopes); + ClientCertificateCredentialsConfig credentials = + (ClientCertificateCredentialsConfig) msGraphFetcherConfig.getCredentials(); + graphClient = new GraphServiceClient( + new ClientCertificateCredentialBuilder().clientId(credentials.getClientId()) + .tenantId(credentials.getTenantId()).pfxCertificate( + new ByteArrayInputStream(credentials.getCertificateBytes())) + .clientCertificatePassword(credentials.getCertificatePassword()) + .build(), scopes); } else if (msGraphFetcherConfig.getCredentials() instanceof ClientSecretCredentialsConfig) { ClientSecretCredentialsConfig credentials = (ClientSecretCredentialsConfig) msGraphFetcherConfig.getCredentials(); graphClient = new GraphServiceClient( - new ClientSecretCredentialBuilder() - .tenantId(credentials.getTenantId()) + new ClientSecretCredentialBuilder().tenantId(credentials.getTenantId()) .clientId(credentials.getClientId()) .clientSecret(credentials.getClientSecret()).build(), scopes); } @@ -115,11 +118,8 @@ public InputStream fetch(String fetchKey, Metadata metadata) throws TikaExceptio String[] fetchKeySplit = fetchKey.split(","); String siteDriveId = fetchKeySplit[0]; String driveItemId = fetchKeySplit[1]; - InputStream is = graphClient.drives().byDriveId(siteDriveId) - .items() - .byDriveItemId(driveItemId) - .content() - .get(); + InputStream is = graphClient.drives().byDriveId(siteDriveId).items() + .byDriveItemId(driveItemId).content().get(); long elapsed = System.currentTimeMillis() - start; LOGGER.debug("Total to fetch {}", elapsed); @@ -137,4 +137,4 @@ public InputStream fetch(String fetchKey, Metadata metadata) throws TikaExceptio } while (++tries < throttleSeconds.length); throw new TikaException("Could not parse " + fetchKey, ex); } -} \ No newline at end of file +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java index 46e3658939..fe72c8c311 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java @@ -16,17 +16,18 @@ */ package org.apache.tika.pipes.fetchers.microsoftgraph.config; -import org.apache.tika.pipes.fetcher.config.AbstractConfig; - import java.util.ArrayList; import java.util.List; +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + public class MsGraphFetcherConfig extends AbstractConfig { private long[] throttleSeconds; private boolean spoolToTemp; private AadCredentialConfigBase credentials; private List scopes = new ArrayList<>(); + public boolean isSpoolToTemp() { return spoolToTemp; } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java index 059a932651..0fafdfa470 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java @@ -16,6 +16,11 @@ */ package org.apache.tika.pipes.fetchers.microsoftgraph; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + import com.microsoft.graph.drives.DrivesRequestBuilder; import com.microsoft.graph.drives.item.DriveItemRequestBuilder; import com.microsoft.graph.drives.item.items.ItemsRequestBuilder; @@ -23,9 +28,6 @@ import com.microsoft.graph.drives.item.items.item.content.ContentRequestBuilder; import com.microsoft.graph.serviceclient.GraphServiceClient; import org.apache.commons.io.IOUtils; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,10 +40,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; @ExtendWith(MockitoExtension.class) class MicrosoftGraphFetcherTest { @@ -57,13 +58,10 @@ class MicrosoftGraphFetcherTest { GraphServiceClient graphClient; @Spy @SuppressWarnings("unused") - MsGraphFetcherConfig msGraphFetcherConfig = new MsGraphFetcherConfig() - .setCredentials(new ClientCertificateCredentialsConfig() - .setCertificateBytes(certificateBytes) - .setCertificatePassword(certificatePassword) - .setClientId(clientId) - .setTenantId(tenantId)) - .setScopes(Collections.singletonList(".default")); + MsGraphFetcherConfig msGraphFetcherConfig = new MsGraphFetcherConfig().setCredentials( + new ClientCertificateCredentialsConfig().setCertificateBytes(certificateBytes) + .setCertificatePassword(certificatePassword).setClientId(clientId) + .setTenantId(tenantId)).setScopes(Collections.singletonList(".default")); @Mock DrivesRequestBuilder drivesRequestBuilder; @@ -87,14 +85,19 @@ class MicrosoftGraphFetcherTest { void fetch() throws Exception { try (AutoCloseable ignored = MockitoAnnotations.openMocks(this)) { Mockito.when(graphClient.drives()).thenReturn(drivesRequestBuilder); - Mockito.when(drivesRequestBuilder.byDriveId(siteDriveId)).thenReturn(driveItemRequestBuilder); + Mockito.when(drivesRequestBuilder.byDriveId(siteDriveId)) + .thenReturn(driveItemRequestBuilder); Mockito.when(driveItemRequestBuilder.items()).thenReturn(itemsRequestBuilder); - Mockito.when(itemsRequestBuilder.byDriveItemId(driveItemid)).thenReturn(driveItemItemRequestBuilder); + Mockito.when(itemsRequestBuilder.byDriveItemId(driveItemid)) + .thenReturn(driveItemItemRequestBuilder); Mockito.when(driveItemItemRequestBuilder.content()).thenReturn(contentRequestBuilder); String content = "content"; - Mockito.when(contentRequestBuilder.get()).thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); - InputStream resultingInputStream = microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata()); - Assertions.assertEquals(content, IOUtils.toString(resultingInputStream, StandardCharsets.UTF_8)); + Mockito.when(contentRequestBuilder.get()) + .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + InputStream resultingInputStream = + microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata()); + Assertions.assertEquals(content, + IOUtils.toString(resultingInputStream, StandardCharsets.UTF_8)); } } -} \ No newline at end of file +} From b376b325dee1dc20f64fe7ffd1ddfad78d9d5fbf Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 4 Apr 2024 07:43:23 -0500 Subject: [PATCH 30/89] jwt fetcher initial commit --- .../tika/pipes/fetcher/http/JwtGenerator.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java new file mode 100644 index 0000000000..13e9362705 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java @@ -0,0 +1,63 @@ +package org.apache.tika.pipes.fetcher.http; + +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +public class JwtGenerator { + public static void main(String[] args) throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + byte[] randomBytes = new byte[32]; + new SecureRandom().nextBytes(randomBytes); + System.out.println(jwt(randomBytes, "nick", "subject", 120)); + System.out.println(jwt(keyPairGenerator.generateKeyPair().getPrivate(), "nick", "subject", 120)); + } + + public static String jwt(byte[] secret, String issuer, String subject, + int expiresInSeconds) + throws JOSEException { + JWSSigner signer = new MACSigner(secret); + + JWTClaimsSet claimsSet = getJwtClaimsSet(issuer, subject, expiresInSeconds); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + + return signedJWT.serialize(); + } + + private static JWTClaimsSet getJwtClaimsSet(String issuer, String subject, int expiresInSeconds) { + return new JWTClaimsSet.Builder() + .subject(subject) + .issuer(issuer) + .expirationTime(Date.from(Instant.now().plus(expiresInSeconds, ChronoUnit.SECONDS))) + .build(); + } + + public static String jwt(PrivateKey privateKey, String issuer, String subject, + int expiresInSeconds) + throws JOSEException { + JWSSigner signer = new RSASSASigner(privateKey); + + JWTClaimsSet claimsSet = getJwtClaimsSet(issuer, subject, expiresInSeconds); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); + + signedJWT.sign(signer); + + return signedJWT.serialize(); + } +} From 4b83d390efdcb33973f371edd32e566a93a47b10 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 5 Apr 2024 16:17:47 -0500 Subject: [PATCH 31/89] add jwt fetching --- .../tika/pipes/fetcher/http/jwt/JwtCreds.java | 25 ++++++++++ .../fetcher/http/{ => jwt}/JwtGenerator.java | 50 +++++++++---------- .../fetcher/http/jwt/JwtPrivateKeyCreds.java | 43 ++++++++++++++++ .../fetcher/http/jwt/JwtSecretCreds.java | 14 ++++++ .../fetcher/http/jwt/JwtGeneratorTest.java | 41 +++++++++++++++ 5 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java rename tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/{ => jwt}/JwtGenerator.java (51%) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java new file mode 100644 index 0000000000..6ff445dfc8 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java @@ -0,0 +1,25 @@ +package org.apache.tika.pipes.fetcher.http.jwt; + +public abstract class JwtCreds { + private final String issuer; + private final String subject; + private final int expiresInSeconds; + + public JwtCreds(String issuer, String subject, int expiresInSeconds) { + this.issuer = issuer; + this.subject = subject; + this.expiresInSeconds = expiresInSeconds; + } + + public String getIssuer() { + return issuer; + } + + public String getSubject() { + return subject; + } + + public int getExpiresInSeconds() { + return expiresInSeconds; + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java similarity index 51% rename from tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java rename to tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java index 13e9362705..91b3e3295b 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/JwtGenerator.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java @@ -1,8 +1,5 @@ -package org.apache.tika.pipes.fetcher.http; +package org.apache.tika.pipes.fetcher.http.jwt; -import java.security.KeyPairGenerator; -import java.security.PrivateKey; -import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; @@ -17,21 +14,20 @@ import com.nimbusds.jwt.SignedJWT; public class JwtGenerator { - public static void main(String[] args) throws Exception { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); - byte[] randomBytes = new byte[32]; - new SecureRandom().nextBytes(randomBytes); - System.out.println(jwt(randomBytes, "nick", "subject", 120)); - System.out.println(jwt(keyPairGenerator.generateKeyPair().getPrivate(), "nick", "subject", 120)); + public static String jwt(JwtCreds jwtCreds) throws JOSEException { + if (jwtCreds instanceof JwtSecretCreds) { + return jwtHS256((JwtSecretCreds) jwtCreds); + } else { + return jwtRS256((JwtPrivateKeyCreds) jwtCreds); + } } - public static String jwt(byte[] secret, String issuer, String subject, - int expiresInSeconds) + public static String jwtHS256(JwtSecretCreds jwtSecretCreds) throws JOSEException { - JWSSigner signer = new MACSigner(secret); + JWSSigner signer = new MACSigner(jwtSecretCreds.getSecret()); - JWTClaimsSet claimsSet = getJwtClaimsSet(issuer, subject, expiresInSeconds); + JWTClaimsSet claimsSet = getJwtClaimsSet(jwtSecretCreds.getIssuer(), + jwtSecretCreds.getSubject(), jwtSecretCreds.getExpiresInSeconds()); SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); signedJWT.sign(signer); @@ -39,20 +35,12 @@ public static String jwt(byte[] secret, String issuer, String subject, return signedJWT.serialize(); } - private static JWTClaimsSet getJwtClaimsSet(String issuer, String subject, int expiresInSeconds) { - return new JWTClaimsSet.Builder() - .subject(subject) - .issuer(issuer) - .expirationTime(Date.from(Instant.now().plus(expiresInSeconds, ChronoUnit.SECONDS))) - .build(); - } - - public static String jwt(PrivateKey privateKey, String issuer, String subject, - int expiresInSeconds) + public static String jwtRS256(JwtPrivateKeyCreds jwtPrivateKeyCreds) throws JOSEException { - JWSSigner signer = new RSASSASigner(privateKey); + JWSSigner signer = new RSASSASigner(jwtPrivateKeyCreds.getPrivateKey()); - JWTClaimsSet claimsSet = getJwtClaimsSet(issuer, subject, expiresInSeconds); + JWTClaimsSet claimsSet = getJwtClaimsSet(jwtPrivateKeyCreds.getIssuer(), + jwtPrivateKeyCreds.getSubject(), jwtPrivateKeyCreds.getExpiresInSeconds()); SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); @@ -60,4 +48,12 @@ public static String jwt(PrivateKey privateKey, String issuer, String subject, return signedJWT.serialize(); } + + private static JWTClaimsSet getJwtClaimsSet(String issuer, String subject, int expiresInSeconds) { + return new JWTClaimsSet.Builder() + .subject(subject) + .issuer(issuer) + .expirationTime(Date.from(Instant.now().plus(expiresInSeconds, ChronoUnit.SECONDS))) + .build(); + } } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java new file mode 100644 index 0000000000..aac7f155d3 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java @@ -0,0 +1,43 @@ +package org.apache.tika.pipes.fetcher.http.jwt; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +import org.apache.tika.exception.TikaConfigException; + +public class JwtPrivateKeyCreds extends JwtCreds { + private final PrivateKey privateKey; + public JwtPrivateKeyCreds(PrivateKey privateKey, String issuer, String subject, + int expiresInSeconds) { + super(issuer, subject, expiresInSeconds); + this.privateKey = privateKey; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public static String convertPrivateKeyToBase64(PrivateKey privateKey) { + // Get the encoded form of the private key + byte[] privateKeyEncoded = privateKey.getEncoded(); + // Encode the byte array using Base64 + return Base64.getEncoder().encodeToString(privateKeyEncoded); + } + + public static PrivateKey convertBase64ToPrivateKey(String privateKeyBase64) + throws TikaConfigException { + try { + byte[] privateKeyEncoded = Base64.getDecoder().decode(privateKeyBase64); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyEncoded); + return keyFactory.generatePrivate(keySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new TikaConfigException("Could not convert private key base64 to PrivateKey", e); + } + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java new file mode 100644 index 0000000000..a8c121b23d --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java @@ -0,0 +1,14 @@ +package org.apache.tika.pipes.fetcher.http.jwt; + +public class JwtSecretCreds extends JwtCreds { + private final byte[] secret; + public JwtSecretCreds(byte[] secret, String issuer, String subject, int expiresInSeconds) { + super(issuer, subject, expiresInSeconds); + this.secret = secret; + } + + public byte[] getSecret() { + return secret; + } + +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java new file mode 100644 index 0000000000..cb5b1ada53 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java @@ -0,0 +1,41 @@ +package org.apache.tika.pipes.fetcher.http.jwt; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.interfaces.RSAPublicKey; + +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class JwtGeneratorTest { + @Test + void jwtSecret() throws Exception { + byte[] randomBytes = new byte[32]; + new SecureRandom().nextBytes(randomBytes); + String jwt = JwtGenerator.jwtHS256(new JwtSecretCreds(randomBytes, "nick", "subject", + 120)); + SignedJWT signedJWT = SignedJWT.parse(jwt); + JWSVerifier verifier = new MACVerifier(randomBytes); + Assertions.assertTrue(signedJWT.verify(verifier)); + } + + @Test + void jwtPrivateKey() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + byte[] randomBytes = new byte[32]; + new SecureRandom().nextBytes(randomBytes); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + String jwt = JwtGenerator.jwtRS256( + new JwtPrivateKeyCreds(keyPair.getPrivate(), "nick", + "subject", 120)); + JWSVerifier verifier = new RSASSAVerifier((RSAPublicKey) keyPair.getPublic()); + SignedJWT signedJWT = SignedJWT.parse(jwt); + Assertions.assertTrue(signedJWT.verify(verifier)); + } +} From a3edd8ec66f7252a3183def88cc1297c8e0d0597 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 5 Apr 2024 16:34:38 -0500 Subject: [PATCH 32/89] jwt generation --- .../tika/pipes/fetcher/http/HttpFetcher.java | 43 +++++++++++++++++++ .../pipes/fetcher/http/jwt/JwtGenerator.java | 13 ++++-- .../fetcher/http/jwt/JwtGeneratorTest.java | 9 ++-- 3 files changed, 56 insertions(+), 9 deletions(-) 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 398ace7ed3..ed865d8a78 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 @@ -69,8 +69,14 @@ import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; +<<<<<<< HEAD import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; +======= +import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; +import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; +import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; +>>>>>>> 819e9320c (jwt generation) import org.apache.tika.utils.StringUtils; /** @@ -155,7 +161,19 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { private int maxErrMsgSize = 10000; //httpHeaders to capture in the metadata +<<<<<<< HEAD private Set httpHeaders = new HashSet<>(); +======= + private final Set httpHeaders = new HashSet<>(); + + private String jwtIssuer; + private String jwtSubject; + private int jwtExpiresInSeconds; + private String jwtSecret; + private String jwtPrivateKeyBase64; + + JwtGenerator jwtGenerator; +>>>>>>> 819e9320c (jwt generation) //When making the request, what User-Agent is sent. //By default httpclient adds e.g. "Apache-HttpClient/4.5.13 (Java/x.y.z)" @@ -170,7 +188,20 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC .setMaxRedirects(maxRedirects) .setRedirectsEnabled(true).build(); get.setConfig(requestConfig); +<<<<<<< HEAD putAdditionalHeadersOnRequest(parseContext, get); +======= + if (!StringUtils.isBlank(userAgent)) { + get.setHeader(USER_AGENT, userAgent); + } + if (jwtGenerator != null) { + try { + get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); + } catch (JOSEException e) { + throw new TikaException("Could not generate JWT", e); + } + } +>>>>>>> 819e9320c (jwt generation) return execute(get, metadata, httpClient, true); } @@ -479,6 +510,18 @@ public void initialize(Map params) throws TikaConfigException { HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); +<<<<<<< HEAD +======= + if (!StringUtils.isBlank(jwtPrivateKeyBase64)) { + PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(jwtPrivateKeyBase64); + jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, jwtIssuer, jwtSubject, + jwtExpiresInSeconds)); + } else if (!StringUtils.isBlank(jwtSecret)) { + jwtGenerator = new JwtGenerator(new JwtSecretCreds(jwtSecret.getBytes(StandardCharsets.UTF_8), + jwtIssuer, + jwtSubject, jwtExpiresInSeconds)); + } +>>>>>>> 819e9320c (jwt generation) } @Override diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java index 91b3e3295b..4ab4b8214a 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java @@ -14,7 +14,12 @@ import com.nimbusds.jwt.SignedJWT; public class JwtGenerator { - public static String jwt(JwtCreds jwtCreds) throws JOSEException { + JwtCreds jwtCreds; + public JwtGenerator(JwtCreds jwtCreds) { + this.jwtCreds = jwtCreds; + } + + public String jwt() throws JOSEException { if (jwtCreds instanceof JwtSecretCreds) { return jwtHS256((JwtSecretCreds) jwtCreds); } else { @@ -22,7 +27,7 @@ public static String jwt(JwtCreds jwtCreds) throws JOSEException { } } - public static String jwtHS256(JwtSecretCreds jwtSecretCreds) + String jwtHS256(JwtSecretCreds jwtSecretCreds) throws JOSEException { JWSSigner signer = new MACSigner(jwtSecretCreds.getSecret()); @@ -35,7 +40,7 @@ public static String jwtHS256(JwtSecretCreds jwtSecretCreds) return signedJWT.serialize(); } - public static String jwtRS256(JwtPrivateKeyCreds jwtPrivateKeyCreds) + String jwtRS256(JwtPrivateKeyCreds jwtPrivateKeyCreds) throws JOSEException { JWSSigner signer = new RSASSASigner(jwtPrivateKeyCreds.getPrivateKey()); @@ -49,7 +54,7 @@ public static String jwtRS256(JwtPrivateKeyCreds jwtPrivateKeyCreds) return signedJWT.serialize(); } - private static JWTClaimsSet getJwtClaimsSet(String issuer, String subject, int expiresInSeconds) { + private JWTClaimsSet getJwtClaimsSet(String issuer, String subject, int expiresInSeconds) { return new JWTClaimsSet.Builder() .subject(subject) .issuer(issuer) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java index cb5b1ada53..0e2769d99f 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java @@ -17,8 +17,8 @@ class JwtGeneratorTest { void jwtSecret() throws Exception { byte[] randomBytes = new byte[32]; new SecureRandom().nextBytes(randomBytes); - String jwt = JwtGenerator.jwtHS256(new JwtSecretCreds(randomBytes, "nick", "subject", - 120)); + String jwt = new JwtGenerator(new JwtSecretCreds(randomBytes, "nick", "subject", + 120)).jwt(); SignedJWT signedJWT = SignedJWT.parse(jwt); JWSVerifier verifier = new MACVerifier(randomBytes); Assertions.assertTrue(signedJWT.verify(verifier)); @@ -31,9 +31,8 @@ void jwtPrivateKey() throws Exception { byte[] randomBytes = new byte[32]; new SecureRandom().nextBytes(randomBytes); KeyPair keyPair = keyPairGenerator.generateKeyPair(); - String jwt = JwtGenerator.jwtRS256( - new JwtPrivateKeyCreds(keyPair.getPrivate(), "nick", - "subject", 120)); + String jwt = new JwtGenerator(new JwtPrivateKeyCreds(keyPair.getPrivate(), "nick", + "subject", 120)).jwt(); JWSVerifier verifier = new RSASSAVerifier((RSAPublicKey) keyPair.getPublic()); SignedJWT signedJWT = SignedJWT.parse(jwt); Assertions.assertTrue(signedJWT.verify(verifier)); From 44962493f80564fd55a6d53a0f7cfa7ce4c425e0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 5 Apr 2024 16:38:43 -0500 Subject: [PATCH 33/89] jwt generation --- .../tika/pipes/fetcher/http/HttpFetcher.java | 397 ++++++++++-------- 1 file changed, 230 insertions(+), 167 deletions(-) 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 ed865d8a78..b64d6bc8a7 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 @@ -28,14 +28,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.util.HashSet; +import java.security.PrivateKey; +import java.util.ArrayList; 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 com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -60,6 +61,7 @@ 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; @@ -69,14 +71,10 @@ import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; -<<<<<<< HEAD -import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; -======= import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; ->>>>>>> 819e9320c (jwt generation) import org.apache.tika.utils.StringUtils; /** @@ -86,29 +84,14 @@ public class HttpFetcher extends AbstractFetcher implements Initializable, Range public HttpFetcher() { } - public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { - setConnectTimeout(httpFetcherConfig.getConnectTimeout()); - setRequestTimeout(httpFetcherConfig.getRequestTimeout()); - setSocketTimeout(httpFetcherConfig.getSocketTimeout()); - setOverallTimeout(httpFetcherConfig.getOverallTimeout()); - - setMaxErrMsgSize(httpFetcherConfig.getMaxErrMsgSize()); - setMaxConnections(httpFetcherConfig.getMaxConnections()); - setMaxConnectionsPerRoute(httpFetcherConfig.getMaxConnectionsPerRoute()); - setMaxRedirects(httpFetcherConfig.getMaxRedirects()); - setMaxSpoolSize(httpFetcherConfig.getMaxSpoolSize()); - - setHttpHeaders(httpFetcherConfig.getHeaders()); - setUserAgent(httpFetcherConfig.getUserAgent()); - setUserName(httpFetcherConfig.getUserName()); - setPassword(httpFetcherConfig.getPassword()); - setNtDomain(httpFetcherConfig.getNtDomain()); - setAuthScheme(httpFetcherConfig.getAuthScheme()); + private HttpFetcherConfig httpFetcherConfig = new HttpFetcherConfig(); + private HttpClientFactory httpClientFactory = new HttpClientFactory(); - setProxyHost(httpFetcherConfig.getProxyHost()); - setProxyPort(httpFetcherConfig.getProxyPort()); + public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { + this.httpFetcherConfig = httpFetcherConfig; } + public static String HTTP_HEADER_PREFIX = "http-header:"; public static String HTTP_FETCH_PREFIX = "http-connection:"; @@ -116,123 +99,109 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { /** * http status code */ - public static Property HTTP_STATUS_CODE = - Property.externalInteger(HTTP_HEADER_PREFIX + "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"); + 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_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_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_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_ENCODING = Property.externalText(HTTP_HEADER_PREFIX + "content-encoding"); - public static Property HTTP_CONTENT_TYPE = - Property.externalText(HTTP_HEADER_PREFIX + "content-type"); + public static Property HTTP_CONTENT_TYPE = Property.externalText(HTTP_HEADER_PREFIX + "content-type"); private static String USER_AGENT = "User-Agent"; Logger LOG = LoggerFactory.getLogger(HttpFetcher.class); - private HttpClientFactory httpClientFactory = new HttpClientFactory(); private HttpClient httpClient; //back-off client that disables compression private HttpClient noCompressHttpClient; - 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 -<<<<<<< HEAD - private Set httpHeaders = new HashSet<>(); -======= - private final Set httpHeaders = new HashSet<>(); - - private String jwtIssuer; - private String jwtSubject; - private int jwtExpiresInSeconds; - private String jwtSecret; - private String jwtPrivateKeyBase64; JwtGenerator jwtGenerator; ->>>>>>> 819e9320c (jwt generation) - - //When making the request, what User-Agent is sent. - //By default httpclient adds e.g. "Apache-HttpClient/4.5.13 (Java/x.y.z)" - private String userAgent = null; - @Override - public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException { + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); - RequestConfig requestConfig = - RequestConfig.custom() - .setMaxRedirects(maxRedirects) - .setRedirectsEnabled(true).build(); + RequestConfig requestConfig = RequestConfig + .custom() + .setMaxRedirects(httpFetcherConfig.getMaxRedirects()) + .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) + .build(); get.setConfig(requestConfig); -<<<<<<< HEAD - putAdditionalHeadersOnRequest(parseContext, get); -======= - if (!StringUtils.isBlank(userAgent)) { - get.setHeader(USER_AGENT, userAgent); - } - if (jwtGenerator != null) { - try { - get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); - } catch (JOSEException e) { - throw new TikaException("Could not generate JWT", e); - } - } ->>>>>>> 819e9320c (jwt generation) + putAdditionalHeadersOnRequest(get, metadata); return execute(get, metadata, httpClient, true); } @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, ParseContext parseContext) - throws IOException { + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, + ParseContext parseContext) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); - putAdditionalHeadersOnRequest(parseContext, get); + putAdditionalHeadersOnRequest(get, metadata); get.setHeader("Range", "bytes=" + startRange + "-" + endRange); return execute(get, metadata, httpClient, true); } - private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet get) { - if (!StringUtils.isBlank(userAgent)) { - get.setHeader(USER_AGENT, userAgent); + private void putAdditionalHeadersOnRequest(HttpGet httpGet, Metadata requestMetadata) throws TikaException { + if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { + httpGet.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } - AdditionalHttpHeaders additionalHttpHeaders = parseContext.get(AdditionalHttpHeaders.class); - if (additionalHttpHeaders != null) { - additionalHttpHeaders - .getHeaders() - .forEach(get::setHeader); + if (requestMetadata != null) { + String [] httpRequestHeaders = requestMetadata.getValues("httpRequestHeaders"); + if (httpRequestHeaders != null) { + for (String httpRequestHeader : httpRequestHeaders) { + placeHeaderOnGetRequest(httpGet, httpRequestHeader); + } + } + } + if (jwtGenerator != null) { + try { + httpGet.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); + } catch (JOSEException e) { + throw new TikaException("Could not generate JWT", e); + } } + placeHeadersOnGetRequest(httpGet); } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, - boolean retryOnBadLength) throws IOException { + private void placeHeadersOnGetRequest(HttpGet httpGet) { + if (httpFetcherConfig.getHttpRequestHeaders() != null) { + for (String httpRequestHeader : httpFetcherConfig.getHttpRequestHeaders()) { + placeHeaderOnGetRequest(httpGet, httpRequestHeader); + } + } + } + + private void placeHeaderOnGetRequest(HttpGet httpGet, String httpRequestHeader) { + int idxOfEquals = httpRequestHeader.indexOf(':'); + if (idxOfEquals == -1) { + return; + } + String headerKey = httpRequestHeader + .substring(0, idxOfEquals) + .trim(); + String headerValue = httpRequestHeader + .substring(idxOfEquals + 1) + .trim(); + httpGet.setHeader(headerKey, headerValue); + } + + + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; final AtomicBoolean timeout = new AtomicBoolean(false); Timer timer = null; + long overallTimeout = httpFetcherConfig.getOverallTimeout() == null ? -1 : httpFetcherConfig.getOverallTimeout(); try { if (overallTimeout > -1) { TimerTask task = new TimerTask() { @@ -250,29 +219,35 @@ public void run() { } response = client.execute(get, context); - updateMetadata(get.getURI().toString(), response, context, metadata); + updateMetadata(get + .getURI() + .toString(), response, context, metadata); - int code = response.getStatusLine().getStatusCode(); + int code = response + .getStatusLine() + .getStatusCode(); + LOG.info("Fetch id {} status code {}", get.getURI(), code); if (code < 200 || code > 299) { - throw new IOException("bad status code: " + code + " :: " + - responseToString(response)); + throw new IOException("bad status code: " + code + " :: " + responseToString(response)); } - try (InputStream is = response.getEntity().getContent()) { + try (InputStream is = response + .getEntity() + .getContent()) { return spool(is, metadata); } } catch (ConnectionClosedException e) { - if (retryOnBadLength && e.getMessage() != null && e.getMessage().contains("Premature " + - "end of " + - "Content-Length delimited message")) { + if (retryOnBadLength && e.getMessage() != null && e + .getMessage() + .contains("Premature " + "end of " + "Content-Length delimited message")) { //one trigger for this is if the server sends the uncompressed length //and then compresses the stream. See HTTPCLIENT-2176 - LOG.warn("premature end of content-length delimited message; retrying with " + - "content compression disabled for {}", get.getURI()); + LOG.warn("premature end of content-length delimited message; retrying with " + "content compression" + + " disabled for {}", get.getURI()); return execute(get, metadata, noCompressHttpClient, false); } throw e; - } catch (IOException e) { + } catch (IOException e) { if (timeout.get()) { throw new TikaTimeoutException("Overall timeout after " + overallTimeout + "ms"); } else { @@ -297,12 +272,12 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep long start = System.currentTimeMillis(); TemporaryResources tmp = new TemporaryResources(); Path tmpFile = tmp.createTempFile(metadata); - if (maxSpoolSize < 0) { + if (httpFetcherConfig.getMaxSpoolSize() < 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) { + long totalRead = IOUtils.copyLarge(content, os, 0, httpFetcherConfig.getMaxSpoolSize()); + if (totalRead == httpFetcherConfig.getMaxSpoolSize() && content.read() != -1) { metadata.set(HTTP_FETCH_TRUNCATED, "true"); } } @@ -312,31 +287,38 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep return TikaInputStream.get(tmpFile, metadata, tmp); } - private void updateMetadata(String url, HttpResponse response, HttpClientContext context, - Metadata metadata) { + private void updateMetadata(String url, HttpResponse response, HttpClientContext context, Metadata metadata) { if (response == null) { return; } if (response.getStatusLine() != null) { - metadata.set(HTTP_STATUS_CODE, response.getStatusLine().getStatusCode()); + metadata.set(HTTP_STATUS_CODE, response + .getStatusLine() + .getStatusCode()); } HttpEntity entity = response.getEntity(); if (entity != null && entity.getContentEncoding() != null) { - metadata.set(HTTP_CONTENT_ENCODING, entity.getContentEncoding().getValue()); + metadata.set(HTTP_CONTENT_ENCODING, entity + .getContentEncoding() + .getValue()); } if (entity != null && entity.getContentType() != null) { - metadata.set(HTTP_CONTENT_TYPE, entity.getContentType().getValue()); + 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()); + //load response headers + if (httpFetcherConfig.getHttpHeaders() != null) { + for (String h : httpFetcherConfig.getHttpHeaders()) { + 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()); + } } } } @@ -362,13 +344,12 @@ private void updateMetadata(String url, HttpResponse response, HttpClientContext HttpConnection connection = context.getConnection(); if (connection instanceof HttpInetConnection) { try { - InetAddress inetAddress = ((HttpInetConnection)connection).getRemoteAddress(); + InetAddress inetAddress = ((HttpInetConnection) connection).getRemoteAddress(); if (inetAddress != null) { metadata.set(HTTP_TARGET_IP_ADDRESS, inetAddress.getHostAddress()); } } catch (ConnectionShutdownException e) { - LOG.warn("connection shutdown while trying to get target URL: " + - url); + LOG.warn("connection shutdown while trying to get target URL: " + url); } } } @@ -377,14 +358,18 @@ private String responseToString(HttpResponse response) { if (response.getEntity() == null) { return ""; } - try (InputStream is = response.getEntity().getContent()) { - UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream.builder().get(); - IOUtils.copyLarge(is, bos, 0, maxErrMsgSize); + try (InputStream is = response + .getEntity() + .getContent()) { + UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream + .builder() + .get(); + IOUtils.copyLarge(is, bos, 0, httpFetcherConfig.getMaxErrMsgSize()); return bos.toString(StandardCharsets.UTF_8); } catch (IOException e) { LOG.warn("IOException trying to read error message", e); return ""; - } catch (NullPointerException e ) { + } catch (NullPointerException e) { return ""; } finally { EntityUtils.consumeQuietly(response.getEntity()); @@ -394,75 +379,90 @@ private String responseToString(HttpResponse response) { @Field public void setUserName(String userName) { - httpClientFactory.setUserName(userName); + httpFetcherConfig.setUserName(userName); } @Field public void setPassword(String password) { - httpClientFactory.setPassword(password); + httpFetcherConfig.setPassword(password); } @Field public void setNtDomain(String domain) { - httpClientFactory.setNtDomain(domain); + httpFetcherConfig.setNtDomain(domain); } @Field public void setAuthScheme(String authScheme) { - httpClientFactory.setAuthScheme(authScheme); + httpFetcherConfig.setAuthScheme(authScheme); } @Field public void setProxyHost(String proxyHost) { - httpClientFactory.setProxyHost(proxyHost); + httpFetcherConfig.setProxyHost(proxyHost); } @Field public void setProxyPort(int proxyPort) { - httpClientFactory.setProxyPort(proxyPort); + httpFetcherConfig.setProxyPort(proxyPort); } @Field public void setConnectTimeout(int connectTimeout) { - httpClientFactory.setConnectTimeout(connectTimeout); + httpFetcherConfig.setConnectTimeout(connectTimeout); } @Field public void setRequestTimeout(int requestTimeout) { - httpClientFactory.setRequestTimeout(requestTimeout); + httpFetcherConfig.setRequestTimeout(requestTimeout); } @Field public void setSocketTimeout(int socketTimeout) { - httpClientFactory.setSocketTimeout(socketTimeout); + httpFetcherConfig.setSocketTimeout(socketTimeout); } @Field public void setMaxConnections(int maxConnections) { - httpClientFactory.setMaxConnections(maxConnections); + httpFetcherConfig.setMaxConnections(maxConnections); } @Field public void setMaxConnectionsPerRoute(int maxConnectionsPerRoute) { - httpClientFactory.setMaxConnectionsPerRoute(maxConnectionsPerRoute); + httpFetcherConfig.setMaxConnectionsPerRoute(maxConnectionsPerRoute); } /** * Set the maximum number of bytes to spool to a temp file. * If this value is -1, 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; + httpFetcherConfig.setMaxSpoolSize(maxSpoolSize); } @Field public void setMaxRedirects(int maxRedirects) { - this.maxRedirects = maxRedirects; + httpFetcherConfig.setMaxRedirects(maxRedirects); + } + + /** + * Which http request headers should we send in the http fetch requests. + * + * @param headers The headers to add to the HTTP GET requests. + */ + @Field + public void setHttpRequestHeaders(List headers) { + httpFetcherConfig.setHttpRequestHeaders(new ArrayList<>()); + if (headers != null) { + httpFetcherConfig + .getHttpRequestHeaders() + .addAll(headers); + } } /** @@ -473,8 +473,12 @@ public void setMaxRedirects(int maxRedirects) { */ @Field public void setHttpHeaders(List headers) { - this.httpHeaders.clear(); - this.httpHeaders.addAll(headers); + httpFetcherConfig.setHttpHeaders(new ArrayList<>()); + if (headers != null) { + httpFetcherConfig + .getHttpHeaders() + .addAll(headers); + } } /** @@ -485,12 +489,12 @@ public void setHttpHeaders(List headers) { */ @Field public void setOverallTimeout(long overallTimeout) { - this.overallTimeout = overallTimeout; + httpFetcherConfig.setOverallTimeout(overallTimeout); } @Field public void setMaxErrMsgSize(int maxErrMsgSize) { - this.maxErrMsgSize = maxErrMsgSize; + httpFetcherConfig.setMaxErrMsgSize(maxErrMsgSize); } /** @@ -501,36 +505,87 @@ public void setMaxErrMsgSize(int maxErrMsgSize) { */ @Field public void setUserAgent(String userAgent) { - this.userAgent = userAgent; + httpFetcherConfig.setUserAgent(userAgent); + } + + @Field + public void setJwtIssuer(String jwtIssuer) { + httpFetcherConfig.setJwtIssuer(jwtIssuer); + } + + @Field + public void setJwtSubject(String jwtSubject) { + httpFetcherConfig.setJwtSubject(jwtSubject); + } + + @Field + public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { + httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); + } + + @Field + public void setJwtSecret(String jwtSecret) { + httpFetcherConfig.setJwtSecret(jwtSecret); + } + + @Field + public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { + httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); } @Override public void initialize(Map params) throws TikaConfigException { + if (httpFetcherConfig.getSocketTimeout() != null) { + httpClientFactory.setSocketTimeout(httpFetcherConfig.getSocketTimeout()); + } + if (httpFetcherConfig.getRequestTimeout() != null) { + httpClientFactory.setRequestTimeout(httpFetcherConfig.getRequestTimeout()); + } + if (httpFetcherConfig.getConnectTimeout() != null) { + httpClientFactory.setSocketTimeout(httpFetcherConfig.getConnectTimeout()); + } + if (httpFetcherConfig.getMaxConnections() != null) { + httpClientFactory.setMaxConnections(httpFetcherConfig.getMaxConnections()); + } + if (httpFetcherConfig.getMaxConnectionsPerRoute() != null) { + httpClientFactory.setMaxConnectionsPerRoute(httpFetcherConfig.getMaxConnectionsPerRoute()); + } + if (!StringUtils.isBlank(httpFetcherConfig.getAuthScheme())) { + httpClientFactory.setUserName(httpFetcherConfig.getUserName()); + httpClientFactory.setPassword(httpFetcherConfig.getPassword()); + httpClientFactory.setAuthScheme(httpFetcherConfig.getAuthScheme()); + if (httpFetcherConfig.getNtDomain() != null) { + httpClientFactory.setNtDomain(httpFetcherConfig.getNtDomain()); + } + } + if (!StringUtils.isBlank(httpFetcherConfig.getProxyHost())) { + httpClientFactory.setProxyHost(httpFetcherConfig.getProxyHost()); + httpClientFactory.setProxyPort(httpFetcherConfig.getProxyPort()); + } httpClient = httpClientFactory.build(); HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); -<<<<<<< HEAD -======= - if (!StringUtils.isBlank(jwtPrivateKeyBase64)) { - PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(jwtPrivateKeyBase64); - jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, jwtIssuer, jwtSubject, - jwtExpiresInSeconds)); - } else if (!StringUtils.isBlank(jwtSecret)) { - jwtGenerator = new JwtGenerator(new JwtSecretCreds(jwtSecret.getBytes(StandardCharsets.UTF_8), - jwtIssuer, - jwtSubject, jwtExpiresInSeconds)); + + if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); + jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { + jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig + .getJwtSecret() + .getBytes(StandardCharsets.UTF_8), httpFetcherConfig.getJwtIssuer(), httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); } ->>>>>>> 819e9320c (jwt generation) } @Override - public void checkInitialization(InitializableProblemHandler problemHandler) - throws TikaConfigException { + public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { + if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + "specified. Only one or the other is supported"); + } } - // For test purposes - void setHttpClientFactory(HttpClientFactory httpClientFactory) { + public void setHttpClientFactory(HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } @@ -541,4 +596,12 @@ public void setHttpClient(HttpClient httpClient) { public HttpClient getHttpClient() { return httpClient; } + + public HttpFetcherConfig getHttpFetcherConfig() { + return httpFetcherConfig; + } + + public void setHttpFetcherConfig(HttpFetcherConfig httpFetcherConfig) { + this.httpFetcherConfig = httpFetcherConfig; + } } From 60f238590d8f233ed174be1e785ebb88d66c3e1c Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 10 Apr 2024 17:21:03 -0500 Subject: [PATCH 34/89] jwt config --- .../http/config/HttpFetcherConfig.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java index 2683e7f552..a5bed636b7 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -38,6 +38,12 @@ public class HttpFetcherConfig extends AbstractConfig { private long overallTimeout; private int maxErrMsgSize; private String userAgent; + private String jwtIssuer; + private String jwtSubject; + private int jwtExpiresInSeconds; + private String jwtSecret; + private String jwtPrivateKeyBase64; + public String getUserName() { return userName; @@ -191,4 +197,49 @@ public HttpFetcherConfig setUserAgent(String userAgent) { this.userAgent = userAgent; return this; } + + public String getJwtIssuer() { + return jwtIssuer; + } + + public HttpFetcherConfig setJwtIssuer(String jwtIssuer) { + this.jwtIssuer = jwtIssuer; + return this; + } + + public String getJwtSubject() { + return jwtSubject; + } + + public HttpFetcherConfig setJwtSubject(String jwtSubject) { + this.jwtSubject = jwtSubject; + return this; + } + + public int getJwtExpiresInSeconds() { + return jwtExpiresInSeconds; + } + + public HttpFetcherConfig setJwtExpiresInSeconds(int jwtExpiresInSeconds) { + this.jwtExpiresInSeconds = jwtExpiresInSeconds; + return this; + } + + public String getJwtSecret() { + return jwtSecret; + } + + public HttpFetcherConfig setJwtSecret(String jwtSecret) { + this.jwtSecret = jwtSecret; + return this; + } + + public String getJwtPrivateKeyBase64() { + return jwtPrivateKeyBase64; + } + + public HttpFetcherConfig setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { + this.jwtPrivateKeyBase64 = jwtPrivateKeyBase64; + return this; + } } From 568f3d845ef63da7c6aafb986ddd33255d78693d Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 12 Apr 2024 14:52:34 -0500 Subject: [PATCH 35/89] remaining merges to get code up to date --- .../tika/pipes/fetcher/http/jwt/JwtCreds.java | 16 ++++++++++++++++ .../pipes/fetcher/http/jwt/JwtGenerator.java | 16 ++++++++++++++++ .../fetcher/http/jwt/JwtPrivateKeyCreds.java | 16 ++++++++++++++++ .../pipes/fetcher/http/jwt/JwtSecretCreds.java | 16 ++++++++++++++++ .../pipes/fetcher/http/jwt/JwtGeneratorTest.java | 16 ++++++++++++++++ 5 files changed, 80 insertions(+) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java index 6ff445dfc8..ed783e7338 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtCreds.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.jwt; public abstract class JwtCreds { diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java index 4ab4b8214a..c8e7bdf2b7 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGenerator.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.jwt; import java.time.Instant; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java index aac7f155d3..149e74f5a5 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtPrivateKeyCreds.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.jwt; import java.security.KeyFactory; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java index a8c121b23d..f159cce3a7 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/jwt/JwtSecretCreds.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.jwt; public class JwtSecretCreds extends JwtCreds { diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java index 0e2769d99f..62aa082adb 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/jwt/JwtGeneratorTest.java @@ -1,3 +1,19 @@ +/* + * 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.tika.pipes.fetcher.http.jwt; import java.security.KeyPair; From 7278437cde22d43e9375bf37b516a5a4796987c6 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 22 Apr 2024 09:44:28 -0500 Subject: [PATCH 36/89] add start of a test scenario --- tika-pipes/tika-grpc/pom.xml | 8 +- .../tika/pipes/grpc/TikaGrpcServer.java | 12 +- .../apache/tika/pipes/grpc/HttpLoadTest.java | 130 ++++++++++++++++++ .../tika/pipes/grpc/TikaGrpcServerTest.java | 4 +- .../src/test/resources/test-files/014760.docx | Bin 0 -> 39631 bytes .../src/test/resources/test-files/017091.docx | Bin 0 -> 37440 bytes .../src/test/resources/test-files/017097.docx | Bin 0 -> 64325 bytes .../src/test/resources/test-files/018367.docx | Bin 0 -> 203043 bytes 8 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java create mode 100644 tika-pipes/tika-grpc/src/test/resources/test-files/014760.docx create mode 100644 tika-pipes/tika-grpc/src/test/resources/test-files/017091.docx create mode 100644 tika-pipes/tika-grpc/src/test/resources/test-files/017097.docx create mode 100644 tika-pipes/tika-grpc/src/test/resources/test-files/018367.docx diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 0dda48f302..def351e54a 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -173,7 +173,7 @@ org.apache.tomcat annotations-api 6.0.53 - provided + provided org.mockito @@ -202,6 +202,12 @@ com.asarkar.grpc grpc-test ${asarkar-grpc-test.version} + test + + + org.eclipse.jetty + jetty-server + test diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 626092f19f..67ca4a80c3 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -33,7 +33,7 @@ public class TikaGrpcServer { private Server server; private static String tikaConfigPath; - private void start() throws Exception { + public void start() throws Exception { /* The port on which the server should run */ int port = Integer.parseInt(System.getProperty("server.port", "50051")); server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) @@ -54,7 +54,7 @@ private void start() throws Exception { })); } - private void stop() throws InterruptedException { + public void stop() throws InterruptedException { if (server != null) { server.shutdown().awaitTermination(30, TimeUnit.SECONDS); } @@ -63,7 +63,7 @@ private void stop() throws InterruptedException { /** * Await termination on the main thread since the grpc library uses daemon threads. */ - private void blockUntilShutdown() throws InterruptedException { + public void blockUntilShutdown() throws InterruptedException { if (server != null) { server.awaitTermination(); } @@ -78,8 +78,12 @@ public static void main(String[] args) throws Exception { System.exit(1); } tikaConfigPath = args[0]; - final TikaGrpcServer server = new TikaGrpcServer(); + TikaGrpcServer server = new TikaGrpcServer(); server.start(); server.blockUntilShutdown(); } + + public static void setTikaConfigPath(String tikaConfigPath) { + TikaGrpcServer.tikaConfigPath = tikaConfigPath; + } } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java new file mode 100644 index 0000000000..840753c486 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java @@ -0,0 +1,130 @@ +package org.apache.tika.pipes.grpc; + +import java.io.File; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import io.grpc.Grpc; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import org.apache.commons.io.FileUtils; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.util.resource.PathResource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.apache.tika.FetchAndParseRequest; +import org.apache.tika.SaveFetcherReply; +import org.apache.tika.SaveFetcherRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.fetcher.http.HttpFetcher; + +class HttpLoadTest { + static File tikaConfigXmlTemplate = + Paths.get("src", "test", "resources", "tika-pipes-test-config.xml").toFile(); + static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); + + static TikaGrpcServer grpcServer; + static int grpcPort; + static String httpServerUrl; + + static TikaGrpc.TikaBlockingStub blockingStub; + String httpFetcherId = "httpFetcherIdHere"; + + List files = Arrays.asList("014760.docx", "017091.docx", "017097.docx", "018367.docx"); + + static int findAvailablePort() throws IOException { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } + } + + static Server httpServer; + static int httpServerPort; + + @BeforeAll + static void setUpHttpServer() throws Exception { + // Specify the folder from which files will be served + httpServerPort = findAvailablePort(); + httpServer = new Server(httpServerPort); + + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirAllowed(true); + resourceHandler.setBaseResource(new PathResource(Paths.get("src", "test", "resources", + "test-files"))); + httpServer.setHandler(resourceHandler); + grpcServer.start(); + + httpServerUrl = InetAddress.getLocalHost().getHostAddress() + ":" + httpServerPort; + } + + @BeforeAll + static void setUpGrpcServer() throws Exception { + setupTikaGrpcServer(); + } + + private static void setupTikaGrpcServer() throws Exception { + grpcPort = findAvailablePort(); + System.getProperty("server.port", String.valueOf(grpcPort)); + + FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); + TikaGrpcServer.setTikaConfigPath(tikaConfigXml.getAbsolutePath()); + grpcServer = new TikaGrpcServer(); + grpcServer.start(); + + String target = InetAddress.getLocalHost().getHostAddress() + ":" + grpcPort; + + ManagedChannel channel = + Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()).build(); + + blockingStub = TikaGrpc.newBlockingStub(channel); + } + + @AfterAll + static void stopHttpServer() throws Exception { + httpServer.stop(); + } + + @AfterAll + static void stopGrpcServer() throws Exception { + grpcServer.stop(); + } + + @BeforeEach + void createHttpFetcher() { + SaveFetcherRequest saveFetcherRequest = SaveFetcherRequest.newBuilder() + .setFetcherId(httpFetcherId) + .setFetcherClass(HttpFetcher.class.getName()) + .build(); + SaveFetcherReply saveFetcherReply = blockingStub.saveFetcher(saveFetcherRequest); + Assertions.assertEquals(saveFetcherReply.getFetcherId(), httpFetcherId); + } + + @Test + void testHttpFetchScenario() throws Exception { + for (String file : files) { + FetchAndParseRequest fetchAndParseRequest = FetchAndParseRequest.newBuilder() + .setFetchKey(httpServerUrl + "/" + file).build(); + + } + } + + // Method to send an HTTP GET request and return the response code + private int sendHttpRequest(String urlString) throws IOException { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + return connection.getResponseCode(); + } +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index da8a4b5b58..99acc92b66 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -21,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.time.Duration; @@ -70,8 +69,9 @@ public class TikaGrpcServerTest { Paths.get("src", "test", "resources", "tika-pipes-test-config.xml").toFile(); static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); + @BeforeAll - static void init() throws IOException { + static void init() throws Exception { FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); } diff --git a/tika-pipes/tika-grpc/src/test/resources/test-files/014760.docx b/tika-pipes/tika-grpc/src/test/resources/test-files/014760.docx new file mode 100644 index 0000000000000000000000000000000000000000..07bb0ea4ef153cdc3606d1d96af48fa5fd737f57 GIT binary patch literal 39631 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 literal 0 HcmV?d00001 diff --git a/tika-pipes/tika-grpc/src/test/resources/test-files/017091.docx b/tika-pipes/tika-grpc/src/test/resources/test-files/017091.docx new file mode 100644 index 0000000000000000000000000000000000000000..03919a30e1ffe259d5fd21065ab9173930f58b5a GIT binary patch literal 37440 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 I^7=qL0MFsDNB{r; literal 0 HcmV?d00001 diff --git a/tika-pipes/tika-grpc/src/test/resources/test-files/017097.docx b/tika-pipes/tika-grpc/src/test/resources/test-files/017097.docx new file mode 100644 index 0000000000000000000000000000000000000000..c3b9c4f454cfa0383e3019212fb6ce0b6a68e0bf GIT binary patch literal 64325 zcmeF1V{>jnyyautwr$&aV%xTD+qP}nHcyNb^Tf%CGw05&nYvRm-{4Na*e`nT?yla| ztA5r0wG?GQ!O(ypfS`bYfQW%kyo4YEfPsM4!GVBKfuKNiL>=s1&FozbRJ|O{T=W<` z?QDsP!9b`Afk6I!|Nq;6<3G@! z*FJE|B_u`-Ry9bj%;!JDm#|C7&^CSgJEXOosIOOsg=q)Isyf&=Zl5QC3^oaSD3x6? z5gm*LZ)fjmf)ncJ8XxCNt3#6o$j!qq0;0tM@{IqXBTq1F3QlD zk^+I{d_ZW*oW{ICMpvKLshni<(c2)ICUO!K>}bV{$u|?Qx>+M-pqBleP#^7Qe3GWC-!!k^A`p1ycM!bW4zg)A9K4jr>1Z z!v52(fwP&d3nRmSJpaF5{~L?+zid4=c^d#K3>SV2zHcS%J`o`F?BF;v-^j+|KQvI5)JS_$o-q=zjxcw zU5d})Li01Q`Y{S^t=Wx$fC~z3cU7&@vnu@~a6SLR?mS4`YN#TcoHbw|I+7Yb7@sF| z_+-XrWRA6+>oecAjycfpH@TccPwhEuX;>I#wu6MzOjYD$`04XA5!2-~A|p$h%K`v7 zN^(4UnOhL-`_3utL<<;Qm!5YR)uTd;b<;X!luL=QNj4HN;x0O*q*XWVL?evZn00!^ zo)~SVpJ9!8;g99q-+eq~fQdb1Xy!jU{bt7edH->kSFa%>yVdNRf}&6OM0BCQQ7wi3 zqO*^V$xPk1*D=0dXTu1Yr(oXB_G)F}#CuGPI?0Mum@Du#Ov0M`h)705s|fe<-GQ1T zg3LEH{*{dkCLvJ_mh&urEa!~#7k)&7G``rIq}O|oYzM8BU?ntpm$LmhCDE*06Yz-I zpx)GNdO8Ivjp-CbNa;kMIa_d%dlJ2D274R zgdZ!y!qCA5H`gm=*~Q5o!(^6Am!8vXsw!ZZB=NqPry~Z)EuBtj5#F@aOlt3RR}1E~ z-hbvpJ+VY9mQ)W=p$KIJ^rlXvl_<@w_RIRis3g(GQcTc4+(%4WT*h-jN#7{xN_xn; zxRp}}^0XL{Y+9fyU}(fE^)jQYm^GpA&RoZ??wXF~nGTD#`!XP9ORUbRumaOvmI4H;c~g$ZeDRf?c@<$97J_l`;mEXNWJL^D9zRc4VT*Bgl@}LO&^dG=2jwmCA=gx$Oz4 zL-f}pn9Wglq`~8a>EL5$fB|WG6|156U&MDZE^3*j*NGe`0E2hPf5zrt5rWa{e9jfPk~^w4fy1Cdlk? zeXz61?r%+T49i9WVQ-B(YwHYk+Ku?}gP`_pu#mp-8_6#K!YM~3tze)R?Nk>03D*iU zYh6lO{~`YvoN@`t5q0@5&^cj5A(A&=w2lJvLM}m+yg4?Z>gX2MEm7UjD^nTI-D7Pe z`D)mh;3nC4>C<&a4j09`E~t6GxJp%KRX;@spDmU1Q$O&bTqSmXp{?g*7m*TKw!;|< zqC&2c99!wp8bl{i?x1^DtIE_OVCB*Nq{^sl!u`Dgb`=cP(#BMi7XHMswuXX(KUWr> zlAwzLfAwqmepeL!3S7a`S1Q$Ad=N?~*ulJeVI=zHKoY|kLtbQ>mbzoxpVVMa1dApv zIlg~_*GAIlZs%vf5xPDBIeA1ttoXYDi~jbWg?wN2@nSjAI=^PyxAABdm>mfcZ;^&? z77`Y*oBaGC)0usGuE)O1n2NpOI*8&L^3^igE$fKzm@p3V@``~a47>7#mjrb#C6GEN zu)8OJDSpWqXgbUSkFzq03&=BOq$cC}9Wna8pwD#5!BP*;*|N~_{2-+ItcJ*_NqW>tF~xkPl}9kTMGiUIIM%Bt9%M=QF6PVs$K*cm zHO#;%eE=mY(h@93j(#EM{jnj#6HQebpeHQwE;(HH-8L1h^>rnVrFu!SB;sB#KY!3c zCj4)`Gx}v*KD-%V@yQ{gd2uY9xuPpwg{xa9p(?Q7Z&d9yUF)~QyM%*bA@8*W{8%>_ zN3>*H+hz&|kHR&kKt@Cbqr|XmsXiD#IAd7tZl!3D4(%{n&yp?Q(c!tvnLsvC_l6)W zpl<#AP}m3ysCyj`>@-Bw7)QbugEJGVc<3Ll)0gWDqFAl$EG!F}!jC@70&Vm6U|Ks`p1pj|U{^Y&!($%ojipz7Ty_rzWitn{v}mrT!QcWax+ z_Hz2_%?c~?0Z>qJBnDI?a3b5s!RYAcSnmbnsa^q#AkzR7Z3_$$P!O7M5(aeO7&1zxrOH4oRDe8^@^)o8WJe@br!sZG|03f zIGO<7&xW6qh7i9QRv-L}aF7nikS_s$y9gtX02fxK?Pe1&Tz!r1*QS#kAM+NI z*N(oM?hChW2}@49351B8XKS~PVuvGqRqy_u28X7-Cb@msUUjLAYoEGwJHQvR-hslS zuG>kIJ&T~zN(RE#?=Io(8;GS{o|Zj$JzGi63x!Lufg`aq`0f?HxraF5J;@2BIRelA zU(ft15Ww*SHH1gJYtoPU}G9h^rlvflMAhZgcZaM|mlf6kLG zdpj)z-`d6dI)~1Z&MG4Y_P6A6ar02#U(sV$yGNx|uiEndZo}gs3AhHKE7sjy-`M5Z zOF}lJOn8!gy>_X<%kM2Jf0S%z`l_hxujW3bzr#=TJCzJ!^-*{0xrQ{|`bCT*tXFI7 zvyVK?C3rnHywk6{OQe|2f*|%WoFa=!-*lCn6nwa>z=}4o9=wMJPIe7d%S7ffoXG5t zML~IciIe?4X|l!Ur}U*_VnjJi#*ov1ZVIyV3L#y9_ggUfBjO#N8p$Ux<@;0HuQTvB z_%4}kB+KTWm>&O2Wrsp2ByOleHE~XzE3{>~Z7; zkr&~+>X*M~!}sPJ1#PhzZ~SPAcBrY}t@_s-{XxT_$AWu^ zpNbO~&$&lB!4bmziG(j3(uCx5Q>$9t!j{UHjouG8nUuG3{w({Cc>ea3?{V(@^l5<-jqB0p#9>*O)@0AXH zbl7U+|7>O2+Zry89$kPVqqhIO^7&=|S~SOK74We;z9-KK26J<}jejJeEnx{2P_Pyr zV1AJG?T@RHKk$cQd)iZ(o+cUik~So;$&eayi1U>&FTgk z+AjN%ml`|lz97D%W6w?y+5uBiZ}okw@LXAZdD{~Kebe-0hY#kJwQRxTO;EdY?1|fa z>Y(Ww$6rc5-H(I7E3T!jyObwCPk3<+NDdO+@>1yCPm3E3*8VH>fYOy2HWz?6z~vC7 zInJp~Kk^cyS^(vDzYT4K^$4R8OdM>CsB~^NZOb?&71eqGp%aT`wKG`3OD8@_hHE># zC@3B}Dng2hRW1g_q)SFm5E*X4zEh5J$tVp3qT9@8&(pNvnjG*L1>}v3MYt8Nx6KX2 z^Y@G_aGswSxX{I)5}ch8NN?Z2L+)H+x21EWd)2q>R44QFqh}ore*M|Ip4zM7!KGm( z^*g$9xV&NQmk82NG1}hQ^JUNTxZuvScU$|j{aIKI7x}YPc%R{s&vAHtO9PpmNsS2> z6a@9FDJPqsIef7OvnKUXfckl7Z3$K@y&-zr(T&syAu|JXC4{3qotn$296K-GQqhoacrZbNjqLM;l+stfQygv? zny6l%Or{po(ApQVrEn*aob8I(s%UAX$o-!zC-2fXlr6hIk86I2|uDgrgk8MB-Y3d$U!Z)A!6pa7e#NRhXqJ63ycxq@H*P{f2hi;Aldww+`%glB}a1j z0Dk}iM?O=(B5pIz3=f|frc^hpZ_l}URApc6*%8`C;C=`+U+%F-<-lF1uW_FQrGF$2 zxduFJ2T?IpS}4ETUE7*=zlppt#XqREQ7L$=vHZ9pqXJca|cqK&?_R~XTceLy} z=9|ow&b9vRzIL|ssqSp@rWf!?#P@k49?ffZQnd+aoW!GZN@$UfPRDSR+T0?&P3O0! zs#X)*(cC)Pc_bKM&TQNg*8)a9vfytyUgG`4DE8wICH7M++T);97YIp?fDg(ZG0hPh zFBm2&C`NV&Cdw0}xF?I8ly5Y{!{a9H!OVxM&WTaw#-@}}v;*nH#DG5dqtEuv!N+g) zSvNZG-*bZ2AE&a~-#Xgen6Ti!+x&|I#tpa*_G9J$g3b%@3T$lG-`v;_1XLh^uL19Z z$vd><`7E_9jous%5ft3yU>|(=(~|pK{fgrW`y#RJ+7z{hE}oef2U;gOM-d)mM>vQl zNq;PL;v-y5xaWWy8t9rnMPV#`>1{kDs2|n-3ZoM@1xe6}mW;=gy07^|5<;OJK7i|I zI`Hbwxg+^PRP=pCGm-$B9PyB+4M!Eq3KGa# z3(?N2B`(`nQ86D6deZOV%q(ntoiKJWrUQdg5x1+G;Z)VjGEM7$;sPT`Pr^VkA~X$* z(=pEiQ#Rj8qb+)gyXau=tOid*-(|bkvdd<)nd+m0CoN0VWUlcrP)30#y+ax)41Xs$yc4>_wr#!Q3aep_Nm5lo29q4txhA( z50ByUxRqmP9SajkKN`Q*fz5~yJt!|oaA{TK#`Rq6#zhIynYgcSnmvwga!KI#r@mp0 zBL9|v<<9A-X(u0$5;`Vu4+xRlSxoIp@dd~gtU8-!FvTJODE zA2E3a5h)zi!j9%JpT(rcmKX%;uWXy#FL!d}XOW5R0&!AGhWH}MY}h3@;bd=kH^WaH z?K$&f? z(@B7oI9QbB)OCen*Oi!=K#rr&IV$eK1((nR)%XElA3Od4v89;`Cr%u!hOV4cOuJ8E z+KXgYX|=&iHqS;3t9Ci3pKS-1`&(QTO}wOq$KlIS{1tR~!EY0G`t8

q*#H9nnzbVMA| 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

y7KU98yV~v zNOs>sYa)<=@^^VNFz$R8^27SIrVUmnyohQ?+=*6sOSW_n_s~wv-r}a)thr&-@Y=jX z!i41}(IOUvx9~Rq)E5FeU5zJi1+6tA;A70tsK-ILo+qn$K`?HV`I}Padp|^(i&s7# zI$|ckKvdH!F8)OH&RNTmB+c^p zOHZDo@iEgcIY*NNPM0EQXcC1lP81rTTuGzqcw!7tjvYWX|Cd3S%54BenO`VmmACii z4{9m5YJhTB0XwRD-r=&fGVP;4In2HN1kNwy9LP#@R0sW8GeEx*vJl0jFngMk_K*=c zmk-D_XIh*-?7m_ePFD)?Pl}L!YDga5XTWlz^Tt>PiBXcqNC9k{Hp%7FEzj+Z8KFc9 zV7U(yAZ~T+yc9nFi(yoHvF0+-ppg6qEN8%S8ykjIeaen25VO)~u8Z}B?)UvCdMgU9 zVR@&#o^cMJPKg$A}R2s)`TX+FhuB?*Tv%pup6||25%k}5<4JHLVb;)@Tu-Yxi z%sIKD*@@zmnG)P|Nv7G(W}1s+mL6vRrRX(YrAuBr@?9o;s_WKKyN%v%o8*^ju!N%G zAMynppkcRq7Dju-iq%^#$15<^jlVfo!wY#0%@JuujiBQ@2^q0K`1CvE;d;13P*TQz zZ{0c}r@3JNa4irbw&a#TJD?_a41Mf`=Dlfywv%^Z)iJeWt=3!PzX~yEFn5q31GIQV zPPackoc~2eVP*(w1G-y!&>cB7hE*jCDW(^Ig~}Tf07vIJ8E#N_&L74Y3}PzXA0L4I zqJuO!d{yP87`eV-zKW^B?qC&=eiF`#Db2K=%I#^-AN#3TNJenz4J66=qn;+#Qd*;N zp$6&u=8IRayuTv_i~`Re+0E?{WiFJ9|fJTjwt;7e>R-MZnyj2H>>*3 z_z7?LA9l0-6W)Dxa=5p>vAetdVPCxzIfuN6Ylrp5zz<#9wewE%a8KSI)(h_Chph~@ z?E{OMB`Y|nb-*gwIxG#R^3L;qls6PZ;uGD^sW>R2Z$-#yc;+$j30a<*f-}>V&mKes zAaCLzKGAh?><7ptef3&S&@EZ&5dLA`A(b9_?f9T8e^{^cU4=ujv!AW%mnS$DD4%5D zpXH5_(5-ZH%(0*;P^S5ZaBin7pFI}P>u=#$fQ({je&G_yUMTrt=K*88_~$6@`R-w< z>p5}b;Q8kv&mCIc7+LsBccpM1#Bozbf-N^RW)g4jsl}QE9t!2b`1Lv@ z>Eh1r-qz{1xVW)aY~y0=e{AS7VWYsHn@fXKG{@47#X<}re%DJdU%~A5&aR^Gk>{8^ zJe|9Jr@Bl!5{ANCis;$5s^_&Fl(}nw8+6X2cg+>mk$Q3k^e}yUx-v z?(yQJ>~_5rcO`e*jdN+On8|t_`smrcOy}RRLCCNgMm2r< zq5iv2ZCi`Yzww+RncMUKwdlaAH!Ag``k`r9R@!>z`Xbm9@q7hy&GElh+75b;)>+i1-(%RYytvvw~GEtD}|b=Iv#6ujndnOuG?>eSD`p zJhkauQQ*3lDP8zdFs%Bt4h4R1#lcomo6w~S+dwd zi(-BnrDo!>bp4`gKz zCJbAf;`b#36zNOQgdL|wZQzcFDKquJUhWN0q&sWRR4h7R!U6Z84gt`5A(>oWUjp`& zMK7-B2y`t!eC(7YwG$*b^612sf$cYfE2*NaQJmnD)AW`y z(WdmY#Lu&MY^-|`Ww2pbm5~!e&o#Sp=xK3~baXi{J#*!g6W^JspCmKTW8}=2luk=( zT)Vvi3s`)9o+?wrH#}6t!DBI~9?4 zilZ@UJLA$Jk|&~$i7wv$h7?TJTKnMo=rZK2S$Um0|OFpp$u z$t#p7x4O>y{)ydSn_j~RQLeE_px17jCdb5<6#>e4d-qVXr z9J7s+vbeyL+lc_X-n(T7;2NnR!@3qUi48?7-s@-S6&Il&I6x8yZJDO~5miufA=Cl! zEuGH-9|!xMF{|(|Pe}5js%!;cnyw9+vb7*RRIRp~(coIf3nZ5i^z5qGSPI+aokRq9 zo4Q_9_0ZQp(sDU-2TyapRu{n#^mM0)mlgYl8fDShCqwq6eBf8k8fVt{=yAgdu0TR% zK565+u1l(58d%YB@&>!k2 zme+F=emL-WsK)@GzYT^ZxxSVijB~}ArH-O^&KbBCJki($9iG?yct-7S%61Of1luUY zHD!3*!D+^Z#CJUQ!FZ8tb(i)=i|+8~nRYa4By@1V)&Q32`9u^ z7L)=sF1~C!@?N(j+?|NQWy&AX73qQaOe8*w@f2i3XsQjgz*KL_Nxf2;T-6G-3!R97GO3oFKICDj&W_`WP#b(gqCrWLH99Rq@b#Wsk!= zYkR)!4gT-evQBEO+)07}0+Pi70z&;?Ygs#(7+D(8{%cQ9<6>!XrY2*(%7W5`u;l#R z!PPbwEQ0dF)1GNI7HB?!9Ew@?l+tgUN zgxM(4RX$=>Qfh#Eg6f*#E}q`Eb-3|Q0@Yml7bzz(j0<~LZj6{F@jcjPIW;d-Fl%~* ze*DfDtCTLDwJ@qv&4Vz+!?J&BTro4SU+%$*8DKrUikvWR{&lhiKfI%Ic=b+MIUf|x zniDd;r~9Q6XXWze9PDsP{kixPX7{4mH;*7c6S6#sdW>5^>aa$P&D}f%Ab3O7X|PgL z@xi5FvWp!TBC-XzD~_JRZ=8Fht@z`xVmecbjU5OIaNp?jA5WeitFw58h7huFc%P1+ zw)=J=dDVYRlv!p3>?_fBy`N1E;pOvYdEMSjqtCmj4Sv4#k#@bkxiTZp{3MwOr_FwQ zI2#GC=6QerC4zb-iL%ZwdIqZh}KoQC&~umLk=aO{~}K zKwp0F+`vP1;8!^EP*;Z-j3Xn0gvhm(AU6FYhBV3-u^%Wi_HRBhFy|NYCrggWFMbj{ z%11OpIFgi73mMw&7dR}P{WA{{=WYK;d;K%NrPn?16%6ziR%MYZ85VYrIZ z?Y``^gw0Rl}Gp{au#a4_8I|Pg+U0j z-^1{xX@=0Rf>Xi#9TOQ}#BBA&30=lc+WcX`Si8Q1OdcR?ll%xeX7Qu4472qT%kMw9`0F2CG4#3v?o}oXJQT$L}**H`xuW z5tZ~Y2R6QD^h)E11}bW(3bQbcDWZW;O=ncRjY`p-3X$s)zB3o7s`=|aEfxTAwp9`D z(qAhrFBg8bILWn0tX@*AKThfEs zuUSS@WOGu|?3)>w@@E|^kRQhrLnMr3`wM!rQ+p4zf)z_j=Wg{}o9?4wN1Ke5NOxjily}sy&%t1YQ_~P50kjK0=pK{xbuSP$tO9VO*8c}uDJCqsF&5=Jf#iL$5Tfq zMh3!}oQ(B`7Mv_{OMB``6;Of(pZN#b20dmdyd8J)-M5aYTQ;-lh^6RySc!de@3B+3 zeD<2L$7dqD{xzrr1(KBj1%m+`|Jm??f;?ZB0ziO({tz1A6K88XLt0BCLsLCkQ%k*{ zM)Y(vHda4FWu-;nVKDz(1}`QmC=V!?0fYet3fKn{2`T=m$Vw^-H8nMHb94Lq`|s}V z8XFs@rlyXKjeUN8Hl8@_0?q-}mk<#I3IWFv1ytY+EM;ZI1uSjoSm_x6bx0Y@=0YGK z5D+mzenrutfkY|$bUMT!{M?24OdoqGE$5aWc2|kEPc1u1(d9U!PcYi~@2~5LGc2~I z?h4GH=DG7c?bS)O{%9hXOGW%Yjj);Ezc>Ya4K|T!@(574>|Q~G&kiw$4?<*Lh$#5V zP8!~@{+Y|K)#>SVP1x=gokr6rE}Ki?#m&v)amlM~q%d>I;WeWu+d=M&D(J$?Z;wTb zy)kV#@u=sS$n}<~EbExMU1dhj_dYQr&cd=8=?4#g73dQSg>AFB> z&Qh4_Rdaz*;d)>Lu5fn-S^2E86<;)5x`Rej(sGHkAVEf7(<~Y3w$!41$=MY+%k-j4 zlQ^%AnQqnw>|oy6_WCX55-DBGsDw1#33sG*Vh^hzCxZlOQ>?ygofW@oSmw<}K}LT4 z?^z-Yp00B%T%=Fa@r6~JYezpASeba5Rn)V8oy(@#rx)>SRbLwsa-2zq$fBV(Z?m_8 z1M9Diu|;+@Cmh!N<~zESs?NI*!#33kRuX$Rag#qPFi%U=OK)ltuYZaLb;>;J0>#)> z-^K~BBm|%WNK8mtuVM&OT*jXdaX_TP&eEb$r4FMduITrC0A>}lX|H1-)Z%ve z<)*WVf<-%1(D(}*5a=?V^~3p(8ZwgAhIa*1fl-SbN4vV*cLsx#=BAe=PkP5vYRo1{ zyanA7=R2$Dg~`%75r4p6b$Xs{k_p>c5-m1wFw%|7I`aj8CEU%c^Gf7$(6pkAb*!Jn z<0SL2#|~vjTM6>qmklQ?B^TTXSfr~dM0L=CCw+@0Uh9=6Xa+0FE*Ud9a(dzS5QMbC zixjg113#fbGbI8rmlJydjg>-tW<+fLZA~}dC_S56N#j(=x`60ahG~rs)3q|C^3%1j zQEX-hK0?Hd;RfP;>>A0w=>|*h-5o5XkdTRiLc^|Gyre!!a6uoZahn9UyAY`S4+zXp%JkBqKwf=AZ!xp6yBA3) zM@rU0w-{AgI;kS+cCu7yyX?9=ogy~VPl;QxS>C1lS%WsFF@WxdB%>PEtNNF^`N3O+eckG8*tW~5> z`?_mRvRQ`RhLyP>$S_-w*&YP0`<(Zbx9^XgH=f~LPT*#4uY%nY@GVw3#dZw2IFL)2 z(Snji&R9(^KUZI%F*ER+K2L4>w}Dr)Jk8U(3?vE-OBBlk%-$jmt#i>)N{r5?^0_dv zPR#R}z?iMoS~S?O$9bA1Nk$fz#U}zXIs&QLRZA}^=xbN;_f+a)@{B|~6;q2bBXXw5 zz2^7|-i9$W!B&Jwf|Alib#YHS4p%`5i=r&}rRELgB%QLS# ztn=GcD&02EK0=}HW69r+En#D7i3r;2@D-XIQV;9R(T9~Ayw)g%jHauM(2IB%szJp+ zM`%J^02LU?r}zR%9r-Y-ER~wWEurqLfa29QW_>hODhm;DM4{r1nqe?*18K z4dA$assJ7LhyE5~jDHU?U~)j4e`!a>12ljI`ImMCK#Y%%51<`)c6R=Eh;<@Ubpy`% zcccu+!e0;@PwG+Fp+gxAPk8tZlTr>ftTdOYNr<&lZem`As_ynjx^F%bqa>luUDx!c z!<`3QN_e@tS{I>Z=o&86Li6OsmB)t_=EjX06d!Ez;TE)UvJB!Xr>Vu^rt8yQ#GEX_ zz2aL}#Q)-pz@p@4>GchfP8Sb~4o1cGL25jBj!(492SQv;rC@qt(6TJvvr&G0^I~uk zH<+3z zLS0PXuLgP;gG0#6VJ}b$U;prpD5g6T;3}*pq%f;yhm%t%E8Qv0lTXPc_9kx`b|cum z7`!ls4Z003A(GSf=eTzHkzU9OY2`>U#FgOUh+%diQMS|@A!-W+?E-*$gTQz1~?hDS&$IeraAHmYU;rL0nY@w7cQ2;(WO4*NldFv*+8?6xux1KbW$Nz z5XKG$uRE`DbCVMK`Xy&M7|lw#Ctq zS@4H}Np3Et9h8ZYlfTCn(um{8_69p4>P zgnqBL_TA0_zy|-I@jIcTD$c-xfd0_m@*>ke@FFjuNgOBu2Lqj&ry~N2?mr{dyX))w zo15#)OGZXUzyS6B_V(%F0l<{6FE26C(Fgnc*Q;w2~|qZ31rf|J_FY!6yi4 z9p6D-S^;pc|8@h)?wP^|ocu2V^S$1j+XqnjKZoC-qCocTKtN!Le*}z)zfS6=zoGe}c#fixS@UGA6n3$coLbh|pE|Unym5itJ zIxVi7Lr@jmk6YOG)FBpL)1T)VgkA5tt4+)t70nH&Wm?}=kMt4Kt0+0y^ue{AZimyQ zZs%$4GP0R$Z>(SzQr>4jY^EUIRtgMSA*DMfAwbfb8)rtc0v$i7FA}v$p3G+5>XCVO ze*#UqCJQxha#D&Ru(bY;zAI=1Ya!10oqSW40ec?EIs_lx`3AIMal_g&#oo2oo^{~r zM1KpLsJ(iUs|l|zE&z(PD`zqkGM1K}2JB_hLl2xFZ!k+G7kyC+jWM(H1NazMY2RQ9G6iXt}rA@UTkkr@b*jciKhWSgw{Sz z@b~e<7tk&+2g}vq?~@=6-Ev`XW4+bi5MSrY+1#*Jfh&`CqMk-D{1WR4c`hAAX;O=e zVc#&RqGL3w{qT&JAC1!@a5X`=P9Y9+c_BFN7Xb{h>SJN!pS=xw>Aw4ud6?f(&66k9 zuwz~QGX({)YaAnoF|Y+2BR7fr=~Ss7jruAdrDyW65lIVXl%UT`z6{v=#QO0Xy_?u8 zQ=lWx?-BS0R+8xGUigQ)+#CcX1Z%h8F<(xuQW!9S+lmh)P5KYXjaT_K&9$zoBZBVh;X_G1p&Ar-?nS|)zHWc5Qp20 zMW`~zxC3>(KIzD;8AO-sqo(;bW@F#L%)c+Jv!)DlX6_VtMP;4jX8(W@|n7vp0 z!mKkk3{ziApF;xnD?ZxWW4;nk>zZ6>Si}JEcF9gEET-m44&5t}Z zmkb;v^4U7P4OtCCUlh^v1KYf6aEw3E&$mr<&^3OU4fP3FQLW#%ywzs_!6PQ(q3Cy5 z!fGD|F}U6Gd%S);26@sxP-~D3dMBbl%jGY@n4u}tc+9*727f@dDtnR0+m>&GyELud z1w26}0i&tvP!=FHz$WvWgjKsy4&6tWYq9iOa3HMAy6bk!@#DFzx1r7eCVKh2#bZu7A)EVB|2%{Q%wYhyGSSF#kW(4*6br{sb<)wcMC{Xz-#+-AtbvdY0*OenzlHuSI?xN7^g zr^PfUUhsyXhlHzpw1K$h<$K5#h84;dMOOjnVJBhVF2b$EHc@w2bb}v!rvdcpQs5V{nTW}2t$J{X7NaYJLci>coBdBNsZecl z>T0=A9yB|Zd-eT8CVJkO)%h-F@EPoGD&Do5t=A0%aeiOMo+PB_)43YC`@xkTY7<)Z zlQ(Q+T6>lx-AZ2khYHu8pk2M2bzja$fV07y>sIk0{qk!!zSgCL4~nB)Krx|#uM)$K zO}L(Ff()z#(tG%$XJ#x>9=PumH>N(@e&Jf66+T7o>bHdzQo~9RSEdXoRahD3hX)O+ zPyprFhL|0;F?59@XH*|GMSh#z>qihcB)Aau{^?cG**s`B5}iZ&g%1`&7x0dcnH+Wp zEg+(R#_^5b==CK?0i^8;JIDvLu{4Dnhy>(4XOXvw>sL>4uge`)M$oBxpF)OPgSa;T zxC~ef|4tA)vZo&C9H=>L5?`R}9w=#F){KgAW;bi(>=s2{7wFr38tsn?Jg7BH?{3I* z6OCubF@)F=_b(G9Y-E#fDK~N123}1!=F-E!iv5u^p9{}Yr|7co%a?L~H149z>T8QfZ^h#QJQ{-nP5X`5weV{rgdALL9969{FMIOQpNKt|4>CVJ9GkD69lJKWdXEk#i@O*lYZ?|02~=3^K5ff1 z-g3HmA1*a+ZYx2;tvB_8`&gH@yU&e-&DLPkYbO%4NtJl&7l;DXiI94}NG(z4l@qZ% zhc-ODhe%l89Xlkqmn!*Ys-9zc zP`7s6z9tu6ok)JtI!1El9{1l|#JvRUo-N-zu8%%|fS<8ifV(x~%qzqH$jQI=1bQ)x zEZYNIK!4~zL$Uvx2b=xx#r{7yu$!Bk0bZbhfPnwQfqhG-`p5hI@0jSo*MFJl=ZW0* z>3oPma)N^8{YfR3RqB6-gwXPic3uv zaFQ@~G-lX6P>Kz-awZra-V7ryF0UNbnc8l$O({CeaU9)(igvn8!a96Op8*AmfpL|HK*^2 zMB{7Hvu%wku@a^SOCE{u!k*l-AA40AKYV`a6`e{f|(Zm6Zi> zSKr;;?d|PZTU(2XiT&lSj?GsI`8QDtH1`)u_vh&mdcPxrZ(+$Q8z`ui<1EehH-<{J z>Cez18|V$p*m!Ts?;emfyNnW2>EQYTiL&9AG`jsHFNzU^wU-^sjlms6o@WpG6>O6` zQ}a+m+IOYa1IS8d-8y1oD!{aC?wSJekz|Kycy267(XJz$=AbGR;v~1ap5AIjb!`j< z^;-nV!)=M8sRk^xN-AkSd%UTk(?+end6SFo+9>bE#AE$LQ(UwJuObY7{+*()nw<%B z^-p%2&9l`n! z9RcV!AmD$+yUsb6+W?h+#k;Dis+N|P08YKg$jI~aa{~i|zd9mHyyEH41^-683kc>< zM_|kv{E3O_1L^50*8T~n{e!eC(iGpFf&~KlLx1=9{}A>7HUYHA|1T%V=;cHu;K_Xc zI6+$1-oFc{1BRm2GY?GaB^0uGc1e?U9UFziS3zZKyoS|cR*k9-4MS50F%7#OISUFd z?LQ+%V)Dni^{cGHdbRv|ukW6zWpf?Ed&*l^{E~;nO{%!GN{oHl)V;bXWOB1=7BZ`5 zCzehkv&MIiZiOux;+yuWXCF%&my1TO=hx2kygN*N+Zol0En>&gI*$bnDq07&GP+M0 zlnPe1FK_Q(#4PHAGRNIx2HfI@0+L6(3zlRZ+v19*4MGM(D>rhg=5^g#l8dLwB(s$q zTMka|dnXQ@(q>fb8>Gyux%DfX`nNeXOG?{T?LvF-MKVYwbIvXwgEM{;ie;9!tc=VY z#urU;YnL*r6y86)$l28Yo<92X-~a!$xsO$O0PgkQ!sI{vGSCNqVB(^Ep5g4h(}ETh zTk8u1p$}S=96&^bZy6WEl1dcMVszT^tJ&-G(sq+@B$5GR;gPmrlZJjF*5U5yNvSqf z@CUIF-&a|%9pbBPm*P<}98ZtU=w>sebP?Ul_YeW6*&!|P!%zM#{D@SW%aW3)%)5;t zS*B6i^~WVF^;?+7uIG$&X&=|0<30Ur@1FEv43TlE2oTK#IE1I%yjjeHV;;El>lWf` z+4>eH>*X*HL}pfY z^ws9WeJKbI<~pgCfI0 z_4!$gRZ(e|%FT|N2a3@b*;{Jq4nfv9oWA9YO`45sRLLGQu?+Kk=#;Zto!;yAW zUyU#uq7%F-#M2rCz-UaFi?E}ynXq_xBxXGF#frRCVzp!MXlyoq_aJ_SS3}Fmo{7$% zW`+=>LIN$V60L^f@U<*dVDE6>kS=bn1~+-QX9RlvStW55JV{pp76i$rII^B zGYc05dsC}ny8Rd{hP^$;YKmn)NRIzdm7{N9N2*c$(wqP~VLt7&Z()Rh=TbV%i8A-3 z8GL(RKYup6GN`o&)YN;luL}|tLqy=3HjW#1jzq*aud!KUo2&FYshSu5A z0EK#v2e~O^ZBPVU13H#}-6T0Rk(pe$tm8u;+^BgfaSG_kahcUCs0@vQAlqk z@=g6O_GU^l91ZPVdg^+pZQUMZl16Gy$a+aT)6xEWMOFz%KZvF;6Jn`tka@gzB#R6U z#a!Z01*$Vza3y^oD8wv`Be4+#%GNBw(v;}ya*vpnApF$f3b5GwhGRRVo!q|i^njZs z2@mI>JD6&!P|N{)2Ce$SHQQBXwm;OM^OAT+1aDdq)S%3}OgBW6Yje^%6_GD^PLXQD z*E(EmG&`u8i!3ui3@%8UIFAlLFmw^h zN0u9SM#z{5i8$93$e#fW(@7Z3@^p`gS%MT)duz&GNK+7md)w)|fO1qD*-TM~D&qE# zW?-|`pwqLIAQO~EqgOt}wXT#H2M}h8d??6tkS3^UXd9LFJj%AjR~46xaU#kW=Th9x812>{g?2cG^St6j$52E8%20U90=qn-^W5hbm|5@8^@=9}nfThF$NY zOqpGuyN;&}*wa^9kXjD#&IlBfEuM&v@v`tI;ENDdhd-2DRDe>){0JwBOuCSA`4y?) z(gp>8kk3N*gEa?8^T?ryy4z|6Xa%Ju=4jEnj8{?e9Hm;pvex~`e1aiReFfAv z)YAbzcz-hTxVAIVSvGowhukgSSmJ3w7)8$6){H%HGSMKiK^=GdNd#C#9fAS}3_kcP zGP`$w_S6v{v7%}=CO%QgIM-UqN zU(0>g^}kUXR)m(D-1j$vCM$ui|Fw$Q6fcJCuD|Ze3Z#Iy6#yu%w z_YDPix$wFK(=r^N5@oktXSSW`_odI|OBhKnE{<{vDUowX*Rx1*Bz&Z?Q<3gkoYf9; znq$t~J=S(XG}j`oK^&hf+vAQfbpaNRB+Rr_I?A`PeL8g_Mj_D@*tBXqQUZ=t|JtsL zEU1hS=S1frFe}c1D;(^pPFPB=-|Xo2D8~v538zg>R5Urx zE~{xD_c!*RPHyh+53Y7@@>CW!Xu4L@lwmJLvxlq1Y{5);V0)p0O_LEV7k9BqR$`Ws zit6p?3Uae9?q}LV9w_;@VNhv8tVV=M+FG_ZvB)Fjuf!&A!yS_|xC~FVXnIz__!hr_^3iVK_Bc@q7HA(B=lh(x6p0)eVqonpJ;7K)FUbWH zn%A;q^-XUmJrNe|@ic%ky-oYKLk5LW!r6 z>W$q<5WCoM99;LV^@P!V*PQYwHv`kJ{i4sCN6kNYMU!BTM6uB^&p9@%sf@Q-<_Y@( zp$IXtXGcG7<>%$PaKeUdYt?@89GfX{r)t5`)eUIQfDW)T^os`Ji10{2Qx2!*d%>WG z>Nw;-S{aC!KsHvrEOO=%MwqmIPH%s1_$EUz-{ZOoI!UCRJE_hxxN53Ml`o%{u=XSo zVJv%GRuOtrcHSQIzn2j(uo{&>k%5GDn}K~m;@TJHF)Tsl{27A&y~Cr`8-gkpV2=Eu zzcT^;XK61B3kxzbGB-CjVPRnaYZ744{AIz=zG622-wjRwBV!L3;V)BiV4l;wj}8$? zmpQ;GQd3R+94U`IVbC6LYuRwhthGn`zV7l;<%^Vf@DKdy{Ul`Uqg`nv0w0VZUGvjd zT*4%~g&&d_*9{4Buia?|M{v;0Nf71&Fidcg{2NzHBEvq#{NZKW4^E7h@f+{cT!kkp zV#RZqrb`*?+nR7%43VH*$ulbOm1hsNcBw(sI1f>-jy)gVrUVlclp2gf6)^f04I>dQ zIQAN(GZ9Z2Wcz4a13X7AalM_V4D&?3mn0j`aRnB>)5Des(5KHjDLQ{@RxKdQ5c7c)D zQ(SJg#tp^#3s`^r=y2Ja6OBMeD3X(;E44*W1T!FLn)gRcDjEo=(y*adG*#G4Uj}E= z{?%SkZ~bw*@j5<6)2EoA3u-wFQ3p)1Q0XZ)N==*S&XVchDLK8f zh3nH&<`&nt_IPvW8ToZos_7)T2NXJS;p8^Ta%ZdbUSnAb1g@=lV*{SK*r3x z{Ug3D{P{F6b+=ch{hn68ujY4sCZlmFyu|y$^1#RnV8Jc-{LsY<v{l24m|`nTCRcT>2O>@O16TfJpSyo|71AXh3T412r_Mx7C^h?k(lr zM4@TW*oXzOXFaM;;;Lp`a&-f9&4S3tXo9Qroj`H>^aW3P_I0`7>Go66jE@~Y35ra6 z3b+VJ{Tmp2WE4f`Na5{ z3be_@;!lQhPI1OGAHpYP_}x-w0gp&fw$Cm!Ym7lPe4=f{rk|;K%`MZRn1fm#18<;5 zZnP4=VI4TO;G3+kLh~vFl_gMtjxRjqF!*ZOazxQ>U8Gzjk>dy!f|m7A;DmP5CQd}# zKy7qH=bY{s_QkH#Hr$cD@)oFtAO;}_^c*IFcSlF6h5d;wBzlzVB~jw$HJB zs9*OpBhQ_JJ=IO6iZRc{@7NZr`<_-FgIi#&&GlgDed{ifgyEj-1T&5>z|Tq(lM|8y zALJfa3`c1wJ#oq_CRHw9n#XpjiU2o%660)2l_m(LRwI;FALeG?wM~I9eQTv&IM?S` zXj)bC;IKu#WL;P{=1`kg+{W&TsbCi#`sG>wa<5mE>r~G8wwboDFd=8&{JT(wajUY7 z>Jd!&hMmo8uD7B~PJkv}hlaIdr6XB~>{HFXwf3|86EY+Axp;#R|GyC@Re1`@5`YYs zKlFFvwI-Xz?Ic6jwL-kxgx(|B`L#S!RJrRw`(V2n1%G7;EtHkpk#MpNTo{Tgm!9FQgvJuLl9BRtk)BBuN zMM7M}hd!xOMU;SYakC%sO!~cc<_Rr*afZagm(kAzXBF23&!{2w1yVS)Q=D2k*b+i? z6~Urtx$-dpx#bxyRkstp8h;JQ-GjxCc~(sZC5az_GxKqSmY7&TrI^?qyN~)zvTLyk zb7KLjrs{TME;hY#^#!-W+){gyCasOjQ?r~V&$>#-Fb9iDwYT2*c_Fnw;a=-1n3h-wYi!NaVK_v&jgI#9KJ?hLp6?)C;=c+%cd|hU3(sTo^eAD6FtLJG zRPUBFJ7vqiCt0ao6$BXCK`4Rp=2iO@ci+r>2=#9Dtm+c}48|`F_Q)jsy{Nvnxq`>puu`t1Meb-_sD}}s=PXWT*rn>F4_9*= zcZ4nb2HU*h#U#I}OMz<++K|!ScwDfUb)n6p`btv_Zv#_he5>XTKQB)6B5txz-vulY zgp=k<%c%|EhkqE5jYA}LR|29>f9UVH?;jEm{)7AeB&My&KG6XR-9J9r>+9>|%Ll%fl+K+hKK%Jt`J`SB`rHUa6mRO{E8;0tFxE*#mdV~$fWP?QOPAlYWV$JF%{rMpk7fM);)#F z{iXP#+y2<&?oYj{f&JDhxx6DHFwu$F&sMd)0psv>r^EV+LQsW~I$(>tSXn_(N~E~; zh)jLPuKKQ+Dq;8J^yfC)3{-6q#aHb;Wt`Unw|I!Qq=b>Lf#%M7^>iQ__qDB*dhjaqUQoNxmM}K_X|4nfBMF4&@LCXq~ zJU)&DJP`y{zO3Pxa-YcynZp3!y5PW6uu>jjr_{ofbQ=4Cep~-a-jEWA=`v74o1j{_ zHp94g2j?oE5r8eaRMq$)XSfag2o)QUMpPf;_T&vMlpOfe`_(Z(|AQ|)&0a9v&%6O; zYE*2T1A~JTf~9K-GYWRINOQMCg!(t_e&4Eq-iNqylEdXT?o*w|fNfhBFr+2-9NW6K zu;FH1H;=>7M|+S^i&a4MQDA1)kY913Ii-hYqlnKF9A<{~>V~ld_a@U8BUDXsQ-}BR z(dISj4zm4IVnYD5>?r^f|A225@bG*$z_)+s@9@p?--S$o|KOW=RrxodfcS&&?(S~D zA?0Fj5ugD6lfDSVcd%p(sQlN_>wiuN`p*D~F5nyxAm9`Oi$%cEKRm2Bj7U-!AO+|T z{cRf1KRvA~ph>)cMMxCUUS z?C!q2J!z_`-R$h}a&zZq=LGut)pBv)@2x-XA8u`K7U1IPYipSr83DK*!1Dlxzun#j z$N~Vji)tH~0e;KB!|i`%eu_#oCh0lnaUcc}erG`eOF#{<(GWBhs(?{Z%z66!obNsmodAVzc zT>oK6*TM&Jzam=zGz(*Bm7aEc@!IRYW510sDP z@mAYmQ{-45wWV{|oyBUAuNjEfRzjFYl-N9kb1ZC3I)t_^t22yL?>oJzva@Dk46nQ# z-U$nVX+E|iZtK@|xeZy~=z~?V$yH;lXF4Y8pr#tu*3E-yr*&becm?cJJ~?h~h5dTp z5Dn(?RT8_Z(Jy3b2d#*kmYUA+Gj5rOr^BHh$y8onsyLmSte{i z+%3K0pgMewx@3#mNhUUcyLN)G*X;%WbqN`T%w|4>0KzNl_uWR5R8;|3B??kE2XCp} zkU3ANe_i6V8{L4ZlnNnrwZz$+%(ooucbYJ^jx8!18SU~^ zw|}o&V>k%05>Fu&e8CW4HFEz#D#JCjr5HdTB9HAonVv1qp{i?ZW-K4f4s)NE88#YJy zLzXI#NLTnkqu5x&52MPV#d9bmbf)_`+}X9T5p<>yD#TtNtLQTN{qIvXqtPZ2#`5QoK?O_xVgru*X=W~1*Z!z2;Ibc#?HNf03u2+e#FAWyvx@b)PPYkN*O6__t3>KDmOlsy)J45h?>eIs$f-a0kr8S zV=J?xW1K4MGc~HAq_T)PDqf|Fky@=|#4dcB3Gu>o9SeqJ#gm$}j#^+N>Kw&(!M(kjc@XDybpRoGr5yf52*lTm8f^>to@5;+Gx0)Kb zjqQ^(YP$GzjSg6t{`R9cM#mN8WEmT44qL|~gA#)>cr$6#M%CEEiL*GDjze&4|66xu z>-D?8?2s$1e4}Bs^pXG&5~ob{V&;jqz?t5g@{Wt zF#sv{Uqn6%5CqU4PY6CxhT5Wqi5=tNW1=+H9pK!$R;2dJe0p8@E2)7m#mTM z8?syxtn`=2akbqeG;Z6H$VFS1=asYQf>$MZv^r8LUgSiET;J~4UVr`X98k!RmJp)U zqKx_>ghSuth#)kT@)Qg!iK+#DD4@a^)lmpph%ds6Sal&zAn6~m2_2gtw`Ezhk5A=N zKzQR{AyGX;CK^+AV-gQ~ndX@9x~g*s3$lHshX-aWj`z=HR1^LlX!K~(QOF$wwfYhM#qzJ1_4eaDa#l{_^H=&c;DF z0L@@03SJhh@J{9&5BgCGSDKJUJl)hVs60H52$+fcLCtId>n5U>mKwO|Y(~S>zPA2y zYZs^@ib`H*Yif9pA`KPjzlA$8@oXu3_vzNs=2bcU9Mq3t#u>O)(SvH};2Ur-TXJ*2I5SlV9f$+AflaTi0_<0D0+*LKp< z8>7HL8(TL2GTE{m8z-GB^B|Ym5@1W_4CnD&3Lq+;71ohQ)1WW1=V>d8W=_Cgyyng|y zs7C@h0f)GXrjqcLFL$WV_h2q+2fWM{^&T3Q&j9Znr!SA3=~XZhXoAx3x6NIO?iV5j zz?GHFu2iGe>O>vSd2HdJX}DBn7tL=5~N7mwnVp z)Fi%YF%!EMV3>L~HG|lU2FJRk(#hQtw#A0w+V?V)rT+Yxl{<~+5mlZz&yb#?Wgs_Lq}cWHWV z`7I&URP7t9%$PxSvQ}(2Mvn>_$}gMqn&uyq)%JgyMG>3i>>Gp}Dp9WFt3voN}R z+Bp?y?IvzOsh%Yirb+g$Cs|MOd!*z_s%MgiG$f6yWg{dfvOl#I#yORoFHQx}>%zi& zA3oB)F|J&=H1IxD{cLRXimPmC6pqV&bd<$rR#LpF)H*dKg~W;11ig6~xkQBdB^BsZ zL7#ylG3-*j;FIz?JjzXrXU_qV(Pz&>;m*rDzgB@NKV3AUj8rZkt<=*|gjuANufQ3? zTITCbzB6reisiQ#SDha;^u#XZ4c`$Oyjb>+eMtmz{1gk#Qf@!D24P^XEL;7VxY0Jh zk=fl=pbL{2meT|-;2SnmU*Do(TZ=rAE;Xp`oJeQjb4NFk4H6J1sBzN1?YW2eWP^k0 z3g9vc1hFBK9i z;1v*RatH|Qe;R#H3sbd!wflSNLrR@&$h`+L&Em-d)r^&IqEM~Lm9j3p0!J-v3u(#| zDxs8Pn^302#E>ERmeus;oU@F44n_F*UaDxY;q}Ok5)kc4GokZGJloOfad2e$A;nbr zW;a+XPqK$J%;Lk^LUtmJeb+*b6DpN`piZ@-VvihfhZEV}5V0F1Jn`(UBchaZM6M-n zhZ}>dGwNjv=4_ZVHi|eE!+s@{UjOVTjoMAd8A3Kmdvd%fQ;K5UO*MTR?jAxUzPgeZ zqlKToQ1^^E6QhMkAhoS-sw$9NZ8rC7cXe)c&WyC48i{}-=)R2>fZS%dcG7UagYqAY@kQWfhgdFRh@PR4oVcAg)z$fT3rNHPuH8&-CK)1#cskq+}y+My)%N8bI4wr(hZSTH(d zd_i((Yq~sapf<$Feh76XM8MU?>jMVab^W_VftS8S#btnV2sF#dNn)O9mP%mC{mj+)|Sc|8%#^^wKs*^W9h zJ>!X_9FE=C^;x`ENsbn#%h%A0MXcM6*Z9{*P!NQ>y86cN>&5Jsxn@^bWQ>V7kzfX2 zXYaCdl%cn58SZ0TJEN|(gI@_o#N~$fuh#PHu8!8izFPY9FNHfw7!omy5#*hevEQEM2Xf@m z+zUQ=&T*O;E*{`mCiwJ1d7ZzeU^wkq_T{sPd>2Gtpbqy;XpVS6yU=9^I+xcSH}SkZ zGm&NcY!6rHS=n`^ESx7%mWyrJ5Kc%rDT3y`9#0SZcUuKlNv$1*=?Tbrj zgrFB#(?Mr%{r7sO)2t13#y$k4cr!=apEI0xShbv}5*59R2&;m9L3!F}Y}ci8s|%=N zt6heAiOOP6v^ei}I)VQ>OB$sRzq7qCuWI|e8$SsX6ocYpj;ta()g`LQQO<>KuF!bU z_ae0%BV7sZ>$mg@9ou2G?k8Wc3pd2?tB?|X$b1Tx1r6vOp|n{d`$Uyz)lIw`ztsy@ zT@FL6Sz_e|sA04k!;_iQC?ezIGfh?|^k{zxIuZd=5T$v0W3)^qZnj_w48 z(`N$J+xCe~L^BsmjrbyAa7LnMOWcpIdg3YUJmy&p33cJ*Os2=hxd@njdg5{HJZ!aV zwo=>85jA@O4fa9JT!r}1fW*3RY%@3Y^)d>i*J)PIWHqJS2|vwJIHHJw;_AZlJis&+ z0r0OyB*#Vb#)$grA}tDAv^D!51bN7pIW{fU|3Ng%UCJ5tZF-fpd4`lc)dW zzg4507?hoivjA7CrW9DN@>S$hC!L~sn(Uv&{=c{ZGoP<8kznH2w4d~tqsR1UAg4J2 zw0c5vd|f!xL~0K>>yy{S3||YS!R42VeYlg&bpzHyq2sAIi$N5E4^h96q)){t!8Q1F zGhFUiiinTj+g64hPKL(uSJgk^qH;vh>48W#w^08;QS$TtL!)NW!1rtt4-J!#lm6vr z^`HJ9E2d{&bj^;!P+dUKy;KEifj5M+SWItO0y3dobg}*1T#=I3O=9P*I3c2fuNC?8 ze9}gP8!luKlvAjt4~Vd1xEtkyJY8}7w9(%%P$?Dge3dZg@BO*FhETuAjO5GVP&qC~ z$9w5N8Q~lY5g#31w?>losI@ldD?@pMIYy;@^8}rgz!w@m_@avRicJUz4SRHfrQGvU zj|JzR=nd;wL2Nmeu%TGJ54O)X71b1nZE7f|e|mGH=H`5gYgTL%ueWk%{1S{31r~pw zMMp!`x2iINMErYZ;<*mal~WuKby7lKXfrcfz$^!+ps2{}^p3-uV^R8o3+KvXDu8wB zaw+cSx)Rw<=oEu)`Y7+25OWvrkCrE>l~1sWXR=(P*}1hRg3LGA>6S(C^_}F<&(G6l z`HYxo(2px#0dFlSoMh|6OjNMXzVe$itYHjKzAM%=NeFGdx_lx&Q*~90X#A?Xr!u!1 zYo`j$<1R`<5Yfn5(CrOTiaQkL1^a>o0)v#nLT#|goH2iXtTfy}PYa2>eF(sAsz3N7 zux)(pWkO5IS*RXm5p~XbXuvp--e3KB9R+b+R3 zf8I%2wm!?Sm&iAzMi~QJo~Xn+7NLTH*MhBrHs>E)&Oh|_dO;p7B1>7Ed&g6G2GDrs zjv)0)+-GGIz=1S8a`E_F&PUew-x>_j$iyc!U1oRAae;I+Qgx6s6K^ zuHe3Q!+(=KdHFlH%Hi14h%>JG+IS#bE+G4>Yq4d$oFghyF;JjBn&Mna`o&52X}Bwr zr_|eTHL&;8@n*2N*$9_0FwdfnX`nK5YGk?WU;#sr#4yRodTkk=jrdO4|e3+|W5m8{_ z!Z0<=m|KgVH_6njxPQE2^x7~H5|UVH*rjc~FfseKjdrI93%<)icO4C5KjDqVjN&eH zc7YctTFGg{ztDhp z6p_PVk+ytIOq+#sjbiFM^8=-cU#i#$^QEY6!_d>@GgOC21qGew#q$Q8r5iq@ig(Tl z8DfZOlT-ehfY$MLfHAcs5Nhld!*iHx&&MfC zeOJ>vz*x^po1eOoG&c0q#|=%?vs((p&=Y{Gj8UDC#mk+!zAEdq*eMt6( zTjY-)TC@db1KdSU0)QC_7;d&(4gB5-_o(|;{7+w@8b2cl5OZQgU};{CoY2QQnsF6V z&Fx^?A{Yx?a?GVp+sQRj(M*|sCkqrL$@EHO4xJ$l3JYz%pOAbfGF=YuX&qI*(8pU5 z$m}%m4k!$B+Nx&7^ain|64oP7Y}!hBCg=U?t)zAjBl8}+qQ$+D#ft);^;QSktp3tS zgAN83)QWG>i~aR9PQLdxr3&iBi~Vxd<7LUzO2^cZD>nMd8N;EaEX*Kt>;n3doL6nZ z%bNTjyupvCj>R7TUZ< zfo*0lYdMc4R_Ak(8QjHiBs!wLd1s685o-~4HI@i(KeEJ781uxZ3r)27%j)WGuJI&E zCGRR72CqEGSqNfUXueFeAw;{(WE^L zq)%}(;x*{mGVQS-^DSt>@I2ABiFIkB(;8_r$RoyprzrEH!2IOX-1MahDF7+8$SN(7 zlL*KsGz%)Y^~=Ev-}pi_Ka9o@qA_qObQ}{uLc7hf8h1Im%n8GA4+bDRhuk z`t{hm>ZXbIVQ6W09RA~P)oIgI{t&CM(JSNwR!6&^P4(FoU*L-y#vVc9;~8baHtv(- zr-#s98e03HZ9xWlpsq07sD_a%?qiCA=(cyqN=mc@CpIz>rgw?Ygd8~2D&ppLA(+&x zNlvgGOSS=mq;Fz7mpBx+^>*7)N?}^xHxw73u{UW^7~t3tp6Q*!A>QRa)8T(504dy8 zW71ZB%7H3`QrOL{sIlReM74O?iX-Gb74`LbSDs+l4NJp%<`GL&HTebMLi0-Eh=i~V z>=#oXG^@|4p$_E8B;JuE=!Ja-P(`;1P8=-+Mn<1&UvgO9FonzCfO?_~_!fhRoKgZZ zd;*(rmeNS+wwp7#$k?us-TDny!}-@jnXUout5@N771NQkw)Jimv6uMe#hr|wqFK(C zGxM(t((!K21Um}4)984CYkG4w05kT_(kBq9_GlU2p?VYyQAW?`NPz36j~q;);}a&Y9(c6$Us)HA+X!GWz6 zn`!lus(s2N_OR-!A{q8N>05{iAW5ch;JY1@LKGq`75k!kw@13o9asM9`40IW zAnenzmkPya=}U;D*R4AHE}m7Wm{^%`Ib*Jrr&Nj6XRA$W^iA?!H9SSB1N1j2RrX7R zZzRiM4Vt~gLXZ1DiGX_?mbkZAQ4O>op{Fmv3w{tp@=>h2I}S+YRE^)6s1o0MM)vWT znZkNrZie#@Dg?RA?FI)i26cClPD0a+kBi})hZhoXEpVC^PgO`~lu742X=J0)C5=qb z-r$jiZLGGiABj{ol=Jx@zstz&p<%@J6>YQ*uQ^^!%X`{`{T%T8mOuCtVoDaHsW0y{ z09;$Z6NmYQwT0sTY(TA3^NkyGZA0$sUD=8KV!TNC83RhU##5_{Y(ZvjW;+;nblf(d zLuNcY3I>O3v-JD7YRuOP!$D-p_faCg4flfPu1#j*rS!-b*Bs5eQ@r(&14OaruociL4a|Vox(u^UFj>CutO*ca8a@)Y{oTL3uZPhCA>}O~R4zb?eTD!lz|{ zAp^5vq|Uc6+6zavlk7)KYZ3wnrcLwW{Ei2!fYSra%7)h~^?C5yAEtYs8XQ&B#4@7n zqT7#7L;RY6D7qu(pvy=}Nm7#7U|fM^9a!e|v3YSca*y}NX&lSBj(Qk)2#9cJ2nfu7 zrg0qH?M*FQzzaIR0&8b4IJ}j@4m@ByBVj5ubcnw6tX@n8b$!zW72BwtWf&*q9h*UV zaQn`r&Padn-C;Z(M6UF*PYfK{exvD%)r-ur97~WE%LiSt% z6GHP{x|Uq)fW=Zn@7W@{TQ^Xz4&iY1hsx2#*2|-a>Dbh5?gkWBZD$yZ!tM>!1hRe? zS3Xj_cJZ`}Zpm5%HJRpt<1W?}TYnLyo0|jBrbZk!QCqabTV96Tp73%3T@3~9@mg;m z9h@VE$Y4RnPfZCH4TJmp1>~i|JH(grwTv!?7VX>!;|Y$=SrP1e;wiXjWABUP<-=3p zM2_-V%%{(&JY@AN&g;GVrluP1pJXL!@BJ`Kso?R(4V8qHuaGcRTa`GN^?;4T8C&=2 zW7%{3yqMS(`|Ulxrcf*Y-B5c|UtR)*E!=BRGb{0KKFJvZRB$KC6SdrZa`{ zO9pF6SXyNJ)V$ z-NqBc9;z0}Nti<}CNs~59&LerjtE=PDM02FMW*5xio>`(Ieh41@&N*O?vlF^fw4Ap z3N|Jx8=2;&QZ~@VREH~@&?l2x*;2wh*!nBtcp9e$2wx$GiAX_BH!Wvcg99Z6d@iq< z=7hQz+?Qw(Vm*L3{#2ksL{Nu2Z8Re31j>9j8;|^_fR=8W0}%GBW#6ZEnAZuPE%`Np zu&-zXeA7mk>=2-KJ!@M#_G?<%jnGL(gp3YPZRgm9#>zkkNUtwDGL4vt}vnu}}!5lb1NY-i6pIWlE*9-f-I(|HNW^Ja{phFkXcU^ZXm~L0nol z@O>*99yi11JYq&FXGH8Y2_-Rv#4Z{(W7QcLY6Z;LFM+Zf7)flh$WYKU$zm!U0B3k` zrhQRxCPHu~9cY%poK|Dfi{S0ja=USTx66*saeLCT0E;n2+SN4N#uhit{pM}Sno`I+ zW*%n<3dI!RNq2(`rz}SiRY>WB8Q%BY{K1Q}=RB%oC>r0zi5O(1*VM)_*H0qv&Y8h$ zHM%oW4E8XjXy*JRLlon09_#xPBisJJ>Nr=M%s@l?f8PfT${N>B&h z--K_mqej1JfVI(5rc`sYMu4aI2pQ+7>#h7Sif`Nxbw!+$P{xJpam5KQiP!rLT#3t# z5gaITaIRj>dpsC&G1BBy3JJr|Juz|&W>!^Ls8hO54jfu*7_ZnSy0If%{jcag&kA*qP2(Mijukt7Q0Yl zO#aq!>r_&16&YXVV-FGdsi#4MpSmmfsjs5_Od#$@0QX{yAq5iI@07xCUl*F@R6Y0sgaiFACaL+K0ZZu zE9_?*jc4c-l`j4(0p_P339%u+SS8s1MskO(J$KC1`CQrEMg;fAd=8OVbmCKF3;ZX* z%-chD3;ZpaWRpB9jqsp1>I887s(&SrlY^}B7lTad2dD?jbQB_n3$!zkUR2c!o8flz zBNud47@9zakc03PZ#Wx7)ApkTNdy8twdN&cwJYJX-}sMUidYCBM6AxxPv07mjLG(X z$qNzYFh$SJzr9xyrhy|njbKGt<*4RZ>g#>B5<(Ks`D$b|$D5XnC8ENi* zoJ4IO{7r35kK4!W1iu3%xG_xJIEivM{3tW=xp^~UJO`A`u~?iq^==dL&1Bx|Q@pWU zW6dn7qY>`D##*`o_l&=O>Wg)|J~fGChHPw-C7)mN|Ksf9L0)rsG>rZ z0ZW(O>v=Ma1+Ucz146b+dVU%E({}A~<|GOD6bh#La&_5g=1DmDXP)NJyr`W-`oR>@ z_`!N&M*Na)3FZ^B{VfdqkW%c}D#2`o(qf?j^>FGR?}@kKkSzNYsX$7!O{ufdKl04T zxIDWW%R_u7W-9NfZiEAL-AbdiPtI*3*-ptL`n@YaNKR1!gqcHg!O$L$W9D zKx%!@@*JH=Hl3vm=^thrRd=@DJG*KS>->aNFf^azIux|Pb-BB?I`g_E8E!K2Hh8Xt zf%otS&$@H3)4Q`p*zeacn=>*A!e2O!xf=tthgBTZ(l1uZL=aQQcnNxC@%^iJQ8(va zT}gQ#pF4Jc79|@!)Z1M8;|Px?DVvS~tf_nm{~F<0Iy$(io0!`DQrC%O1IJ7@?C=9x zkO=uw1uyEQmAdM?1($}`Wd;=_Z0(q}@n{G`ZA6#iy&*IEn+0YKIUEFVr zqKsLYWZs%RZOj9p^Rs`Pm`j^qDr*~*g}|AnXN|^X=X*jvJus46z9VVr1`BCeVPayI zprz69L|n!tpI1Lk)rxo^TBEt6&4Va|32e=8laP(sh)D2md^9Rp%ggbr1w!GjX4^YG zdplj-#3vB?r3|zFQSyo@3p>F+`3)T!_ku++^cg;VHIVEb`9iFw&uHso=sI6YeelW} zB|tC;0RV9&&N$<1Hli7Ih_8ydqINy|?sST2ofA-P*u>{Z>tgGniMX5Qb=IzeXXX=0oW+g* zl;D|oI@mc^^ywwtH&=H*rq}SC39iCkvI;&yB(eSmUfCU<$<`n4@uV)P?fSXSgN;w& zP%zzbBeUpk$eZDUbfJ(bS0uz{K8juOjLxiBp+_G~BfId8k~1ni4RHHGoW(T@y5;jT zR&lBk!)B%HkhCC5XW@^aH@%QHpQKyWJE37GXTNzXg*i54L>G*z(Z$RPDDVpEYK)Zq z01u2a0fhT@Dnp51zm#!X($Kzx`%^pE|FRt66}XX`1#1`jzuQ3*C#V143_i5|S*pMG z_G&@!QjuhbYw@XS$VdfYfCl8$v!-tD=Jqptibeb!!HYdSoiCQ=Oe&(A9e% z7*zG9F8l;Y#jm*@2lF{*gOYE(m3a_vcb8~}`LkSx_W6F@$@xgs& zgSb3Qrm@rT9bc-D7GYFNe^!sC%D8q+U7@GY6;p~v4dzA87!l`ZCmJT{p52#sMyldI zqp5~F;>PWmHhUpz1RE7%Z?13U#;lI78}jEvcvJJ=m+IU5Cdr47AP9cMP1G+HjmAJm zmMt%D4S;0tsoYjq=l`7U;!$(cxVnh|Bl65ivt&?d0KSBZ+Q#L`>$K2o-a`5m&U6;m zZW8y5W!5ha=an_$`P64hsop>wsYxlEMk?Mu%H zJs(uA1=R4{hGQOHg0sm@2cjg>8vV`eiZzl^^gP#hmBL80iX*^-w2{^kF_zwUM5%jN5J=hT6$i>3Wm6he^@v&g+<`GF9 z_&+791@=n)ml)VN1)TcJr1CFm@X*5D9$bi@SN|jF%fkBGY_PAu=pf85bIb?WBlxLqYc=M^v_$IJZ3!JVBnfM3B zueeK(8IN};cwkV2y>fpm$1lh2$Bf4z=pPubQvbmClf{z)G z!>c|pIP?F&_~rBTnDIDN-vfi`!|#k=f%_gK9>-C8Kz#f7JK|SVwa19Z5x^c0dL_Rj ze#HZOjCdS{>j6Pn`a9x}y7AbH{{cZ#|2yK3s`1!~`~k7j@H^sa)W5Qztwg&?D_&){sUvsv{!jGp-AB1n${}%qw zBo6un>Ftg literal 0 HcmV?d00001 diff --git a/tika-pipes/tika-grpc/src/test/resources/test-files/018367.docx b/tika-pipes/tika-grpc/src/test/resources/test-files/018367.docx new file mode 100644 index 0000000000000000000000000000000000000000..7d14aee263d6a9c16eb66c0347d70b9e40b50ea0 GIT binary patch literal 203043 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

wKF%0{uNQ;mQ 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

jzl<1yUI4o%)9WbrMK?RMNwS=}!9 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 Date: Mon, 22 Apr 2024 18:10:12 -0500 Subject: [PATCH 37/89] add a fully functional http test case --- .../tika/pipes/fetcher/http/HttpFetcher.java | 147 ++----------- .../http/config/HttpFetcherConfig.java | 70 +++---- tika-pipes/tika-grpc/pom.xml | 7 + .../tika/pipes/grpc/TikaGrpcServerImpl.java | 30 +-- .../tika-grpc/src/main/proto/tika.proto | 4 +- .../apache/tika/pipes/grpc/HttpLoadTest.java | 130 ------------ ...BiDirectionalStreamingIntegrationTest.java | 194 ++++++++++++++++++ .../tika/pipes/grpc/TikaGrpcServerTest.java | 144 ++++++++----- .../apache/tika/client/HttpClientFactory.java | 4 +- 9 files changed, 361 insertions(+), 369 deletions(-) delete mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java create mode 100644 tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java 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 b64d6bc8a7..b3cdffbdf0 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 @@ -28,7 +28,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.security.PrivateKey; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -36,7 +35,6 @@ import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; -import com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -68,13 +66,9 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Property; import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; -import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; -import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; -import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** @@ -126,10 +120,9 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { //back-off client that disables compression private HttpClient noCompressHttpClient; - JwtGenerator jwtGenerator; - @Override - public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException, TikaException { + public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, TikaException { + LOG.info("Fetching HTTP key: {}", fetchKey); HttpGet get = new HttpGet(fetchKey); RequestConfig requestConfig = RequestConfig .custom() @@ -137,65 +130,22 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) .build(); get.setConfig(requestConfig); - putAdditionalHeadersOnRequest(get, metadata); + if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { + get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); + } return execute(get, metadata, httpClient, true); } @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, - ParseContext parseContext) throws IOException, TikaException { + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException { HttpGet get = new HttpGet(fetchKey); - putAdditionalHeadersOnRequest(get, metadata); - - get.setHeader("Range", "bytes=" + startRange + "-" + endRange); - return execute(get, metadata, httpClient, true); - } - - private void putAdditionalHeadersOnRequest(HttpGet httpGet, Metadata requestMetadata) throws TikaException { if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { - httpGet.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); - } - if (requestMetadata != null) { - String [] httpRequestHeaders = requestMetadata.getValues("httpRequestHeaders"); - if (httpRequestHeaders != null) { - for (String httpRequestHeader : httpRequestHeaders) { - placeHeaderOnGetRequest(httpGet, httpRequestHeader); - } - } - } - if (jwtGenerator != null) { - try { - httpGet.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); - } catch (JOSEException e) { - throw new TikaException("Could not generate JWT", e); - } - } - placeHeadersOnGetRequest(httpGet); - } - - private void placeHeadersOnGetRequest(HttpGet httpGet) { - if (httpFetcherConfig.getHttpRequestHeaders() != null) { - for (String httpRequestHeader : httpFetcherConfig.getHttpRequestHeaders()) { - placeHeaderOnGetRequest(httpGet, httpRequestHeader); - } - } - } - - private void placeHeaderOnGetRequest(HttpGet httpGet, String httpRequestHeader) { - int idxOfEquals = httpRequestHeader.indexOf(':'); - if (idxOfEquals == -1) { - return; + get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } - String headerKey = httpRequestHeader - .substring(0, idxOfEquals) - .trim(); - String headerValue = httpRequestHeader - .substring(idxOfEquals + 1) - .trim(); - httpGet.setHeader(headerKey, headerValue); + get.setHeader("Range", "bytes=" + startRange + "-" + endRange); + return execute(get, metadata, httpClient, true); } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; @@ -226,7 +176,6 @@ public void run() { int code = response .getStatusLine() .getStatusCode(); - LOG.info("Fetch id {} status code {}", get.getURI(), code); if (code < 200 || code > 299) { throw new IOException("bad status code: " + code + " :: " + responseToString(response)); } @@ -242,8 +191,7 @@ public void run() { .contains("Premature " + "end of " + "Content-Length delimited message")) { //one trigger for this is if the server sends the uncompressed length //and then compresses the stream. See HTTPCLIENT-2176 - LOG.warn("premature end of content-length delimited message; retrying with " + "content compression" + - " disabled for {}", get.getURI()); + LOG.warn("premature end of content-length delimited message; retrying with " + "content compression disabled for {}", get.getURI()); return execute(get, metadata, noCompressHttpClient, false); } throw e; @@ -310,7 +258,7 @@ private void updateMetadata(String url, HttpResponse response, HttpClientContext .getValue()); } - //load response headers + //load headers if (httpFetcherConfig.getHttpHeaders() != null) { for (String h : httpFetcherConfig.getHttpHeaders()) { Header[] headers = response.getHeaders(h); @@ -450,21 +398,6 @@ public void setMaxRedirects(int maxRedirects) { httpFetcherConfig.setMaxRedirects(maxRedirects); } - /** - * Which http request headers should we send in the http fetch requests. - * - * @param headers The headers to add to the HTTP GET requests. - */ - @Field - public void setHttpRequestHeaders(List headers) { - httpFetcherConfig.setHttpRequestHeaders(new ArrayList<>()); - if (headers != null) { - httpFetcherConfig - .getHttpRequestHeaders() - .addAll(headers); - } - } - /** * Which http headers should we capture in the metadata. * Keys will be prepended with {@link HttpFetcher#HTTP_HEADER_PREFIX} @@ -475,9 +408,7 @@ public void setHttpRequestHeaders(List headers) { public void setHttpHeaders(List headers) { httpFetcherConfig.setHttpHeaders(new ArrayList<>()); if (headers != null) { - httpFetcherConfig - .getHttpHeaders() - .addAll(headers); + httpFetcherConfig.getHttpHeaders().addAll(headers); } } @@ -508,31 +439,6 @@ public void setUserAgent(String userAgent) { httpFetcherConfig.setUserAgent(userAgent); } - @Field - public void setJwtIssuer(String jwtIssuer) { - httpFetcherConfig.setJwtIssuer(jwtIssuer); - } - - @Field - public void setJwtSubject(String jwtSubject) { - httpFetcherConfig.setJwtSubject(jwtSubject); - } - - @Field - public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { - httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); - } - - @Field - public void setJwtSecret(String jwtSecret) { - httpFetcherConfig.setJwtSecret(jwtSecret); - } - - @Field - public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { - httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); - } - @Override public void initialize(Map params) throws TikaConfigException { if (httpFetcherConfig.getSocketTimeout() != null) { @@ -566,42 +472,13 @@ public void initialize(Map params) throws TikaConfigException { HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); - - if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); - jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), - httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { - jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig - .getJwtSecret() - .getBytes(StandardCharsets.UTF_8), httpFetcherConfig.getJwtIssuer(), httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } } @Override public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { - if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + "specified. Only one or the other is supported"); - } } public void setHttpClientFactory(HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } - - public void setHttpClient(HttpClient httpClient) { - this.httpClient = httpClient; - } - - public HttpClient getHttpClient() { - return httpClient; - } - - public HttpFetcherConfig getHttpFetcherConfig() { - return httpFetcherConfig; - } - - public void setHttpFetcherConfig(HttpFetcherConfig httpFetcherConfig) { - this.httpFetcherConfig = httpFetcherConfig; - } } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java index a5bed636b7..7713c7ca40 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -26,17 +26,17 @@ public class HttpFetcherConfig extends AbstractConfig { private String ntDomain; private String authScheme; private String proxyHost; - private int proxyPort; - private int connectTimeout; - private int requestTimeout; - private int socketTimeout; - private int maxConnections; - int maxConnectionsPerRoute; - private long maxSpoolSize; - private int maxRedirects; - private List headers; - private long overallTimeout; - private int maxErrMsgSize; + private Integer proxyPort; + private Integer connectTimeout; + private Integer requestTimeout; + private Integer socketTimeout; + private Integer maxConnections; + private Integer maxConnectionsPerRoute; + private Long maxSpoolSize; + private Integer maxRedirects; + private List httpHeaders; + private Long overallTimeout; + private Integer maxErrMsgSize; private String userAgent; private String jwtIssuer; private String jwtSubject; @@ -90,101 +90,101 @@ public HttpFetcherConfig setProxyHost(String proxyHost) { return this; } - public int getProxyPort() { + public Integer getProxyPort() { return proxyPort; } - public HttpFetcherConfig setProxyPort(int proxyPort) { + public HttpFetcherConfig setProxyPort(Integer proxyPort) { this.proxyPort = proxyPort; return this; } - public int getConnectTimeout() { + public Integer getConnectTimeout() { return connectTimeout; } - public HttpFetcherConfig setConnectTimeout(int connectTimeout) { + public HttpFetcherConfig setConnectTimeout(Integer connectTimeout) { this.connectTimeout = connectTimeout; return this; } - public int getRequestTimeout() { + public Integer getRequestTimeout() { return requestTimeout; } - public HttpFetcherConfig setRequestTimeout(int requestTimeout) { + public HttpFetcherConfig setRequestTimeout(Integer requestTimeout) { this.requestTimeout = requestTimeout; return this; } - public int getSocketTimeout() { + public Integer getSocketTimeout() { return socketTimeout; } - public HttpFetcherConfig setSocketTimeout(int socketTimeout) { + public HttpFetcherConfig setSocketTimeout(Integer socketTimeout) { this.socketTimeout = socketTimeout; return this; } - public int getMaxConnections() { + public Integer getMaxConnections() { return maxConnections; } - public HttpFetcherConfig setMaxConnections(int maxConnections) { + public HttpFetcherConfig setMaxConnections(Integer maxConnections) { this.maxConnections = maxConnections; return this; } - public int getMaxConnectionsPerRoute() { + public Integer getMaxConnectionsPerRoute() { return maxConnectionsPerRoute; } - public HttpFetcherConfig setMaxConnectionsPerRoute(int maxConnectionsPerRoute) { + public HttpFetcherConfig setMaxConnectionsPerRoute(Integer maxConnectionsPerRoute) { this.maxConnectionsPerRoute = maxConnectionsPerRoute; return this; } - public long getMaxSpoolSize() { + public Long getMaxSpoolSize() { return maxSpoolSize; } - public HttpFetcherConfig setMaxSpoolSize(long maxSpoolSize) { + public HttpFetcherConfig setMaxSpoolSize(Long maxSpoolSize) { this.maxSpoolSize = maxSpoolSize; return this; } - public int getMaxRedirects() { + public Integer getMaxRedirects() { return maxRedirects; } - public HttpFetcherConfig setMaxRedirects(int maxRedirects) { + public HttpFetcherConfig setMaxRedirects(Integer maxRedirects) { this.maxRedirects = maxRedirects; return this; } - public List getHeaders() { - return headers; + public List getHttpHeaders() { + return httpHeaders; } - public HttpFetcherConfig setHeaders(List headers) { - this.headers = headers; + public HttpFetcherConfig setHttpHeaders(List httpHeaders) { + this.httpHeaders = httpHeaders; return this; } - public long getOverallTimeout() { + public Long getOverallTimeout() { return overallTimeout; } - public HttpFetcherConfig setOverallTimeout(long overallTimeout) { + public HttpFetcherConfig setOverallTimeout(Long overallTimeout) { this.overallTimeout = overallTimeout; return this; } - public int getMaxErrMsgSize() { + public Integer getMaxErrMsgSize() { return maxErrMsgSize; } - public HttpFetcherConfig setMaxErrMsgSize(int maxErrMsgSize) { + public HttpFetcherConfig setMaxErrMsgSize(Integer maxErrMsgSize) { this.maxErrMsgSize = maxErrMsgSize; return this; } diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index def351e54a..58fdf71f0d 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -22,6 +22,7 @@ 3.24.0 1.2.2 11 + 4.2.1 @@ -209,6 +210,12 @@ jetty-server test + + org.awaitility + awaitility + ${awaitility.version} + test + diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 2fabf8fd90..e2f06ffa51 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -33,6 +33,7 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.rpc.Status; @@ -71,7 +72,10 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { private static final Logger LOG = LoggerFactory.getLogger(TikaConfigSerializer.class); - public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + static { + OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } /** * FetcherID is key, The pair is the Fetcher object and the Metadata @@ -186,17 +190,17 @@ private void fetchAndParseImpl(FetchAndParseRequest request, PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + FetchAndParseReply.Builder fetchReplyBuilder = + FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { - FetchAndParseReply.Builder fetchReplyBuilder = - FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); for (String name : metadata.names()) { String value = metadata.get(name); if (value != null) { fetchReplyBuilder.putFields(name, value); } } - responseObserver.onNext(fetchReplyBuilder.build()); } + responseObserver.onNext(fetchReplyBuilder.build()); } catch (IOException e) { throw new RuntimeException(e); } catch (InterruptedException e) { @@ -210,10 +214,10 @@ public void saveFetcher(SaveFetcherRequest request, StreamObserver responseObserver) { SaveFetcherReply reply = SaveFetcherReply.newBuilder().setFetcherId(request.getFetcherId()).build(); - Map tikaParamsMap = createTikaParamMap(request.getParamsMap()); try { - saveFetcher(request.getFetcherId(), request.getFetcherClass(), request.getParamsMap(), - tikaParamsMap); + Map fetcherConfigMap = OBJECT_MAPPER.readValue(request.getFetcherConfigJson(), new TypeReference<>() {}); + Map tikaParamsMap = createTikaParamMap(fetcherConfigMap); + saveFetcher(request.getFetcherId(), request.getFetcherClass(), fetcherConfigMap, tikaParamsMap); updateTikaConfig(); } catch (Exception e) { throw new RuntimeException(e); @@ -222,8 +226,7 @@ public void saveFetcher(SaveFetcherRequest request, responseObserver.onCompleted(); } - private void saveFetcher(String name, String fetcherClassName, Map paramsMap, - Map tikaParamsMap) { + private void saveFetcher(String name, String fetcherClassName, Map paramsMap, Map tikaParamsMap) { try { if (paramsMap == null) { paramsMap = new LinkedHashMap<>(); @@ -255,9 +258,9 @@ private void saveFetcher(String name, String fetcherClassName, Map createTikaParamMap(Map paramsMap) { + private static Map createTikaParamMap(Map fetcherConfigMap) { Map tikaParamsMap = new HashMap<>(); - for (Map.Entry entry : paramsMap.entrySet()) { + for (Map.Entry entry : fetcherConfigMap.entrySet()) { tikaParamsMap.put(entry.getKey(), new Param<>(entry.getKey(), entry.getValue())); } return tikaParamsMap; @@ -283,12 +286,9 @@ public void getFetcher(GetFetcherRequest request, } getFetcherReply.setFetcherId(request.getFetcherId()); getFetcherReply.setFetcherClass(abstractFetcher.getClass().getName()); - Map paramMap = - OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() { - }); + Map paramMap = OBJECT_MAPPER.convertValue(abstractConfig, new TypeReference<>() {}); paramMap.forEach( (k, v) -> getFetcherReply.putParams(Objects.toString(k), Objects.toString(v))); - responseObserver.onNext(getFetcherReply.build()); responseObserver.onCompleted(); } diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 66d946adc0..46d7458616 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -14,6 +14,8 @@ syntax = "proto3"; package tika; +import "google/protobuf/any.proto"; + option java_multiple_files = true; option java_package = "org.apache.tika"; option java_outer_classname = "TikaProto"; @@ -35,7 +37,7 @@ service Tika { message SaveFetcherRequest { string fetcher_id = 1; string fetcher_class = 2; - map params = 3; + string fetcher_config_json = 3; } message SaveFetcherReply { diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java deleted file mode 100644 index 840753c486..0000000000 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/HttpLoadTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package org.apache.tika.pipes.grpc; - -import java.io.File; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.URL; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; - -import io.grpc.Grpc; -import io.grpc.InsecureChannelCredentials; -import io.grpc.ManagedChannel; -import org.apache.commons.io.FileUtils; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.ResourceHandler; -import org.eclipse.jetty.util.resource.PathResource; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.apache.tika.FetchAndParseRequest; -import org.apache.tika.SaveFetcherReply; -import org.apache.tika.SaveFetcherRequest; -import org.apache.tika.TikaGrpc; -import org.apache.tika.pipes.fetcher.http.HttpFetcher; - -class HttpLoadTest { - static File tikaConfigXmlTemplate = - Paths.get("src", "test", "resources", "tika-pipes-test-config.xml").toFile(); - static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); - - static TikaGrpcServer grpcServer; - static int grpcPort; - static String httpServerUrl; - - static TikaGrpc.TikaBlockingStub blockingStub; - String httpFetcherId = "httpFetcherIdHere"; - - List files = Arrays.asList("014760.docx", "017091.docx", "017097.docx", "018367.docx"); - - static int findAvailablePort() throws IOException { - try (ServerSocket serverSocket = new ServerSocket(0)) { - return serverSocket.getLocalPort(); - } - } - - static Server httpServer; - static int httpServerPort; - - @BeforeAll - static void setUpHttpServer() throws Exception { - // Specify the folder from which files will be served - httpServerPort = findAvailablePort(); - httpServer = new Server(httpServerPort); - - ResourceHandler resourceHandler = new ResourceHandler(); - resourceHandler.setDirAllowed(true); - resourceHandler.setBaseResource(new PathResource(Paths.get("src", "test", "resources", - "test-files"))); - httpServer.setHandler(resourceHandler); - grpcServer.start(); - - httpServerUrl = InetAddress.getLocalHost().getHostAddress() + ":" + httpServerPort; - } - - @BeforeAll - static void setUpGrpcServer() throws Exception { - setupTikaGrpcServer(); - } - - private static void setupTikaGrpcServer() throws Exception { - grpcPort = findAvailablePort(); - System.getProperty("server.port", String.valueOf(grpcPort)); - - FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); - TikaGrpcServer.setTikaConfigPath(tikaConfigXml.getAbsolutePath()); - grpcServer = new TikaGrpcServer(); - grpcServer.start(); - - String target = InetAddress.getLocalHost().getHostAddress() + ":" + grpcPort; - - ManagedChannel channel = - Grpc.newChannelBuilder(target, InsecureChannelCredentials.create()).build(); - - blockingStub = TikaGrpc.newBlockingStub(channel); - } - - @AfterAll - static void stopHttpServer() throws Exception { - httpServer.stop(); - } - - @AfterAll - static void stopGrpcServer() throws Exception { - grpcServer.stop(); - } - - @BeforeEach - void createHttpFetcher() { - SaveFetcherRequest saveFetcherRequest = SaveFetcherRequest.newBuilder() - .setFetcherId(httpFetcherId) - .setFetcherClass(HttpFetcher.class.getName()) - .build(); - SaveFetcherReply saveFetcherReply = blockingStub.saveFetcher(saveFetcherRequest); - Assertions.assertEquals(saveFetcherReply.getFetcherId(), httpFetcherId); - } - - @Test - void testHttpFetchScenario() throws Exception { - for (String file : files) { - FetchAndParseRequest fetchAndParseRequest = FetchAndParseRequest.newBuilder() - .setFetchKey(httpServerUrl + "/" + file).build(); - - } - } - - // Method to send an HTTP GET request and return the response code - private int sendHttpRequest(String urlString) throws IOException { - URL url = new URL(urlString); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - return connection.getResponseCode(); - } -} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java new file mode 100644 index 0000000000..6c4ea9c391 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java @@ -0,0 +1,194 @@ +/* + * 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.tika.pipes.grpc; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import io.grpc.Grpc; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import io.grpc.stub.StreamObserver; +import org.apache.commons.io.FileUtils; +import org.awaitility.Awaitility; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.util.resource.PathResource; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.tika.FetchAndParseReply; +import org.apache.tika.FetchAndParseRequest; +import org.apache.tika.SaveFetcherReply; +import org.apache.tika.SaveFetcherRequest; +import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.fetcher.http.HttpFetcher; + +/** + * This test will start an HTTP server using jetty. + * Then it will start Tika Pipes Grpc service. + * Then it will, using a bidirectional stream of data, send urls to the + * HTTP fetcher whilst simultaneously receiving parsed output as they parse. + */ +class PipesBiDirectionalStreamingIntegrationTest { + static final Logger LOGGER = LoggerFactory.getLogger(PipesBiDirectionalStreamingIntegrationTest.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + static File tikaConfigXmlTemplate = Paths + .get("src", "test", "resources", "tika-pipes-test-config.xml") + .toFile(); + static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); + static TikaGrpcServer grpcServer; + static int grpcPort; + static String httpServerUrl; + static TikaGrpc.TikaBlockingStub tikaBlockingStub; + static TikaGrpc.TikaStub tikaStub; + static Server httpServer; + static int httpServerPort; + String httpFetcherId = "httpFetcherIdHere"; + List files = Arrays.asList("014760.docx", "017091.docx", "017097.docx", "018367.docx"); + + static int findAvailablePort() throws IOException { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } + } + + @BeforeAll + static void setUpHttpServer() throws Exception { + // Specify the folder from which files will be served + httpServerPort = findAvailablePort(); + httpServer = new Server(httpServerPort); + + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirAllowed(true); + resourceHandler.setBaseResource(new PathResource(Paths.get("src", "test", "resources", "test-files"))); + httpServer.setHandler(resourceHandler); + httpServer.start(); + + httpServerUrl = "http://" + InetAddress + .getByName("localhost") + .getHostAddress() + ":" + httpServerPort; + } + + @BeforeAll + static void setUpGrpcServer() throws Exception { + grpcPort = findAvailablePort(); + System.setProperty("server.port", String.valueOf(grpcPort)); + + FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); + TikaGrpcServer.setTikaConfigPath(tikaConfigXml.getAbsolutePath()); + grpcServer = new TikaGrpcServer(); + grpcServer.start(); + + String target = InetAddress + .getByName("localhost") + .getHostAddress() + ":" + grpcPort; + + ManagedChannel channel = Grpc + .newChannelBuilder(target, InsecureChannelCredentials.create()) + .build(); + + tikaBlockingStub = TikaGrpc.newBlockingStub(channel); + tikaStub = TikaGrpc.newStub(channel); + } + + @AfterAll + static void stopHttpServer() throws Exception { + httpServer.stop(); + } + + @AfterAll + static void stopGrpcServer() throws Exception { + grpcServer.stop(); + } + + @BeforeEach + void createHttpFetcher() throws Exception { + SaveFetcherRequest saveFetcherRequest = SaveFetcherRequest + .newBuilder() + .setFetcherId(httpFetcherId) + .setFetcherClass(HttpFetcher.class.getName()) + .setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(ImmutableMap + .builder() + .put("requestTimeout", 30_000) + .put("socketTimeout", 30_000) + .put("connectTimeout", 20_000) + .put("maxConnectionsPerRoute", 200) + .put("maxRedirects", 0) + .put("maxSpoolSize", -1) + .put("overallTimeout", 50_000) + .build())) + .build(); + SaveFetcherReply saveFetcherReply = tikaBlockingStub.saveFetcher(saveFetcherRequest); + Assertions.assertEquals(saveFetcherReply.getFetcherId(), httpFetcherId); + } + + @Test + void testHttpFetchScenario() throws Exception { + AtomicInteger numParsed = new AtomicInteger(); + Map> result = Collections.synchronizedMap(new HashMap<>()); + StreamObserver responseObserver = new StreamObserver<>() { + @Override + public void onNext(FetchAndParseReply fetchAndParseReply) { + LOGGER.info("Parsed: {}", fetchAndParseReply.getFetchKey()); + numParsed.incrementAndGet(); + result.put(fetchAndParseReply.getFetchKey(), fetchAndParseReply.getFieldsMap()); + } + + @Override + public void onError(Throwable throwable) { + LOGGER.error("Error occurred", throwable); + } + + @Override + public void onCompleted() { + LOGGER.info("Completed fetching."); + } + }; + StreamObserver request = tikaStub.fetchAndParseBiDirectionalStreaming(responseObserver); + for (String file : files) { + request.onNext(FetchAndParseRequest + .newBuilder() + .setFetcherId(httpFetcherId) + .setFetchKey(httpServerUrl + "/" + file) + .build()); + } + request.onCompleted(); + + Awaitility.await().atMost(Duration.ofSeconds(600)).until(() -> result.size() == files.size()); + + Assertions.assertEquals(files.size(), numParsed.get()); + } +} diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 99acc92b66..1622dea55e 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -35,6 +35,8 @@ import com.asarkar.grpc.test.GrpcCleanupExtension; import com.asarkar.grpc.test.Resources; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; import io.grpc.ManagedChannel; import io.grpc.Server; import io.grpc.Status; @@ -63,10 +65,12 @@ @ExtendWith(GrpcCleanupExtension.class) public class TikaGrpcServerTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Logger LOG = LoggerFactory.getLogger(TikaGrpcServerTest.class); public static final int NUM_TEST_DOCS = 50; - static File tikaConfigXmlTemplate = - Paths.get("src", "test", "resources", "tika-pipes-test-config.xml").toFile(); + static File tikaConfigXmlTemplate = Paths + .get("src", "test", "resources", "tika-pipes-test-config.xml") + .toFile(); static File tikaConfigXml = new File("target", "tika-config-" + UUID.randomUUID() + ".xml"); @@ -81,13 +85,18 @@ static void init() throws Exception { public void testFetcherCrud(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); - Server server = InProcessServerBuilder.forName(serverName).directExecutor() - .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build() + Server server = InProcessServerBuilder + .forName(serverName) + .directExecutor() + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())) + .build() .start(); resources.register(server, Duration.ofSeconds(10)); - ManagedChannel channel = - InProcessChannelBuilder.forName(serverName).directExecutor().build(); + ManagedChannel channel = InProcessChannelBuilder + .forName(serverName) + .directExecutor() + .build(); resources.register(channel, Duration.ofSeconds(10)); TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); @@ -95,32 +104,48 @@ public void testFetcherCrud(Resources resources) throws Exception { // create fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; - SaveFetcherReply reply = blockingStub.saveFetcher( - SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder) - .putParams("extractFileSystemMetadata", "true").build()); + SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(ImmutableMap + .builder() + .put("basePath", targetFolder) + .put("extractFileSystemMetadata", true) + .build())) + .build()); assertEquals(fetcherId, reply.getFetcherId()); } // update fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; - SaveFetcherReply reply = blockingStub.saveFetcher( - SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder) - .putParams("extractFileSystemMetadata", "false").build()); + SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(ImmutableMap + .builder() + .put("basePath", targetFolder) + .put("extractFileSystemMetadata", false) + .build())) + .build()); assertEquals(fetcherId, reply.getFetcherId()); - GetFetcherReply getFetcherReply = - blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); - assertEquals("false", getFetcherReply.getParamsMap().get("extractFileSystemMetadata")); + GetFetcherReply getFetcherReply = blockingStub.getFetcher(GetFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .build()); + assertEquals("false", getFetcherReply + .getParamsMap() + .get("extractFileSystemMetadata")); } // get fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; - GetFetcherReply getFetcherReply = - blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); + GetFetcherReply getFetcherReply = blockingStub.getFetcher(GetFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .build()); assertEquals(fetcherId, getFetcherReply.getFetcherId()); assertEquals(FileSystemFetcher.class.getName(), getFetcherReply.getFetcherClass()); } @@ -128,14 +153,21 @@ public void testFetcherCrud(Resources resources) throws Exception { // delete fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { String fetcherId = "fetcherIdHere" + i; - DeleteFetcherReply deleteFetcherReply = - blockingStub.deleteFetcher(DeleteFetcherRequest.newBuilder().setFetcherId(fetcherId).build()); + DeleteFetcherReply deleteFetcherReply = blockingStub.deleteFetcher(DeleteFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .build()); Assertions.assertTrue(deleteFetcherReply.getSuccess()); - StatusRuntimeException statusRuntimeException = - Assertions.assertThrows(StatusRuntimeException.class, () -> - blockingStub.getFetcher(GetFetcherRequest.newBuilder().setFetcherId(fetcherId).build())); - Assertions.assertEquals(Status.NOT_FOUND.getCode().value(), - statusRuntimeException.getStatus().getCode().value()); + StatusRuntimeException statusRuntimeException = Assertions.assertThrows(StatusRuntimeException.class, () -> blockingStub.getFetcher(GetFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .build())); + Assertions.assertEquals(Status.NOT_FOUND + .getCode() + .value(), statusRuntimeException + .getStatus() + .getCode() + .value()); } } @@ -143,35 +175,44 @@ public void testFetcherCrud(Resources resources) throws Exception { public void testBiStream(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); - Server server = InProcessServerBuilder.forName(serverName).directExecutor() - .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())).build() + Server server = InProcessServerBuilder + .forName(serverName) + .directExecutor() + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())) + .build() .start(); resources.register(server, Duration.ofSeconds(10)); - ManagedChannel channel = - InProcessChannelBuilder.forName(serverName).directExecutor().build(); + ManagedChannel channel = InProcessChannelBuilder + .forName(serverName) + .directExecutor() + .build(); resources.register(channel, Duration.ofSeconds(10)); TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); TikaGrpc.TikaStub tikaStub = TikaGrpc.newStub(channel); String fetcherId = "fetcherIdHere"; String targetFolder = new File("target").getAbsolutePath(); - SaveFetcherReply reply = blockingStub.saveFetcher( - SaveFetcherRequest.newBuilder().setFetcherId(fetcherId) - .setFetcherClass(FileSystemFetcher.class.getName()) - .putParams("basePath", targetFolder) - .putParams("extractFileSystemMetadata", "true").build()); + SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest + .newBuilder() + .setFetcherId(fetcherId) + .setFetcherClass(FileSystemFetcher.class.getName()) + .setFetcherConfigJson(OBJECT_MAPPER.writeValueAsString(ImmutableMap + .builder() + .put("basePath", targetFolder) + .put("extractFileSystemMetadata", true) + .build())) + + .build()); assertEquals(fetcherId, reply.getFetcherId()); - List fetchAndParseReplys = - Collections.synchronizedList(new ArrayList<>()); + List fetchAndParseReplys = Collections.synchronizedList(new ArrayList<>()); StreamObserver replyStreamObserver = new StreamObserver<>() { @Override public void onNext(FetchAndParseReply fetchAndParseReply) { - LOG.debug("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), - fetchAndParseReply.getFieldsMap()); + LOG.debug("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), fetchAndParseReply.getFieldsMap()); fetchAndParseReplys.add(fetchAndParseReply); } @@ -186,26 +227,25 @@ public void onCompleted() { } }; - StreamObserver requestStreamObserver = - tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); + StreamObserver requestStreamObserver = tikaStub.fetchAndParseBiDirectionalStreaming(replyStreamObserver); - File testDocumentFolder = new File("target/" + - DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ssSSS", Locale.getDefault()) - .format(LocalDateTime.now(ZoneId.systemDefault())) + "-" + - UUID.randomUUID()); + File testDocumentFolder = new File("target/" + DateTimeFormatter + .ofPattern("yyyy_MM_dd_HH_mm_ssSSS", Locale.getDefault()) + .format(LocalDateTime.now(ZoneId.systemDefault())) + "-" + UUID.randomUUID()); assertTrue(testDocumentFolder.mkdir()); try { for (int i = 0; i < NUM_TEST_DOCS; ++i) { File testFile = new File(testDocumentFolder, "test-" + i + ".html"); - FileUtils.writeStringToFile(testFile, "test " + i + "", - StandardCharsets.UTF_8); + FileUtils.writeStringToFile(testFile, "test " + i + "", StandardCharsets.UTF_8); } File[] testDocuments = testDocumentFolder.listFiles(); assertNotNull(testDocuments); for (File testDocument : testDocuments) { - requestStreamObserver.onNext( - FetchAndParseRequest.newBuilder().setFetcherId(fetcherId) - .setFetchKey(testDocument.getAbsolutePath()).build()); + requestStreamObserver.onNext(FetchAndParseRequest + .newBuilder() + .setFetcherId(fetcherId) + .setFetchKey(testDocument.getAbsolutePath()) + .build()); } requestStreamObserver.onCompleted(); diff --git a/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java b/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java index e29c03b2cc..4919c17aee 100644 --- a/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java +++ b/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java @@ -337,7 +337,9 @@ private void addCredentialsProvider(HttpClientBuilder builder) throws TikaConfig authSchemeRegistry = RegistryBuilder.create() .register("ntlm", new NTLMSchemeFactory()).build(); } - provider.setCredentials(AuthScope.ANY, credentials); + if (credentials != null) { + provider.setCredentials(AuthScope.ANY, credentials); + } builder.setDefaultCredentialsProvider(provider); builder.setDefaultAuthSchemeRegistry(authSchemeRegistry); From 1d88c9e083120e14ed265bb890473d264ec4f234 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Tue, 23 Apr 2024 13:30:47 -0500 Subject: [PATCH 38/89] add mtls as an option --- .../tika-grpc/example-dockerfile/Dockerfile | 2 +- tika-pipes/tika-grpc/pom.xml | 4 + .../tika/pipes/grpc/TikaGrpcServer.java | 110 +++++++++++++++--- ...BiDirectionalStreamingIntegrationTest.java | 13 ++- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile index 9d45e8a1eb..64c46cc948 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile +++ b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile @@ -28,4 +28,4 @@ RUN set -eux \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* EXPOSE 50051 -ENTRYPOINT [ "/bin/sh", "-c", "exec java -Dlog4j.configurationFile=/tika/config/log4j2.xml -Dserver.port=50051 -cp \"/tika/libs/*\" org.apache.tika.pipes.grpc.TikaGrpcServer /tika/config/tika-config.xml"] +ENTRYPOINT [ "/bin/sh", "-c", "exec java -Dlog4j.configurationFile=/tika/config/log4j2.xml -cp \"/tika/libs/*\" org.apache.tika.pipes.grpc.TikaGrpcServer --port 50051 --tika-config /tika/config/tika-config.xml"] diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 58fdf71f0d..9c52855c5d 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -176,6 +176,10 @@ 6.0.53 provided + + com.beust + jcommander + org.mockito mockito-core diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 67ca4a80c3..609d94ca40 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -16,11 +16,16 @@ */ package org.apache.tika.pipes.grpc; +import java.io.File; import java.util.concurrent.TimeUnit; +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; import io.grpc.Grpc; import io.grpc.InsecureServerCredentials; import io.grpc.Server; +import io.grpc.ServerCredentials; +import io.grpc.TlsServerCredentials; import io.grpc.protobuf.services.ProtoReflectionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,16 +36,48 @@ public class TikaGrpcServer { private static final Logger LOGGER = LoggerFactory.getLogger(TikaGrpcServer.class); private Server server; - private static String tikaConfigPath; + @Parameter(names = {"-p", "--port"}, description = "The grpc server port", help = true, required = true) + private Integer port; + + @Parameter(names = {"-t", "--tika-config"}, description = "The grpc server port", help = true, required = true) + private File tikaConfigXml; + + @Parameter(names = {"-s", "--secure"}, description = "Enable credentials required to access this grpc server") + private boolean secure; + + @Parameter(names = {"--cert-chain"}, description = "Certificate chain file, example cert-chain.p12") + private File certChain; + + @Parameter(names = {"--private-key"}, description = "Private key store, example private-key.p12") + private File privateKey; + + @Parameter(names = {"--private-key-password"}, description = "Private key password, if applicable") + private String privateKeyPassword; + + @Parameter(names = {"--trust-store"}, description = "The trust store. Example trust.jks") + private File trustStore; + + @Parameter(names = {"-h", "-H", "--help"}, description = "Display help menu") + private boolean help; public void start() throws Exception { - /* The port on which the server should run */ - int port = Integer.parseInt(System.getProperty("server.port", "50051")); - server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) - .addService(new TikaGrpcServerImpl(tikaConfigPath)) - .addService(ProtoReflectionService.newInstance()) // Enable reflection - .build() - .start(); + ServerCredentials creds; + if (secure) { + TlsServerCredentials.Builder channelCredBuilder = TlsServerCredentials.newBuilder(); + channelCredBuilder.keyManager(certChain, privateKey, privateKeyPassword); + if (trustStore != null && trustStore.exists()) { + channelCredBuilder.trustManager(trustStore); + channelCredBuilder.clientAuth(TlsServerCredentials.ClientAuth.REQUIRE); + } + creds = channelCredBuilder.build(); + } else { + creds = InsecureServerCredentials.create(); + } + server = Grpc.newServerBuilderForPort(port, creds) + .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())) + .addService(ProtoReflectionService.newInstance()) // Enable reflection + .build() + .start(); LOGGER.info("Server started, listening on " + port); Runtime.getRuntime().addShutdownHook(new Thread(() -> { // Use stderr here since the logger may have been reset by its JVM shutdown hook. @@ -73,17 +110,60 @@ public void blockUntilShutdown() throws InterruptedException { * Main launches the server from the command line. */ public static void main(String[] args) throws Exception { - if (args.length != 1) { - System.err.println("Usage: TikaGrpcServer {path-to-tika-config-xml-file}"); - System.exit(1); - } - tikaConfigPath = args[0]; TikaGrpcServer server = new TikaGrpcServer(); + JCommander commander = JCommander + .newBuilder() + .addObject(server) + .build(); + + commander.parse(args); + + if (server.help) { + commander.usage(); + return; + } + server.start(); server.blockUntilShutdown(); } - public static void setTikaConfigPath(String tikaConfigPath) { - TikaGrpcServer.tikaConfigPath = tikaConfigPath; + public TikaGrpcServer setTikaConfigXml(File tikaConfigXml) { + this.tikaConfigXml = tikaConfigXml; + return this; + } + + public TikaGrpcServer setServer(Server server) { + this.server = server; + return this; + } + + public TikaGrpcServer setPort(Integer port) { + this.port = port; + return this; + } + + public TikaGrpcServer setSecure(boolean secure) { + this.secure = secure; + return this; + } + + public TikaGrpcServer setCertChain(File certChain) { + this.certChain = certChain; + return this; + } + + public TikaGrpcServer setPrivateKey(File privateKey) { + this.privateKey = privateKey; + return this; + } + + public TikaGrpcServer setPrivateKeyPassword(String privateKeyPassword) { + this.privateKeyPassword = privateKeyPassword; + return this; + } + + public TikaGrpcServer setTrustStore(File trustStore) { + this.trustStore = trustStore; + return this; } } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java index 6c4ea9c391..e33cd559f3 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java @@ -105,11 +105,10 @@ static void setUpHttpServer() throws Exception { @BeforeAll static void setUpGrpcServer() throws Exception { grpcPort = findAvailablePort(); - System.setProperty("server.port", String.valueOf(grpcPort)); - FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); - TikaGrpcServer.setTikaConfigPath(tikaConfigXml.getAbsolutePath()); grpcServer = new TikaGrpcServer(); + grpcServer.setTikaConfigXml(tikaConfigXml); + grpcServer.setPort(grpcPort); grpcServer.start(); String target = InetAddress @@ -126,12 +125,16 @@ static void setUpGrpcServer() throws Exception { @AfterAll static void stopHttpServer() throws Exception { - httpServer.stop(); + if (httpServer != null) { + httpServer.stop(); + } } @AfterAll static void stopGrpcServer() throws Exception { - grpcServer.stop(); + if (grpcServer != null) { + grpcServer.stop(); + } } @BeforeEach From cdba335b9dea3be948c6b8549111e0a5f18d1e02 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Tue, 23 Apr 2024 14:58:55 -0500 Subject: [PATCH 39/89] add mtls in the example --- .../tika/pipes/grpc/TikaGrpcServer.java | 30 ++++++++++++------- ...BiDirectionalStreamingIntegrationTest.java | 17 +++++++++-- .../src/test/resources/certs/README.md | 5 ++++ .../tika-grpc/src/test/resources/certs/ca.key | 28 +++++++++++++++++ .../tika-grpc/src/test/resources/certs/ca.pem | 20 +++++++++++++ .../src/test/resources/certs/client.key | 28 +++++++++++++++++ .../src/test/resources/certs/client.pem | 20 +++++++++++++ .../src/test/resources/certs/server1.key | 28 +++++++++++++++++ .../src/test/resources/certs/server1.pem | 22 ++++++++++++++ 9 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/README.md create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/ca.key create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/ca.pem create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/client.key create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/client.pem create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/server1.key create mode 100644 tika-pipes/tika-grpc/src/test/resources/certs/server1.pem diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 609d94ca40..b205edf48c 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -45,17 +45,20 @@ public class TikaGrpcServer { @Parameter(names = {"-s", "--secure"}, description = "Enable credentials required to access this grpc server") private boolean secure; - @Parameter(names = {"--cert-chain"}, description = "Certificate chain file, example cert-chain.p12") + @Parameter(names = {"--cert-chain"}, description = "Certificate chain file. Example: server1.pem See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls") private File certChain; - @Parameter(names = {"--private-key"}, description = "Private key store, example private-key.p12") + @Parameter(names = {"--private-key"}, description = "Private key store. Example: server1.key See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls") private File privateKey; - @Parameter(names = {"--private-key-password"}, description = "Private key password, if applicable") + @Parameter(names = {"--private-key-password"}, description = "Private key password, if needed") private String privateKeyPassword; - @Parameter(names = {"--trust-store"}, description = "The trust store. Example trust.jks") - private File trustStore; + @Parameter(names = {"--trust-cert-collection"}, description = "The trust certificate collection (root certs). Example: ca.pem See: https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/examples/example-tls") + private File trustCertCollection; + + @Parameter(names = {"--client-auth-required"}, description = "Is Mutual TLS required?") + private boolean clientAuthRequired; @Parameter(names = {"-h", "-H", "--help"}, description = "Display help menu") private boolean help; @@ -65,9 +68,11 @@ public void start() throws Exception { if (secure) { TlsServerCredentials.Builder channelCredBuilder = TlsServerCredentials.newBuilder(); channelCredBuilder.keyManager(certChain, privateKey, privateKeyPassword); - if (trustStore != null && trustStore.exists()) { - channelCredBuilder.trustManager(trustStore); - channelCredBuilder.clientAuth(TlsServerCredentials.ClientAuth.REQUIRE); + if (trustCertCollection != null && trustCertCollection.exists()) { + channelCredBuilder.trustManager(trustCertCollection); + if (clientAuthRequired) { + channelCredBuilder.clientAuth(TlsServerCredentials.ClientAuth.REQUIRE); + } } creds = channelCredBuilder.build(); } else { @@ -162,8 +167,13 @@ public TikaGrpcServer setPrivateKeyPassword(String privateKeyPassword) { return this; } - public TikaGrpcServer setTrustStore(File trustStore) { - this.trustStore = trustStore; + public TikaGrpcServer setTrustCertCollection(File trustCertCollection) { + this.trustCertCollection = trustCertCollection; + return this; + } + + public TikaGrpcServer setClientAuthRequired(boolean clientAuthRequired) { + this.clientAuthRequired = clientAuthRequired; return this; } } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java index e33cd559f3..e78110abb1 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/PipesBiDirectionalStreamingIntegrationTest.java @@ -33,8 +33,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableMap; import io.grpc.Grpc; -import io.grpc.InsecureChannelCredentials; import io.grpc.ManagedChannel; +import io.grpc.TlsChannelCredentials; +import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.grpc.stub.StreamObserver; import org.apache.commons.io.FileUtils; import org.awaitility.Awaitility; @@ -106,17 +107,29 @@ static void setUpHttpServer() throws Exception { static void setUpGrpcServer() throws Exception { grpcPort = findAvailablePort(); FileUtils.copyFile(tikaConfigXmlTemplate, tikaConfigXml); + grpcServer = new TikaGrpcServer(); grpcServer.setTikaConfigXml(tikaConfigXml); grpcServer.setPort(grpcPort); + grpcServer.setSecure(true); + grpcServer.setCertChain(Paths.get("src", "test", "resources", "certs", "server1.pem").toFile()); + grpcServer.setPrivateKey(Paths.get("src", "test", "resources", "certs", "server1.key").toFile()); + grpcServer.setTrustCertCollection(Paths.get("src", "test", "resources", "certs", "ca.pem").toFile()); + grpcServer.setClientAuthRequired(true); grpcServer.start(); String target = InetAddress .getByName("localhost") .getHostAddress() + ":" + grpcPort; + TlsChannelCredentials.Builder channelCredBuilder = TlsChannelCredentials.newBuilder(); + File clientCertChain = Paths.get("src", "test", "resources", "certs", "client.pem").toFile(); + File clientPrivateKey = Paths.get("src", "test", "resources", "certs", "client.key").toFile(); + channelCredBuilder.keyManager(clientCertChain, clientPrivateKey); + channelCredBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE.getTrustManagers()); + ManagedChannel channel = Grpc - .newChannelBuilder(target, InsecureChannelCredentials.create()) + .newChannelBuilder(target, channelCredBuilder.build()) .build(); tikaBlockingStub = TikaGrpc.newBlockingStub(channel); diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/README.md b/tika-pipes/tika-grpc/src/test/resources/certs/README.md new file mode 100644 index 0000000000..7373d56354 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/README.md @@ -0,0 +1,5 @@ +# Test certs for Tika Grpc mTLS + +Generate these using script as documented here: + +https://github.com/grpc/grpc-java/tree/b3ffb5078df361d7460786e134db7b5c00939246/testing/src/main/resources/certs diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/ca.key b/tika-pipes/tika-grpc/src/test/resources/certs/ca.key new file mode 100644 index 0000000000..03be0bfa6e --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwYvShd+UXQvOg +z4GH6pRT3KGrPDbDw45fma7+I0LJQ4GupoeLuYYfHvcYPTV2I3MLO+VxCp00gfo1 +BIvsNOkGNxrrqNhP27ve9l7YwOuvWdVu4u9+73znRx3GJQ4ie/nF/z6xMcbQL5r5 +UC8yGwuJGOyr6VcpEnKTnORtuwRPJuqnGgn4rsKhLLfJz+RAhjdOKnAS3CQo/iHP +KjoqIZ38M97GJ7icFQic3dtLUFR41nnN5ogLZ6DduR55btypPnlv5h6foLFjRMST +MEroAq39ZSJqUoyBPTBtPFFk7uRQIfdKrp7/Bd4V0n4e91Us+UCDlOcxo2lF1CKH +/ydEWmx3AgMBAAECggEAKrDosKQKKKUlvkg6+6CFIf8GiiFax+ru7KiPuCbkpT3X +h2P67pCKq8Gc4Jr/84YE9DUdBU0iW3ESE/7ztsnflIeF1n/ZSwrN39sVfbTD1n8R +r3LxsHFac8e8pxaU4zfKbmemztBTZFQBWFJV+fSdyCLmNX2WgPRcEuooR366PkWv +xZLAxeDGqpnsa62o1GdGmalxx8aljLN/QcbQi73mR9Osim1OtSd1cyDlZ/8x6OoV +Ae5GDN3Bj0hO9ZKzNWTbQpRw9SHKU6sWXtHlcDx4xi5kN/n9aptn7kixbY9y8uOM +5zjErVGWvxdP94IvlSkrkenwnIjlHBtdlAjVuCFioQKBgQDoJLyfHNWPBnjPGVnK +xcbIIwmf4C9UnZBbHRD3YxU/GBpsPgPh9EwhQTAXlGQGHeuslxCIh4cEfbOIrJ9b +/s3OqeL9CSUaz/N+1av1ZuwOI9CEvNPi51IK+rXNRmVJG8pG6RaKNx57pXaFtmqq +FUtC7twbPECvjspapn61nZYSiQKBgQDCg1tpGwZJJOCIkhYH4wFc4j4p0LxIcBJ2 +E3L9VnQ+APT/x8uitkZsuRY9tmWcHK8/zWTc1GpFdwGUJ9+Yzvprtej+P/buxM9J +Y6ZJZdCIHWDuh3eq+sXS4lwr5fi7ir5m97npG1bXPlOoYIJ7p172EyoNmurRIgiP +LWnzK0jG/wKBgQCRQtOouNFFcyZLaTCPutxdRddy7ESRrRq0eOax9pVH6tw12URy +snyk3naqepdwYG6li82zsSKig8nA/0uktDeyVwoLjhpiwbc7KZc1sxaI7o4/US1B +McBb0G/MqH0elz4myxnomP8BHhOhLflmvnZexrqCbFyJvk8PFFn7aUWMCQKBgDvX +9BCzOszYJqh94X9NrQapqJxu1u6mZFelhjRBHARTgQ0MqC8IS0R58UjNTBeqj5Re +mdCDHar/gSHW3qkBzPPEhMlsXol5TZjzqp5cT7sA5uicDwowmxpVgCwVVeBFQG0n +fDAmtCIGz/A2uQ5YIRQuMzr6VZJAGUgLndQtlfd7AoGBAMq1imggFKd1rt49XCnO +t97lpWOT+TlWYblHr01tOw+esawG5MFucqVI6tGpBSccTRQw6orWf4GK3KmkgQ6J +UgHKjwYsA0sf4U5vppkdkbAbM/WwUPOTQpGFRERyJqMqFGIc4wMtZOJBxXwf+9iD +l8tvan8w/6HugqnI7qqkTgLq +-----END PRIVATE KEY----- diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/ca.pem b/tika-pipes/tika-grpc/src/test/resources/certs/ca.pem new file mode 100644 index 0000000000..49d39cd8ed --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWjCCAkKgAwIBAgIUWrP0VvHcy+LP6UuYNtiL9gBhD5owDQYJKoZIhvcNAQEL +BQAwVjELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEPMA0GA1UEAwwGdGVzdGNhMB4XDTIw +MDMxNzE4NTk1MVoXDTMwMDMxNTE4NTk1MVowVjELMAkGA1UEBhMCQVUxEzARBgNV +BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 +ZDEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAsGL0oXflF0LzoM+Bh+qUU9yhqzw2w8OOX5mu/iNCyUOBrqaHi7mGHx73GD01 +diNzCzvlcQqdNIH6NQSL7DTpBjca66jYT9u73vZe2MDrr1nVbuLvfu9850cdxiUO +Inv5xf8+sTHG0C+a+VAvMhsLiRjsq+lXKRJyk5zkbbsETybqpxoJ+K7CoSy3yc/k +QIY3TipwEtwkKP4hzyo6KiGd/DPexie4nBUInN3bS1BUeNZ5zeaIC2eg3bkeeW7c +qT55b+Yen6CxY0TEkzBK6AKt/WUialKMgT0wbTxRZO7kUCH3Sq6e/wXeFdJ+HvdV +LPlAg5TnMaNpRdQih/8nRFpsdwIDAQABoyAwHjAMBgNVHRMEBTADAQH/MA4GA1Ud +DwEB/wQEAwICBDANBgkqhkiG9w0BAQsFAAOCAQEAkTrKZjBrJXHps/HrjNCFPb5a +THuGPCSsepe1wkKdSp1h4HGRpLoCgcLysCJ5hZhRpHkRihhef+rFHEe60UePQO3S +CVTtdJB4CYWpcNyXOdqefrbJW5QNljxgi6Fhvs7JJkBqdXIkWXtFk2eRgOIP2Eo9 +/OHQHlYnwZFrk6sp4wPyR+A95S0toZBcyDVz7u+hOW0pGK3wviOe9lvRgj/H3Pwt +bewb0l+MhRig0/DVHamyVxrDRbqInU1/GTNCwcZkXKYFWSf92U+kIcTth24Q1gcw +eZiLl5FfrWokUNytFElXob0V0a5/kbhiLc3yWmvWqHTpqCALbVyF+rKJo2f5Kw== +-----END CERTIFICATE----- diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/client.key b/tika-pipes/tika-grpc/src/test/resources/certs/client.key new file mode 100644 index 0000000000..349b40033d --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCyqYRp+DXVp72N +FbQH8hdhTZLycZXOlJhmMsrJmrjn2p7pI/8mTZ/0FC+SGWBGZV+ELiHrmCX5zfaI +Lr9Iuw7Ghr3Vzoefi8r62rLupVPNi/qdqyjWk2dECHC9Z3+Ag3KzKTyerXWjKcvy +KVmM0ZxE0RXhDW/RoQbqZsU2GKg1B2rhUU8KN0gVmKn0rJHOxzRVSYeYLYp5Yn7K +rtPJcKyo9aVuEr7dGANzpyF6lg/nYBWc+9SGwkoLdFvKvABYJMyrbNhHUQfv0fza +Z0P86dfTENrDxzALrzGnqcx3KTrwJjkZ/aSr1tyD0/tXvukRFiPxWBJhjHQ70GqT +FQY19RbhAgMBAAECggEAIL8JUhL4awyvpWhQ8xPgTSlWwbEn8BE0TacJnCILuhNM +BRdf8LlRk/8PKQwVpVF3TFbYSMI+U6b4hMVssfv3HVQc/083dHq+3XOwUCVlUstR +SAzTE2E5EDMr1stdh0SQhV4Nilfos9s5Uk1Z6IGSztoz1GgOErIc/mGPy/aA/hbr +fRWHvTp35+MbCJSvZuOeevX2iLs0dNzqdk6DiOWIH/BVGirVPtO6ykrkuTj1FWiN +hyZ3MBChShlNH2poNX46ntOc7nEus0qteOgxBK8lummFEtlehCA7hd/8xuvYlP0k +7aN684LCRDajmAGpoZO57NSDYQhAFGZeUZ93SMFucQKBgQDe7GGkzZFEiv91u1q9 +lgMy1h5dZjIZKgQaOarPC6wCQMUdqCf6cSLsAPr4T8EDoWsnY7dSnrTZ6YCIFL1T +idg8M3BQXipICCJkFORS76pKKZ0wMn3/NgkSepsmNct91WHr6okvx4tOaoRCtdzU +g7jt4Mr3sfLCiZtqTQyySdMUEwKBgQDNK+ZFKL0XhkWZP+PGKjWG8LWpPiK3d78/ +wYBFXzSTGlkr6FvRmYtZeNwXWRYLB4UxZ9At4hbJVEdi/2dITOz/sehVDyCAjjs3 +gycsc3UJqiZbcw5XKhI5TWBuWxkKENdbMSayogVbp2aSYoRblH764//t0ACmbfTW +KUQRQPB/uwKBgQC5QjjjfPL8w4cJkGoYpFKELO2PMR7xSrmeEc6hwlFwjeNCgjy3 +JM6g0y++rIj7O2qRkY0IXFxvvF3UuWedxTCu1xC/uYHp2ti506LsScB7YZoAM/YB +4iYn9Tx6xLoYGP0H0iGwU2SyBlNkHT8oXU+SYP5MWtYkVbeS3/VtNWz1gQKBgQCA +6Nk4kN0mH7YxEKRzSOfyzeDF4oV7kuB2FYUbkTL+TirC3K58JiYY5Egc31trOKFm +Jlz1xz0b6DkmKWTiV3r9OPHKJ8P7IeJxAZWmZzCdDuwkv0i+WW+z0zsIe3JjEavN +3zb6O7R0HtziksWoqMeTqZeO+wa9iw6vVKQw1wWEqwKBgFHfahFs0DZ5cUTpGpBt +F/AQG7ukgipB6N6AkB9kDbgCs1FLgd199MQrEncug5hfpq8QerbyMatmA+GXoGMb +7vztKEH85yzp4n02FNL6H7xL4VVILvyZHdolmiORJ4qT2hZnl8pEQ2TYuF4RlHUd +nSwXX+2o0J/nF85fm4AwWKAc +-----END PRIVATE KEY----- diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/client.pem b/tika-pipes/tika-grpc/src/test/resources/certs/client.pem new file mode 100644 index 0000000000..8815875f32 --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/client.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNzCCAh8CFGyX00RCepOv/qCJ1oVdTtY92U83MA0GCSqGSIb3DQEBCwUAMFYx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMMBnRlc3RjYTAeFw0yMDAzMTgw +MTA2MTBaFw0zMDAzMTYwMTA2MTBaMFoxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApT +b21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEzAR +BgNVBAMMCnRlc3RjbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQCyqYRp+DXVp72NFbQH8hdhTZLycZXOlJhmMsrJmrjn2p7pI/8mTZ/0FC+SGWBG +ZV+ELiHrmCX5zfaILr9Iuw7Ghr3Vzoefi8r62rLupVPNi/qdqyjWk2dECHC9Z3+A +g3KzKTyerXWjKcvyKVmM0ZxE0RXhDW/RoQbqZsU2GKg1B2rhUU8KN0gVmKn0rJHO +xzRVSYeYLYp5Yn7KrtPJcKyo9aVuEr7dGANzpyF6lg/nYBWc+9SGwkoLdFvKvABY +JMyrbNhHUQfv0fzaZ0P86dfTENrDxzALrzGnqcx3KTrwJjkZ/aSr1tyD0/tXvukR +FiPxWBJhjHQ70GqTFQY19RbhAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFXCewK8 +cWT+zWxXyGFnouFSBzTi0BMBJRrhsiNoiQxkqityJHWFExiQZie+7CA+EabXCQUB ++JwMSWM29j3mSw10DTfmC3rhheQqGxy304BZyUpdpvI2dt3p/mcsE7O+p4sQrSep +gijiDssKAfxTAmUM93N6+Q8yJK5immxlbeYfijoBvmkzyB/B+qNRPsx0n7aFGnfv +oWfkW296iPhWLiwknpC3xB6oK3vRbK4Zj1OaGb0grK7VN8EyhBix2xVF61i4dzCK +kMIpl7CUpw1Mb2z8q3F2bHBS7iF7g1Ccn5VGcO+aJ+6PWydaeqJ6VEBF0Nwv9woe +mL5AluNRLaqjZvE= +-----END CERTIFICATE----- diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/server1.key b/tika-pipes/tika-grpc/src/test/resources/certs/server1.key new file mode 100644 index 0000000000..086462992c --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/server1.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDnE443EknxvxBq +6+hvn/t09hl8hx366EBYvZmVM/NC+7igXRAjiJiA/mIaCvL3MS0Iz5hBLxSGICU+ +WproA3GCIFITIwcf/ETyWj/5xpgZ4AKrLrjQmmX8mhwUajfF3UvwMJrCOVqPp67t +PtP+2kBXaqrXdvnvXR41FsIB8V7zIAuIZB6bHQhiGVlc1sgZYsE2EGG9WMmHtS86 +qkAOTjG2XyjmPTGAwhGDpYkYrpzp99IiDh4/Veai81hn0ssQkbry0XRD/Ig3jcHh +23WiriPNJ0JsbgXUSLKRPZObA9VgOLy2aXoN84IMaeK3yy+cwSYG/99w93fUZJte +MXwz4oYZAgMBAAECggEBAIVn2Ncai+4xbH0OLWckabwgyJ4IM9rDc0LIU368O1kU +koais8qP9dujAWgfoh3sGh/YGgKn96VnsZjKHlyMgF+r4TaDJn3k2rlAOWcurGlj +1qaVlsV4HiEzp7pxiDmHhWvp4672Bb6iBG+bsjCUOEk/n9o9KhZzIBluRhtxCmw5 +nw4Do7z00PTvN81260uPWSc04IrytvZUiAIx/5qxD72bij2xJ8t/I9GI8g4FtoVB +8pB6S/hJX1PZhh9VlU6Yk+TOfOVnbebG4W5138LkB835eqk3Zz0qsbc2euoi8Hxi +y1VGwQEmMQ63jXz4c6g+X55ifvUK9Jpn5E8pq+pMd7ECgYEA93lYq+Cr54K4ey5t +sWMa+ye5RqxjzgXj2Kqr55jb54VWG7wp2iGbg8FMlkQwzTJwebzDyCSatguEZLuB +gRGroRnsUOy9vBvhKPOch9bfKIl6qOgzMJB267fBVWx5ybnRbWN/I7RvMQf3k+9y +biCIVnxDLEEYyx7z85/5qxsXg/MCgYEA7wmWKtCTn032Hy9P8OL49T0X6Z8FlkDC +Rk42ygrc/MUbugq9RGUxcCxoImOG9JXUpEtUe31YDm2j+/nbvrjl6/bP2qWs0V7l +dTJl6dABP51pCw8+l4cWgBBX08Lkeen812AAFNrjmDCjX6rHjWHLJcpS18fnRRkP +V1d/AHWX7MMCgYEA6Gsw2guhp0Zf2GCcaNK5DlQab8OL4Hwrpttzo4kuTlwtqNKp +Q9H4al9qfF4Cr1TFya98+EVYf8yFRM3NLNjZpe3gwYf2EerlJj7VLcahw0KKzoN1 +QBENfwgPLRk5sDkx9VhSmcfl/diLroZdpAwtv3vo4nEoxeuGFbKTGx3Qkf0CgYEA +xyR+dcb05Ygm3w4klHQTowQ10s1H80iaUcZBgQuR1ghEtDbUPZHsoR5t1xCB02ys +DgAwLv1bChIvxvH/L6KM8ovZ2LekBX4AviWxoBxJnfz/EVau98B0b1auRN6eSC83 +FRuGldlSOW1z/nSh8ViizSYE5H5HX1qkXEippvFRE88CgYB3Bfu3YQY60ITWIShv +nNkdcbTT9eoP9suaRJjw92Ln+7ZpALYlQMKUZmJ/5uBmLs4RFwUTQruLOPL4yLTH +awADWUzs3IRr1fwn9E+zM8JVyKCnUEM3w4N5UZskGO2klashAd30hWO+knRv/y0r +uGIYs9Ek7YXlXIRVrzMwcsrt1w== +-----END PRIVATE KEY----- diff --git a/tika-pipes/tika-grpc/src/test/resources/certs/server1.pem b/tika-pipes/tika-grpc/src/test/resources/certs/server1.pem new file mode 100644 index 0000000000..88244f856c --- /dev/null +++ b/tika-pipes/tika-grpc/src/test/resources/certs/server1.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUbJfTREJ6k6/+oInWhV1O1j3ZT0IwDQYJKoZIhvcNAQEL +BQAwVjELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEPMA0GA1UEAwwGdGVzdGNhMB4XDTIw +MDMxODAzMTA0MloXDTMwMDMxNjAzMTA0MlowZTELMAkGA1UEBhMCVVMxETAPBgNV +BAgMCElsbGlub2lzMRAwDgYDVQQHDAdDaGljYWdvMRUwEwYDVQQKDAxFeGFtcGxl +LCBDby4xGjAYBgNVBAMMESoudGVzdC5nb29nbGUuY29tMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA5xOONxJJ8b8Qauvob5/7dPYZfIcd+uhAWL2ZlTPz +Qvu4oF0QI4iYgP5iGgry9zEtCM+YQS8UhiAlPlqa6ANxgiBSEyMHH/xE8lo/+caY +GeACqy640Jpl/JocFGo3xd1L8DCawjlaj6eu7T7T/tpAV2qq13b5710eNRbCAfFe +8yALiGQemx0IYhlZXNbIGWLBNhBhvVjJh7UvOqpADk4xtl8o5j0xgMIRg6WJGK6c +6ffSIg4eP1XmovNYZ9LLEJG68tF0Q/yIN43B4dt1oq4jzSdCbG4F1EiykT2TmwPV +YDi8tml6DfOCDGnit8svnMEmBv/fcPd31GSbXjF8M+KGGQIDAQABo2swaTAJBgNV +HRMEAjAAMAsGA1UdDwQEAwIF4DBPBgNVHREESDBGghAqLnRlc3QuZ29vZ2xlLmZy +ghh3YXRlcnpvb2kudGVzdC5nb29nbGUuYmWCEioudGVzdC55b3V0dWJlLmNvbYcE +wKgBAzANBgkqhkiG9w0BAQsFAAOCAQEAS8hDQA8PSgipgAml7Q3/djwQ644ghWQv +C2Kb+r30RCY1EyKNhnQnIIh/OUbBZvh0M0iYsy6xqXgfDhCB93AA6j0i5cS8fkhH +Jl4RK0tSkGQ3YNY4NzXwQP/vmUgfkw8VBAZ4Y4GKxppdATjffIW+srbAmdDruIRM +wPeikgOoRrXf0LA1fi4TqxARzeRwenQpayNfGHTvVF9aJkl8HoaMunTAdG5pIVcr +9GKi/gEMpXUJbbVv3U5frX1Wo4CFo+rZWJ/LyCMeb0jciNLxSdMwj/E/ZuExlyeZ +gc9ctPjSMvgSyXEKv6Vwobleeg88V2ZgzenziORoWj4KszG/lbQZvg== +-----END CERTIFICATE----- From 04d1f5e2faa7a186977594ba1b80334392ef95e8 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Tue, 23 Apr 2024 15:51:29 -0500 Subject: [PATCH 40/89] fix merge issue. --- .../tika/pipes/fetcher/http/HttpFetcher.java | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) 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 b3cdffbdf0..8d845807d9 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 @@ -28,6 +28,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.PrivateKey; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -35,6 +36,7 @@ import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; +import com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -69,6 +71,9 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; +import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; +import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; +import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** @@ -120,6 +125,8 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { //back-off client that disables compression private HttpClient noCompressHttpClient; + JwtGenerator jwtGenerator; + @Override public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, TikaException { LOG.info("Fetching HTTP key: {}", fetchKey); @@ -130,12 +137,22 @@ public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) .build(); get.setConfig(requestConfig); + populateHeaders(get); + return execute(get, metadata, httpClient, true); + } + + private void populateHeaders(HttpGet get) throws TikaException { if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } - return execute(get, metadata, httpClient, true); + if (jwtGenerator != null) { + try { + get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); + } catch (JOSEException e) { + throw new TikaException("Could not generate JWT", e); + } + } } - @Override public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException { HttpGet get = new HttpGet(fetchKey); @@ -439,6 +456,31 @@ public void setUserAgent(String userAgent) { httpFetcherConfig.setUserAgent(userAgent); } + @Field + public void setJwtIssuer(String jwtIssuer) { + httpFetcherConfig.setJwtIssuer(jwtIssuer); + } + + @Field + public void setJwtSubject(String jwtSubject) { + httpFetcherConfig.setJwtSubject(jwtSubject); + } + + @Field + public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { + httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); + } + + @Field + public void setJwtSecret(String jwtSecret) { + httpFetcherConfig.setJwtSecret(jwtSecret); + } + + @Field + public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { + httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); + } + @Override public void initialize(Map params) throws TikaConfigException { if (httpFetcherConfig.getSocketTimeout() != null) { @@ -472,10 +514,24 @@ public void initialize(Map params) throws TikaConfigException { HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); + + if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); + jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { + jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig.getJwtSecret().getBytes(StandardCharsets.UTF_8), + httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } } @Override public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { + if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + + "specified. Only one or the other is supported"); + } } public void setHttpClientFactory(HttpClientFactory httpClientFactory) { From 4f0be91c0d34d4bafcc1be797c294861c30c9613 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Tue, 7 May 2024 09:52:04 -0500 Subject: [PATCH 41/89] add some robustness and add an exec --- tika-pipes/tika-grpc/pom.xml | 16 +++++++++++++++- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 4 +++- .../tika/pipes/grpc/TikaGrpcServerTest.java | 16 +++++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 9c52855c5d..66d62a1af6 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -201,7 +201,6 @@ org.apache.tika tika-fetcher-http ${project.version} - test com.asarkar.grpc @@ -286,6 +285,21 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.2.0 + + + + java + + + + + org.apache.tika.pipes.grpc.TikaGrpcServer + + diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index e2f06ffa51..04c5ed8c9b 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -261,7 +261,9 @@ private void saveFetcher(String name, String fetcherClassName, Map createTikaParamMap(Map fetcherConfigMap) { Map tikaParamsMap = new HashMap<>(); for (Map.Entry entry : fetcherConfigMap.entrySet()) { - tikaParamsMap.put(entry.getKey(), new Param<>(entry.getKey(), entry.getValue())); + if (entry.getValue() != null) { + tikaParamsMap.put(entry.getKey(), new Param<>(entry.getKey(), entry.getValue())); + } } return tikaParamsMap; } diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 1622dea55e..2def77e5b0 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -45,6 +45,7 @@ import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.stub.StreamObserver; import org.apache.commons.io.FileUtils; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -103,7 +104,7 @@ public void testFetcherCrud(Resources resources) throws Exception { String targetFolder = new File("target").getAbsolutePath(); // create fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { - String fetcherId = "fetcherIdHere" + i; + String fetcherId = createFetcherId(i); SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest .newBuilder() .setFetcherId(fetcherId) @@ -118,7 +119,7 @@ public void testFetcherCrud(Resources resources) throws Exception { } // update fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { - String fetcherId = "fetcherIdHere" + i; + String fetcherId = createFetcherId(i); SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest .newBuilder() .setFetcherId(fetcherId) @@ -141,7 +142,7 @@ public void testFetcherCrud(Resources resources) throws Exception { // get fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { - String fetcherId = "fetcherIdHere" + i; + String fetcherId = createFetcherId(i); GetFetcherReply getFetcherReply = blockingStub.getFetcher(GetFetcherRequest .newBuilder() .setFetcherId(fetcherId) @@ -152,7 +153,7 @@ public void testFetcherCrud(Resources resources) throws Exception { // delete fetchers for (int i = 0; i < NUM_FETCHERS_TO_CREATE; ++i) { - String fetcherId = "fetcherIdHere" + i; + String fetcherId = createFetcherId(i); DeleteFetcherReply deleteFetcherReply = blockingStub.deleteFetcher(DeleteFetcherRequest .newBuilder() .setFetcherId(fetcherId) @@ -171,6 +172,11 @@ public void testFetcherCrud(Resources resources) throws Exception { } } + @NotNull + private static String createFetcherId(int i) { + return "nick" + i + ":is:cool:super/" + FileSystemFetcher.class; + } + @Test public void testBiStream(Resources resources) throws Exception { String serverName = InProcessServerBuilder.generateName(); @@ -191,7 +197,7 @@ public void testBiStream(Resources resources) throws Exception { TikaGrpc.TikaBlockingStub blockingStub = TikaGrpc.newBlockingStub(channel); TikaGrpc.TikaStub tikaStub = TikaGrpc.newStub(channel); - String fetcherId = "fetcherIdHere"; + String fetcherId = createFetcherId(1); String targetFolder = new File("target").getAbsolutePath(); SaveFetcherReply reply = blockingStub.saveFetcher(SaveFetcherRequest .newBuilder() From cf812e7659f89317499729417cfb5e66ce9cf084 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 29 Apr 2024 13:01:36 -0500 Subject: [PATCH 42/89] TIKA-4247 HttpFetcher - add ability to send request headers set headers in a metadata value for "httpRequestHeaders" those will be sent along with http request. --- .../tika/pipes/fetcher/http/HttpFetcher.java | 295 +++++++----------- .../pipes/fetcher/http/HttpFetcherTest.java | 41 ++- 2 files changed, 154 insertions(+), 182 deletions(-) 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 8d845807d9..a8bea6f1b9 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 @@ -28,15 +28,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.security.PrivateKey; -import java.util.ArrayList; +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 com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -70,26 +69,12 @@ import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; -import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; -import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; -import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; -import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** * Based on Apache httpclient */ public class HttpFetcher extends AbstractFetcher implements Initializable, RangeFetcher { - public HttpFetcher() { - - } - - private HttpFetcherConfig httpFetcherConfig = new HttpFetcherConfig(); - private HttpClientFactory httpClientFactory = new HttpClientFactory(); - - public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { - this.httpFetcherConfig = httpFetcherConfig; - } public static String HTTP_HEADER_PREFIX = "http-header:"; @@ -98,77 +83,103 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { /** * http status code */ - public static Property HTTP_STATUS_CODE = Property.externalInteger(HTTP_HEADER_PREFIX + "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"); + 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_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_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_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_ENCODING = + Property.externalText(HTTP_HEADER_PREFIX + "content-encoding"); - public static Property HTTP_CONTENT_TYPE = Property.externalText(HTTP_HEADER_PREFIX + "content-type"); + public static Property HTTP_CONTENT_TYPE = + Property.externalText(HTTP_HEADER_PREFIX + "content-type"); private static String USER_AGENT = "User-Agent"; Logger LOG = LoggerFactory.getLogger(HttpFetcher.class); + private HttpClientFactory httpClientFactory = new HttpClientFactory(); private HttpClient httpClient; //back-off client that disables compression private HttpClient noCompressHttpClient; + 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 httpHeaders = new HashSet<>(); + + //When making the request, what User-Agent is sent. + //By default httpclient adds e.g. "Apache-HttpClient/4.5.13 (Java/x.y.z)" + private String userAgent = null; - JwtGenerator jwtGenerator; @Override public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, TikaException { - LOG.info("Fetching HTTP key: {}", fetchKey); HttpGet get = new HttpGet(fetchKey); - RequestConfig requestConfig = RequestConfig - .custom() - .setMaxRedirects(httpFetcherConfig.getMaxRedirects()) - .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) - .build(); + RequestConfig requestConfig = + RequestConfig.custom() + .setMaxRedirects(maxRedirects) + .setRedirectsEnabled(true).build(); get.setConfig(requestConfig); - populateHeaders(get); + setHttpRequestHeaders(metadata, get); return execute(get, metadata, httpClient, true); } - private void populateHeaders(HttpGet get) throws TikaException { - if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { - get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); + private void setHttpRequestHeaders(Metadata metadata, HttpGet get) { + if (!StringUtils.isBlank(userAgent)) { + get.setHeader(USER_AGENT, userAgent); } - if (jwtGenerator != null) { - try { - get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); - } catch (JOSEException e) { - throw new TikaException("Could not generate JWT", e); + // additional http request headers can be sent in here. + String[] httpRequestHeaders = metadata.getValues("httpRequestHeaders"); + if (httpRequestHeaders != null) { + for (String httpRequestHeader : httpRequestHeaders) { + int idxOfEquals = httpRequestHeader.indexOf('='); + String headerKey = httpRequestHeader.substring(0, idxOfEquals); + String headerValue = httpRequestHeader.substring(idxOfEquals + 1); + get.setHeader(headerKey, headerValue); } } } + @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException { + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) + throws IOException { HttpGet get = new HttpGet(fetchKey); - if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { - get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); + if (! StringUtils.isBlank(userAgent)) { + get.setHeader(USER_AGENT, userAgent); } get.setHeader("Range", "bytes=" + startRange + "-" + endRange); return execute(get, metadata, httpClient, true); } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, + boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; final AtomicBoolean timeout = new AtomicBoolean(false); Timer timer = null; - long overallTimeout = httpFetcherConfig.getOverallTimeout() == null ? -1 : httpFetcherConfig.getOverallTimeout(); try { if (overallTimeout > -1) { TimerTask task = new TimerTask() { @@ -186,33 +197,29 @@ public void run() { } response = client.execute(get, context); - updateMetadata(get - .getURI() - .toString(), response, context, metadata); + updateMetadata(get.getURI().toString(), response, context, metadata); - int code = response - .getStatusLine() - .getStatusCode(); + int code = response.getStatusLine().getStatusCode(); if (code < 200 || code > 299) { - throw new IOException("bad status code: " + code + " :: " + responseToString(response)); + throw new IOException("bad status code: " + code + " :: " + + responseToString(response)); } - try (InputStream is = response - .getEntity() - .getContent()) { + try (InputStream is = response.getEntity().getContent()) { return spool(is, metadata); } } catch (ConnectionClosedException e) { - if (retryOnBadLength && e.getMessage() != null && e - .getMessage() - .contains("Premature " + "end of " + "Content-Length delimited message")) { + if (retryOnBadLength && e.getMessage() != null && e.getMessage().contains("Premature " + + "end of " + + "Content-Length delimited message")) { //one trigger for this is if the server sends the uncompressed length //and then compresses the stream. See HTTPCLIENT-2176 - LOG.warn("premature end of content-length delimited message; retrying with " + "content compression disabled for {}", get.getURI()); + LOG.warn("premature end of content-length delimited message; retrying with " + + "content compression disabled for {}", get.getURI()); return execute(get, metadata, noCompressHttpClient, false); } throw e; - } catch (IOException e) { + } catch (IOException e) { if (timeout.get()) { throw new TikaTimeoutException("Overall timeout after " + overallTimeout + "ms"); } else { @@ -237,12 +244,12 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep long start = System.currentTimeMillis(); TemporaryResources tmp = new TemporaryResources(); Path tmpFile = tmp.createTempFile(metadata); - if (httpFetcherConfig.getMaxSpoolSize() < 0) { + if (maxSpoolSize < 0) { Files.copy(content, tmpFile, StandardCopyOption.REPLACE_EXISTING); } else { try (OutputStream os = Files.newOutputStream(tmpFile)) { - long totalRead = IOUtils.copyLarge(content, os, 0, httpFetcherConfig.getMaxSpoolSize()); - if (totalRead == httpFetcherConfig.getMaxSpoolSize() && content.read() != -1) { + long totalRead = IOUtils.copyLarge(content, os, 0, maxSpoolSize); + if (totalRead == maxSpoolSize && content.read() != -1) { metadata.set(HTTP_FETCH_TRUNCATED, "true"); } } @@ -252,38 +259,31 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep return TikaInputStream.get(tmpFile, metadata, tmp); } - private void updateMetadata(String url, HttpResponse response, HttpClientContext context, Metadata metadata) { + private void updateMetadata(String url, HttpResponse response, HttpClientContext context, + Metadata metadata) { if (response == null) { return; } if (response.getStatusLine() != null) { - metadata.set(HTTP_STATUS_CODE, response - .getStatusLine() - .getStatusCode()); + metadata.set(HTTP_STATUS_CODE, response.getStatusLine().getStatusCode()); } HttpEntity entity = response.getEntity(); if (entity != null && entity.getContentEncoding() != null) { - metadata.set(HTTP_CONTENT_ENCODING, entity - .getContentEncoding() - .getValue()); + metadata.set(HTTP_CONTENT_ENCODING, entity.getContentEncoding().getValue()); } if (entity != null && entity.getContentType() != null) { - metadata.set(HTTP_CONTENT_TYPE, entity - .getContentType() - .getValue()); + metadata.set(HTTP_CONTENT_TYPE, entity.getContentType().getValue()); } //load headers - if (httpFetcherConfig.getHttpHeaders() != null) { - for (String h : httpFetcherConfig.getHttpHeaders()) { - 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()); - } + 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()); } } } @@ -309,12 +309,13 @@ private void updateMetadata(String url, HttpResponse response, HttpClientContext HttpConnection connection = context.getConnection(); if (connection instanceof HttpInetConnection) { try { - InetAddress inetAddress = ((HttpInetConnection) connection).getRemoteAddress(); + InetAddress inetAddress = ((HttpInetConnection)connection).getRemoteAddress(); if (inetAddress != null) { metadata.set(HTTP_TARGET_IP_ADDRESS, inetAddress.getHostAddress()); } } catch (ConnectionShutdownException e) { - LOG.warn("connection shutdown while trying to get target URL: " + url); + LOG.warn("connection shutdown while trying to get target URL: " + + url); } } } @@ -323,18 +324,14 @@ private String responseToString(HttpResponse response) { if (response.getEntity() == null) { return ""; } - try (InputStream is = response - .getEntity() - .getContent()) { - UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream - .builder() - .get(); - IOUtils.copyLarge(is, bos, 0, httpFetcherConfig.getMaxErrMsgSize()); + try (InputStream is = response.getEntity().getContent()) { + UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream.builder().get(); + IOUtils.copyLarge(is, bos, 0, maxErrMsgSize); return bos.toString(StandardCharsets.UTF_8); } catch (IOException e) { LOG.warn("IOException trying to read error message", e); return ""; - } catch (NullPointerException e) { + } catch (NullPointerException e ) { return ""; } finally { EntityUtils.consumeQuietly(response.getEntity()); @@ -344,75 +341,75 @@ private String responseToString(HttpResponse response) { @Field public void setUserName(String userName) { - httpFetcherConfig.setUserName(userName); + httpClientFactory.setUserName(userName); } @Field public void setPassword(String password) { - httpFetcherConfig.setPassword(password); + httpClientFactory.setPassword(password); } @Field public void setNtDomain(String domain) { - httpFetcherConfig.setNtDomain(domain); + httpClientFactory.setNtDomain(domain); } @Field public void setAuthScheme(String authScheme) { - httpFetcherConfig.setAuthScheme(authScheme); + httpClientFactory.setAuthScheme(authScheme); } @Field public void setProxyHost(String proxyHost) { - httpFetcherConfig.setProxyHost(proxyHost); + httpClientFactory.setProxyHost(proxyHost); } @Field public void setProxyPort(int proxyPort) { - httpFetcherConfig.setProxyPort(proxyPort); + httpClientFactory.setProxyPort(proxyPort); } @Field public void setConnectTimeout(int connectTimeout) { - httpFetcherConfig.setConnectTimeout(connectTimeout); + httpClientFactory.setConnectTimeout(connectTimeout); } @Field public void setRequestTimeout(int requestTimeout) { - httpFetcherConfig.setRequestTimeout(requestTimeout); + httpClientFactory.setRequestTimeout(requestTimeout); } @Field public void setSocketTimeout(int socketTimeout) { - httpFetcherConfig.setSocketTimeout(socketTimeout); + httpClientFactory.setSocketTimeout(socketTimeout); } @Field public void setMaxConnections(int maxConnections) { - httpFetcherConfig.setMaxConnections(maxConnections); + httpClientFactory.setMaxConnections(maxConnections); } @Field public void setMaxConnectionsPerRoute(int maxConnectionsPerRoute) { - httpFetcherConfig.setMaxConnectionsPerRoute(maxConnectionsPerRoute); + httpClientFactory.setMaxConnectionsPerRoute(maxConnectionsPerRoute); } /** * Set the maximum number of bytes to spool to a temp file. * If this value is -1, the full stream will be spooled to a temp file - *

+ * * Default size is -1. * * @param maxSpoolSize */ @Field public void setMaxSpoolSize(long maxSpoolSize) { - httpFetcherConfig.setMaxSpoolSize(maxSpoolSize); + this.maxSpoolSize = maxSpoolSize; } @Field public void setMaxRedirects(int maxRedirects) { - httpFetcherConfig.setMaxRedirects(maxRedirects); + this.maxRedirects = maxRedirects; } /** @@ -423,10 +420,8 @@ public void setMaxRedirects(int maxRedirects) { */ @Field public void setHttpHeaders(List headers) { - httpFetcherConfig.setHttpHeaders(new ArrayList<>()); - if (headers != null) { - httpFetcherConfig.getHttpHeaders().addAll(headers); - } + this.httpHeaders.clear(); + this.httpHeaders.addAll(headers); } /** @@ -437,12 +432,12 @@ public void setHttpHeaders(List headers) { */ @Field public void setOverallTimeout(long overallTimeout) { - httpFetcherConfig.setOverallTimeout(overallTimeout); + this.overallTimeout = overallTimeout; } @Field public void setMaxErrMsgSize(int maxErrMsgSize) { - httpFetcherConfig.setMaxErrMsgSize(maxErrMsgSize); + this.maxErrMsgSize = maxErrMsgSize; } /** @@ -453,88 +448,28 @@ public void setMaxErrMsgSize(int maxErrMsgSize) { */ @Field public void setUserAgent(String userAgent) { - httpFetcherConfig.setUserAgent(userAgent); - } - - @Field - public void setJwtIssuer(String jwtIssuer) { - httpFetcherConfig.setJwtIssuer(jwtIssuer); - } - - @Field - public void setJwtSubject(String jwtSubject) { - httpFetcherConfig.setJwtSubject(jwtSubject); - } - - @Field - public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { - httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); - } - - @Field - public void setJwtSecret(String jwtSecret) { - httpFetcherConfig.setJwtSecret(jwtSecret); - } - - @Field - public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { - httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); + this.userAgent = userAgent; } @Override public void initialize(Map params) throws TikaConfigException { - if (httpFetcherConfig.getSocketTimeout() != null) { - httpClientFactory.setSocketTimeout(httpFetcherConfig.getSocketTimeout()); - } - if (httpFetcherConfig.getRequestTimeout() != null) { - httpClientFactory.setRequestTimeout(httpFetcherConfig.getRequestTimeout()); - } - if (httpFetcherConfig.getConnectTimeout() != null) { - httpClientFactory.setSocketTimeout(httpFetcherConfig.getConnectTimeout()); - } - if (httpFetcherConfig.getMaxConnections() != null) { - httpClientFactory.setMaxConnections(httpFetcherConfig.getMaxConnections()); - } - if (httpFetcherConfig.getMaxConnectionsPerRoute() != null) { - httpClientFactory.setMaxConnectionsPerRoute(httpFetcherConfig.getMaxConnectionsPerRoute()); - } - if (!StringUtils.isBlank(httpFetcherConfig.getAuthScheme())) { - httpClientFactory.setUserName(httpFetcherConfig.getUserName()); - httpClientFactory.setPassword(httpFetcherConfig.getPassword()); - httpClientFactory.setAuthScheme(httpFetcherConfig.getAuthScheme()); - if (httpFetcherConfig.getNtDomain() != null) { - httpClientFactory.setNtDomain(httpFetcherConfig.getNtDomain()); - } - } - if (!StringUtils.isBlank(httpFetcherConfig.getProxyHost())) { - httpClientFactory.setProxyHost(httpFetcherConfig.getProxyHost()); - httpClientFactory.setProxyPort(httpFetcherConfig.getProxyPort()); - } httpClient = httpClientFactory.build(); HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); - - if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); - jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), - httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { - jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig.getJwtSecret().getBytes(StandardCharsets.UTF_8), - httpFetcherConfig.getJwtIssuer(), - httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } } @Override - public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { - if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + - "specified. Only one or the other is supported"); - } + public void checkInitialization(InitializableProblemHandler problemHandler) + throws TikaConfigException { } - public void setHttpClientFactory(HttpClientFactory httpClientFactory) { + // For test purposes + void setHttpClientFactory(HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } + + void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } } 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 1fca7811eb..652de5d2c8 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 @@ -57,13 +57,13 @@ import org.apache.tika.exception.TikaException; import org.apache.tika.io.TemporaryResources; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Property; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.FetcherManager; import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; -public class HttpFetcherTest extends TikaTest { - +class HttpFetcherTest extends TikaTest { private static final String TEST_URL = "wontbecalled"; private static final String CONTENT = "request content"; @@ -71,6 +71,7 @@ public class HttpFetcherTest extends TikaTest { @BeforeEach public void before() throws Exception { + httpFetcher = new HttpFetcher(); final HttpResponse mockResponse = buildMockResponse(HttpStatus.SC_OK, IOUtils.toInputStream(CONTENT, Charset.defaultCharset())); @@ -107,6 +108,42 @@ public void test4xxResponse() throws Exception { assertEquals(TEST_URL, meta.get("http-connection:target-url")); } + @Test + public void testHttpRequestHeaders() throws Exception { + HttpClient httpClient = Mockito.mock(HttpClient.class); + httpFetcher.setHttpClient(httpClient); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + ArgumentCaptor httpGetArgumentCaptor = ArgumentCaptor.forClass(HttpGet.class); + + when(httpClient.execute(httpGetArgumentCaptor.capture(), any(HttpContext.class))) + .thenReturn(response); + when(response.getStatusLine()).thenReturn(new StatusLine() { + @Override + public ProtocolVersion getProtocolVersion() { + return new HttpGet("http://localhost").getProtocolVersion(); + } + + @Override + public int getStatusCode() { + return 200; + } + + @Override + public String getReasonPhrase() { + return null; + } + }); + + when(response.getEntity()).thenReturn(new StringEntity("Hi")); + + Metadata metadata = new Metadata(); + metadata.set(Property.externalText("httpRequestHeaders"), new String[] {"nick1=val1", "nick2=val2"}); + httpFetcher.fetch("http://localhost", metadata); + HttpGet httpGet = httpGetArgumentCaptor.getValue(); + Assertions.assertEquals("val1", httpGet.getHeaders("nick1")[0].getValue()); + Assertions.assertEquals("val2", httpGet.getHeaders("nick2")[0].getValue()); + } + @Test @Disabled("requires network connectivity") public void testRedirect() throws Exception { From 3d0babd980f8eb6c31ecbc981b3a48bd58d025c0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 8 May 2024 16:27:54 -0500 Subject: [PATCH 43/89] Fix issues with the fetch metadata --- .../org/apache/tika/pipes/PipesServer.java | 2 +- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 38 +++++++++++++++---- .../tika-grpc/src/main/proto/tika.proto | 2 +- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java b/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java index 95bb0e9edc..8025453802 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java @@ -377,7 +377,7 @@ private void actuallyParse(FetchEmitTuple t) { LOG.trace("timer -- to parse: {} ms", System.currentTimeMillis() - start); } - if (metadataIsEmpty(parseData.getMetadataList())) { + if (parseData == null || metadataIsEmpty(parseData.getMetadataList())) { write(STATUS.EMPTY_OUTPUT); return; } diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 04c5ed8c9b..bcfb2f3b3f 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -23,8 +23,10 @@ import java.nio.file.Paths; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Transformer; @@ -61,6 +63,7 @@ import org.apache.tika.config.TikaConfigSerializer; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Property; import org.apache.tika.pipes.FetchEmitTuple; import org.apache.tika.pipes.PipesClient; import org.apache.tika.pipes.PipesConfig; @@ -183,20 +186,39 @@ private void fetchAndParseImpl(FetchAndParseRequest request, "Could not find fetcher with name " + request.getFetcherId()); } Metadata tikaMetadata = new Metadata(); - for (Map.Entry entry : request.getMetadataMap().entrySet()) { - tikaMetadata.add(entry.getKey(), entry.getValue()); - } try { + Map metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); + for (Map.Entry entry : metadataJsonObject.entrySet()) { + if (entry.getValue() instanceof List) { + List list = (List) entry.getValue(); + tikaMetadata.set(Property.externalText(entry.getKey()), list.stream() + .map(String::valueOf) + .collect(Collectors.toList()) + .toArray(new String[] {})); + } else if (entry.getValue() instanceof String) { + tikaMetadata.set(Property.externalText(entry.getKey()), (String) entry.getValue()); + } else if (entry.getValue() instanceof Integer) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Integer) entry.getValue()); + } else if (entry.getValue() instanceof Double) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Double) entry.getValue()); + } else if (entry.getValue() instanceof Float) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Float) entry.getValue()); + } else if (entry.getValue() instanceof Boolean) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Boolean) entry.getValue()); + } + } PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); - for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { - for (String name : metadata.names()) { - String value = metadata.get(name); - if (value != null) { - fetchReplyBuilder.putFields(name, value); + if (pipesResult.getEmitData() != null && pipesResult.getEmitData().getMetadataList() != null) { + for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { + for (String name : metadata.names()) { + String value = metadata.get(name); + if (value != null) { + fetchReplyBuilder.putFields(name, value); + } } } } diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 46d7458616..18e2fd17f7 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -47,7 +47,7 @@ message SaveFetcherReply { message FetchAndParseRequest { string fetcher_id = 1; string fetch_key = 2; - map metadata = 3; + string metadata_json = 3; } message FetchAndParseReply { From 590b650a2721135d4cb4bf4c54ec91be2f45957f Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 09:31:19 -0500 Subject: [PATCH 44/89] TIKA-4252: fix metadata issue --- .../org/apache/tika/pipes/PipesServer.java | 96 ++++++++++--------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java b/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java index 8025453802..59def749cf 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/PipesServer.java @@ -69,10 +69,11 @@ import org.apache.tika.pipes.emitter.EmitterManager; import org.apache.tika.pipes.emitter.StreamEmitter; import org.apache.tika.pipes.emitter.TikaEmitterException; -import org.apache.tika.pipes.extractor.EmbeddedDocumentBytesConfig; import org.apache.tika.pipes.extractor.EmittingEmbeddedDocumentBytesHandler; +import org.apache.tika.pipes.fetcher.FetchKey; import org.apache.tika.pipes.fetcher.Fetcher; import org.apache.tika.pipes.fetcher.FetcherManager; +import org.apache.tika.pipes.fetcher.RangeFetcher; import org.apache.tika.sax.BasicContentHandlerFactory; import org.apache.tika.sax.ContentHandlerFactory; import org.apache.tika.sax.RecursiveParserWrapperHandler; @@ -280,7 +281,7 @@ private String getContainerStacktrace(FetchEmitTuple t, List metadataL private void emit(String taskId, EmitKey emitKey, boolean isExtractEmbeddedBytes, MetadataListAndEmbeddedBytes parseData, - String parseExceptionStack, ParseContext parseContext) { + String parseExceptionStack) { Emitter emitter = null; try { @@ -296,7 +297,7 @@ private void emit(String taskId, EmitKey emitKey, parseData.toBePackagedForStreamEmitter()) { emitContentsAndBytes(emitter, emitKey, parseData); } else { - emitter.emit(emitKey.getEmitKey(), parseData.getMetadataList(), parseContext); + emitter.emit(emitKey.getEmitKey(), parseData.getMetadataList()); } } catch (IOException | TikaEmitterException e) { LOG.warn("emit exception", e); @@ -400,11 +401,8 @@ private void emitParseData(FetchEmitTuple t, MetadataListAndEmbeddedBytes parseD String stack = getContainerStacktrace(t, parseData.getMetadataList()); //we need to apply this after we pull out the stacktrace filterMetadata(parseData.getMetadataList()); - ParseContext parseContext = t.getParseContext(); - FetchEmitTuple.ON_PARSE_EXCEPTION onParseException = t.getOnParseException(); - EmbeddedDocumentBytesConfig embeddedDocumentBytesConfig = parseContext.get(EmbeddedDocumentBytesConfig.class); if (StringUtils.isBlank(stack) || - onParseException == FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT) { + t.getOnParseException() == FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT) { injectUserMetadata(t.getMetadata(), parseData.getMetadataList()); EmitKey emitKey = t.getEmitKey(); if (StringUtils.isBlank(emitKey.getEmitKey())) { @@ -412,14 +410,14 @@ private void emitParseData(FetchEmitTuple t, MetadataListAndEmbeddedBytes parseD t.setEmitKey(emitKey); } EmitData emitData = new EmitData(t.getEmitKey(), parseData.getMetadataList(), stack); - if (embeddedDocumentBytesConfig.isExtractEmbeddedDocumentBytes() && + if (t.getEmbeddedDocumentBytesConfig().isExtractEmbeddedDocumentBytes() && parseData.toBePackagedForStreamEmitter()) { - emit(t.getId(), emitKey, embeddedDocumentBytesConfig.isExtractEmbeddedDocumentBytes(), - parseData, stack, parseContext); + emit(t.getId(), emitKey, t.getEmbeddedDocumentBytesConfig().isExtractEmbeddedDocumentBytes(), + parseData, stack); } else if (maxForEmitBatchBytes >= 0 && emitData.getEstimatedSizeBytes() >= maxForEmitBatchBytes) { - emit(t.getId(), emitKey, embeddedDocumentBytesConfig.isExtractEmbeddedDocumentBytes(), - parseData, stack, parseContext); + emit(t.getId(), emitKey, t.getEmbeddedDocumentBytesConfig().isExtractEmbeddedDocumentBytes(), + parseData, stack); } else { //send back to the client write(emitData); @@ -458,18 +456,35 @@ private Fetcher getFetcher(FetchEmitTuple t) { } protected MetadataListAndEmbeddedBytes parseFromTuple(FetchEmitTuple t, Fetcher fetcher) { - - Metadata metadata = new Metadata(); - try (InputStream stream = fetcher.fetch(t.getFetchKey().getFetchKey(), metadata, t.getParseContext())) { - return parseWithStream(t, stream, metadata); - } catch (SecurityException e) { - LOG.error("security exception " + t.getId(), e); - throw e; - } catch (TikaException | IOException e) { - LOG.warn("fetch exception " + t.getId(), e); - write(STATUS.FETCH_EXCEPTION, ExceptionUtils.getStackTrace(e)); + FetchKey fetchKey = t.getFetchKey(); + if (fetchKey.hasRange()) { + if (!(fetcher instanceof RangeFetcher)) { + throw new IllegalArgumentException( + "fetch key has a range, but the fetcher is not a range fetcher"); + } + Metadata metadata = t.getMetadata() == null ? new Metadata() : t.getMetadata(); + try (InputStream stream = ((RangeFetcher) fetcher).fetch(fetchKey.getFetchKey(), + fetchKey.getRangeStart(), fetchKey.getRangeEnd(), metadata)) { + return parseWithStream(t, stream, metadata); + } catch (SecurityException e) { + LOG.error("security exception " + t.getId(), e); + throw e; + } catch (TikaException | IOException e) { + LOG.warn("fetch exception " + t.getId(), e); + write(STATUS.FETCH_EXCEPTION, ExceptionUtils.getStackTrace(e)); + } + } else { + Metadata metadata = t.getMetadata() == null ? new Metadata() : t.getMetadata(); + try (InputStream stream = fetcher.fetch(t.getFetchKey().getFetchKey(), metadata)) { + return parseWithStream(t, stream, metadata); + } catch (SecurityException e) { + LOG.error("security exception " + t.getId(), e); + throw e; + } catch (TikaException | IOException e) { + LOG.warn("fetch exception " + t.getId(), e); + write(STATUS.FETCH_EXCEPTION, ExceptionUtils.getStackTrace(e)); + } } - return null; } @@ -513,11 +528,10 @@ private void handleOOM(String taskId, OutOfMemoryError oom) { private MetadataListAndEmbeddedBytes parseWithStream(FetchEmitTuple fetchEmitTuple, InputStream stream, Metadata metadata) throws TikaConfigException { - + HandlerConfig handlerConfig = fetchEmitTuple.getHandlerConfig(); List metadataList; //this adds the EmbeddedDocumentByteStore to the parsecontext - ParseContext parseContext = setupParseContext(fetchEmitTuple); - HandlerConfig handlerConfig = parseContext.get(HandlerConfig.class); + ParseContext parseContext = createParseContext(fetchEmitTuple); if (handlerConfig.getParseMode() == HandlerConfig.PARSE_MODE.RMETA) { metadataList = parseRecursive(fetchEmitTuple, handlerConfig, stream, metadata, parseContext); @@ -530,16 +544,10 @@ private MetadataListAndEmbeddedBytes parseWithStream(FetchEmitTuple fetchEmitTup parseContext.get(EmbeddedDocumentBytesHandler.class)); } - private ParseContext setupParseContext(FetchEmitTuple fetchEmitTuple) + private ParseContext createParseContext(FetchEmitTuple fetchEmitTuple) throws TikaConfigException { - ParseContext parseContext = fetchEmitTuple.getParseContext(); - if (parseContext.get(HandlerConfig.class) == null) { - parseContext.set(HandlerConfig.class, HandlerConfig.DEFAULT_HANDLER_CONFIG); - } - EmbeddedDocumentBytesConfig embeddedDocumentBytesConfig = parseContext.get(EmbeddedDocumentBytesConfig.class); - if (embeddedDocumentBytesConfig == null) { - //make sure there's one here -- or do we make this default in fetchemit tuple? - parseContext.set(EmbeddedDocumentBytesConfig.class, EmbeddedDocumentBytesConfig.SKIP); + ParseContext parseContext = new ParseContext(); + if (! fetchEmitTuple.getEmbeddedDocumentBytesConfig().isExtractEmbeddedDocumentBytes()) { return parseContext; } EmbeddedDocumentExtractorFactory factory = ((AutoDetectParser)autoDetectParser) @@ -553,17 +561,18 @@ private ParseContext setupParseContext(FetchEmitTuple fetchEmitTuple) "instance of EmbeddedDocumentByteStoreExtractorFactory if you want" + "to extract embedded bytes! I see this embedded doc factory: " + factory.getClass() + "and a request: " + - embeddedDocumentBytesConfig); + fetchEmitTuple.getEmbeddedDocumentBytesConfig()); } } //TODO: especially clean this up. - if (!StringUtils.isBlank(embeddedDocumentBytesConfig.getEmitter())) { + if (!StringUtils.isBlank(fetchEmitTuple.getEmbeddedDocumentBytesConfig().getEmitter())) { parseContext.set(EmbeddedDocumentBytesHandler.class, - new EmittingEmbeddedDocumentBytesHandler(fetchEmitTuple, emitterManager)); + new EmittingEmbeddedDocumentBytesHandler(fetchEmitTuple.getEmitKey(), + fetchEmitTuple.getEmbeddedDocumentBytesConfig(), emitterManager)); } else { parseContext.set(EmbeddedDocumentBytesHandler.class, new BasicEmbeddedDocumentBytesHandler( - embeddedDocumentBytesConfig)); + fetchEmitTuple.getEmbeddedDocumentBytesConfig())); } return parseContext; } @@ -684,10 +693,11 @@ private void _preParse(FetchEmitTuple t, TikaInputStream tis, Metadata metadata, } catch (IOException e) { LOG.warn("problem detecting: " + t.getId(), e); } - EmbeddedDocumentBytesConfig embeddedDocumentBytesConfig = parseContext.get(EmbeddedDocumentBytesConfig.class); - if (embeddedDocumentBytesConfig != null && - embeddedDocumentBytesConfig.isIncludeOriginal()) { - EmbeddedDocumentBytesHandler embeddedDocumentByteStore = parseContext.get(EmbeddedDocumentBytesHandler.class); + + if (t.getEmbeddedDocumentBytesConfig() != null && + t.getEmbeddedDocumentBytesConfig().isIncludeOriginal()) { + EmbeddedDocumentBytesHandler embeddedDocumentByteStore = + parseContext.get(EmbeddedDocumentBytesHandler.class); try (InputStream is = Files.newInputStream(tis.getPath())) { embeddedDocumentByteStore.add(0, metadata, is); } catch (IOException e) { From 0e5a4b68270fb54ce36efeb02f273bbf3ea74a65 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 09:32:06 -0500 Subject: [PATCH 45/89] TIKA-4252: fix metadata issue --- .../java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index bcfb2f3b3f..7386c7853a 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -65,6 +65,7 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Property; import org.apache.tika.pipes.FetchEmitTuple; +import org.apache.tika.pipes.HandlerConfig; import org.apache.tika.pipes.PipesClient; import org.apache.tika.pipes.PipesConfig; import org.apache.tika.pipes.PipesResult; @@ -208,8 +209,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } } PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), - FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); if (pipesResult.getEmitData() != null && pipesResult.getEmitData().getMetadataList() != null) { From 377db2daea22b7f2d73683d1de0d97409859a93f Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 09:32:29 -0500 Subject: [PATCH 46/89] TIKA-4252: fix defaults. fix header parsing. --- .../tika/pipes/fetcher/http/HttpFetcher.java | 292 +++++++++++------- .../http/config/HttpFetcherConfig.java | 21 +- 2 files changed, 199 insertions(+), 114 deletions(-) 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 a8bea6f1b9..f5b8cba707 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 @@ -28,14 +28,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.util.HashSet; +import java.security.PrivateKey; +import java.util.ArrayList; 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 com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -69,12 +70,26 @@ import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; +import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; +import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; +import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; +import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** * Based on Apache httpclient */ public class HttpFetcher extends AbstractFetcher implements Initializable, RangeFetcher { + public HttpFetcher() { + + } + + private HttpFetcherConfig httpFetcherConfig = new HttpFetcherConfig(); + private HttpClientFactory httpClientFactory = new HttpClientFactory(); + + public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { + this.httpFetcherConfig = httpFetcherConfig; + } public static String HTTP_HEADER_PREFIX = "http-header:"; @@ -83,103 +98,91 @@ public class HttpFetcher extends AbstractFetcher implements Initializable, Range /** * http status code */ - public static Property HTTP_STATUS_CODE = - Property.externalInteger(HTTP_HEADER_PREFIX + "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"); + 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_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_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_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_ENCODING = Property.externalText(HTTP_HEADER_PREFIX + "content-encoding"); - public static Property HTTP_CONTENT_TYPE = - Property.externalText(HTTP_HEADER_PREFIX + "content-type"); + public static Property HTTP_CONTENT_TYPE = Property.externalText(HTTP_HEADER_PREFIX + "content-type"); private static String USER_AGENT = "User-Agent"; Logger LOG = LoggerFactory.getLogger(HttpFetcher.class); - private HttpClientFactory httpClientFactory = new HttpClientFactory(); private HttpClient httpClient; //back-off client that disables compression private HttpClient noCompressHttpClient; - 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 httpHeaders = new HashSet<>(); - - //When making the request, what User-Agent is sent. - //By default httpclient adds e.g. "Apache-HttpClient/4.5.13 (Java/x.y.z)" - private String userAgent = null; + JwtGenerator jwtGenerator; @Override public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); - RequestConfig requestConfig = - RequestConfig.custom() - .setMaxRedirects(maxRedirects) - .setRedirectsEnabled(true).build(); + RequestConfig requestConfig = RequestConfig + .custom() + .setMaxRedirects(httpFetcherConfig.getMaxRedirects()) + .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) + .build(); get.setConfig(requestConfig); setHttpRequestHeaders(metadata, get); return execute(get, metadata, httpClient, true); } - private void setHttpRequestHeaders(Metadata metadata, HttpGet get) { - if (!StringUtils.isBlank(userAgent)) { - get.setHeader(USER_AGENT, userAgent); + private void setHttpRequestHeaders(Metadata metadata, HttpGet get) throws TikaException { + if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { + get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } // additional http request headers can be sent in here. String[] httpRequestHeaders = metadata.getValues("httpRequestHeaders"); if (httpRequestHeaders != null) { for (String httpRequestHeader : httpRequestHeaders) { - int idxOfEquals = httpRequestHeader.indexOf('='); - String headerKey = httpRequestHeader.substring(0, idxOfEquals); - String headerValue = httpRequestHeader.substring(idxOfEquals + 1); + int idxOfEquals = httpRequestHeader.indexOf(':'); + if (idxOfEquals == -1) { + continue; + } + String headerKey = httpRequestHeader.substring(0, idxOfEquals).trim(); + String headerValue = httpRequestHeader.substring(idxOfEquals + 1).trim(); get.setHeader(headerKey, headerValue); } } + if (jwtGenerator != null) { + try { + get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); + } catch (JOSEException e) { + throw new TikaException("Could not generate JWT", e); + } + } } @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) - throws IOException { + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); - if (! StringUtils.isBlank(userAgent)) { - get.setHeader(USER_AGENT, userAgent); + if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { + get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } + setHttpRequestHeaders(metadata, get); get.setHeader("Range", "bytes=" + startRange + "-" + endRange); return execute(get, metadata, httpClient, true); } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, - boolean retryOnBadLength) throws IOException { + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; final AtomicBoolean timeout = new AtomicBoolean(false); Timer timer = null; + long overallTimeout = httpFetcherConfig.getOverallTimeout() == null ? -1 : httpFetcherConfig.getOverallTimeout(); try { if (overallTimeout > -1) { TimerTask task = new TimerTask() { @@ -197,29 +200,34 @@ public void run() { } response = client.execute(get, context); - updateMetadata(get.getURI().toString(), response, context, metadata); + updateMetadata(get + .getURI() + .toString(), response, context, metadata); - int code = response.getStatusLine().getStatusCode(); + int code = response + .getStatusLine() + .getStatusCode(); + LOG.info("Fetch id {} status code {}", get.getURI(), code); if (code < 200 || code > 299) { - throw new IOException("bad status code: " + code + " :: " + - responseToString(response)); + throw new IOException("bad status code: " + code + " :: " + responseToString(response)); } - try (InputStream is = response.getEntity().getContent()) { + try (InputStream is = response + .getEntity() + .getContent()) { return spool(is, metadata); } } catch (ConnectionClosedException e) { - if (retryOnBadLength && e.getMessage() != null && e.getMessage().contains("Premature " + - "end of " + - "Content-Length delimited message")) { + if (retryOnBadLength && e.getMessage() != null && e + .getMessage() + .contains("Premature " + "end of " + "Content-Length delimited message")) { //one trigger for this is if the server sends the uncompressed length //and then compresses the stream. See HTTPCLIENT-2176 - LOG.warn("premature end of content-length delimited message; retrying with " + - "content compression disabled for {}", get.getURI()); + LOG.warn("premature end of content-length delimited message; retrying with " + "content compression disabled for {}", get.getURI()); return execute(get, metadata, noCompressHttpClient, false); } throw e; - } catch (IOException e) { + } catch (IOException e) { if (timeout.get()) { throw new TikaTimeoutException("Overall timeout after " + overallTimeout + "ms"); } else { @@ -244,12 +252,12 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep long start = System.currentTimeMillis(); TemporaryResources tmp = new TemporaryResources(); Path tmpFile = tmp.createTempFile(metadata); - if (maxSpoolSize < 0) { + if (httpFetcherConfig.getMaxSpoolSize() < 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) { + long totalRead = IOUtils.copyLarge(content, os, 0, httpFetcherConfig.getMaxSpoolSize()); + if (totalRead == httpFetcherConfig.getMaxSpoolSize() && content.read() != -1) { metadata.set(HTTP_FETCH_TRUNCATED, "true"); } } @@ -259,31 +267,38 @@ private InputStream spool(InputStream content, Metadata metadata) throws IOExcep return TikaInputStream.get(tmpFile, metadata, tmp); } - private void updateMetadata(String url, HttpResponse response, HttpClientContext context, - Metadata metadata) { + private void updateMetadata(String url, HttpResponse response, HttpClientContext context, Metadata metadata) { if (response == null) { return; } if (response.getStatusLine() != null) { - metadata.set(HTTP_STATUS_CODE, response.getStatusLine().getStatusCode()); + metadata.set(HTTP_STATUS_CODE, response + .getStatusLine() + .getStatusCode()); } HttpEntity entity = response.getEntity(); if (entity != null && entity.getContentEncoding() != null) { - metadata.set(HTTP_CONTENT_ENCODING, entity.getContentEncoding().getValue()); + metadata.set(HTTP_CONTENT_ENCODING, entity + .getContentEncoding() + .getValue()); } if (entity != null && entity.getContentType() != null) { - metadata.set(HTTP_CONTENT_TYPE, entity.getContentType().getValue()); + 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()); + if (httpFetcherConfig.getHttpHeaders() != null) { + for (String h : httpFetcherConfig.getHttpHeaders()) { + 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()); + } } } } @@ -309,13 +324,12 @@ private void updateMetadata(String url, HttpResponse response, HttpClientContext HttpConnection connection = context.getConnection(); if (connection instanceof HttpInetConnection) { try { - InetAddress inetAddress = ((HttpInetConnection)connection).getRemoteAddress(); + InetAddress inetAddress = ((HttpInetConnection) connection).getRemoteAddress(); if (inetAddress != null) { metadata.set(HTTP_TARGET_IP_ADDRESS, inetAddress.getHostAddress()); } } catch (ConnectionShutdownException e) { - LOG.warn("connection shutdown while trying to get target URL: " + - url); + LOG.warn("connection shutdown while trying to get target URL: " + url); } } } @@ -324,14 +338,18 @@ private String responseToString(HttpResponse response) { if (response.getEntity() == null) { return ""; } - try (InputStream is = response.getEntity().getContent()) { - UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream.builder().get(); - IOUtils.copyLarge(is, bos, 0, maxErrMsgSize); + try (InputStream is = response + .getEntity() + .getContent()) { + UnsynchronizedByteArrayOutputStream bos = UnsynchronizedByteArrayOutputStream + .builder() + .get(); + IOUtils.copyLarge(is, bos, 0, httpFetcherConfig.getMaxErrMsgSize()); return bos.toString(StandardCharsets.UTF_8); } catch (IOException e) { LOG.warn("IOException trying to read error message", e); return ""; - } catch (NullPointerException e ) { + } catch (NullPointerException e) { return ""; } finally { EntityUtils.consumeQuietly(response.getEntity()); @@ -341,75 +359,75 @@ private String responseToString(HttpResponse response) { @Field public void setUserName(String userName) { - httpClientFactory.setUserName(userName); + httpFetcherConfig.setUserName(userName); } @Field public void setPassword(String password) { - httpClientFactory.setPassword(password); + httpFetcherConfig.setPassword(password); } @Field public void setNtDomain(String domain) { - httpClientFactory.setNtDomain(domain); + httpFetcherConfig.setNtDomain(domain); } @Field public void setAuthScheme(String authScheme) { - httpClientFactory.setAuthScheme(authScheme); + httpFetcherConfig.setAuthScheme(authScheme); } @Field public void setProxyHost(String proxyHost) { - httpClientFactory.setProxyHost(proxyHost); + httpFetcherConfig.setProxyHost(proxyHost); } @Field public void setProxyPort(int proxyPort) { - httpClientFactory.setProxyPort(proxyPort); + httpFetcherConfig.setProxyPort(proxyPort); } @Field public void setConnectTimeout(int connectTimeout) { - httpClientFactory.setConnectTimeout(connectTimeout); + httpFetcherConfig.setConnectTimeout(connectTimeout); } @Field public void setRequestTimeout(int requestTimeout) { - httpClientFactory.setRequestTimeout(requestTimeout); + httpFetcherConfig.setRequestTimeout(requestTimeout); } @Field public void setSocketTimeout(int socketTimeout) { - httpClientFactory.setSocketTimeout(socketTimeout); + httpFetcherConfig.setSocketTimeout(socketTimeout); } @Field public void setMaxConnections(int maxConnections) { - httpClientFactory.setMaxConnections(maxConnections); + httpFetcherConfig.setMaxConnections(maxConnections); } @Field public void setMaxConnectionsPerRoute(int maxConnectionsPerRoute) { - httpClientFactory.setMaxConnectionsPerRoute(maxConnectionsPerRoute); + httpFetcherConfig.setMaxConnectionsPerRoute(maxConnectionsPerRoute); } /** * Set the maximum number of bytes to spool to a temp file. * If this value is -1, 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; + httpFetcherConfig.setMaxSpoolSize(maxSpoolSize); } @Field public void setMaxRedirects(int maxRedirects) { - this.maxRedirects = maxRedirects; + httpFetcherConfig.setMaxRedirects(maxRedirects); } /** @@ -420,8 +438,10 @@ public void setMaxRedirects(int maxRedirects) { */ @Field public void setHttpHeaders(List headers) { - this.httpHeaders.clear(); - this.httpHeaders.addAll(headers); + httpFetcherConfig.setHttpHeaders(new ArrayList<>()); + if (headers != null) { + httpFetcherConfig.getHttpHeaders().addAll(headers); + } } /** @@ -432,12 +452,12 @@ public void setHttpHeaders(List headers) { */ @Field public void setOverallTimeout(long overallTimeout) { - this.overallTimeout = overallTimeout; + httpFetcherConfig.setOverallTimeout(overallTimeout); } @Field public void setMaxErrMsgSize(int maxErrMsgSize) { - this.maxErrMsgSize = maxErrMsgSize; + httpFetcherConfig.setMaxErrMsgSize(maxErrMsgSize); } /** @@ -448,24 +468,88 @@ public void setMaxErrMsgSize(int maxErrMsgSize) { */ @Field public void setUserAgent(String userAgent) { - this.userAgent = userAgent; + httpFetcherConfig.setUserAgent(userAgent); + } + + @Field + public void setJwtIssuer(String jwtIssuer) { + httpFetcherConfig.setJwtIssuer(jwtIssuer); + } + + @Field + public void setJwtSubject(String jwtSubject) { + httpFetcherConfig.setJwtSubject(jwtSubject); + } + + @Field + public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { + httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); + } + + @Field + public void setJwtSecret(String jwtSecret) { + httpFetcherConfig.setJwtSecret(jwtSecret); + } + + @Field + public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { + httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); } @Override public void initialize(Map params) throws TikaConfigException { + if (httpFetcherConfig.getSocketTimeout() != null) { + httpClientFactory.setSocketTimeout(httpFetcherConfig.getSocketTimeout()); + } + if (httpFetcherConfig.getRequestTimeout() != null) { + httpClientFactory.setRequestTimeout(httpFetcherConfig.getRequestTimeout()); + } + if (httpFetcherConfig.getConnectTimeout() != null) { + httpClientFactory.setSocketTimeout(httpFetcherConfig.getConnectTimeout()); + } + if (httpFetcherConfig.getMaxConnections() != null) { + httpClientFactory.setMaxConnections(httpFetcherConfig.getMaxConnections()); + } + if (httpFetcherConfig.getMaxConnectionsPerRoute() != null) { + httpClientFactory.setMaxConnectionsPerRoute(httpFetcherConfig.getMaxConnectionsPerRoute()); + } + if (!StringUtils.isBlank(httpFetcherConfig.getAuthScheme())) { + httpClientFactory.setUserName(httpFetcherConfig.getUserName()); + httpClientFactory.setPassword(httpFetcherConfig.getPassword()); + httpClientFactory.setAuthScheme(httpFetcherConfig.getAuthScheme()); + if (httpFetcherConfig.getNtDomain() != null) { + httpClientFactory.setNtDomain(httpFetcherConfig.getNtDomain()); + } + } + if (!StringUtils.isBlank(httpFetcherConfig.getProxyHost())) { + httpClientFactory.setProxyHost(httpFetcherConfig.getProxyHost()); + httpClientFactory.setProxyPort(httpFetcherConfig.getProxyPort()); + } httpClient = httpClientFactory.build(); HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); + + if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); + jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { + jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig.getJwtSecret().getBytes(StandardCharsets.UTF_8), + httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } } @Override - public void checkInitialization(InitializableProblemHandler problemHandler) - throws TikaConfigException { + public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { + if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + + "specified. Only one or the other is supported"); + } } - // For test purposes - void setHttpClientFactory(HttpClientFactory httpClientFactory) { + public void setHttpClientFactory(HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java index 7713c7ca40..ce2a3b3ab9 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -16,6 +16,7 @@ */ package org.apache.tika.pipes.fetcher.http.config; +import java.util.ArrayList; import java.util.List; import org.apache.tika.pipes.fetcher.config.AbstractConfig; @@ -27,16 +28,16 @@ public class HttpFetcherConfig extends AbstractConfig { private String authScheme; private String proxyHost; private Integer proxyPort; - private Integer connectTimeout; - private Integer requestTimeout; - private Integer socketTimeout; - private Integer maxConnections; - private Integer maxConnectionsPerRoute; - private Long maxSpoolSize; - private Integer maxRedirects; - private List httpHeaders; - private Long overallTimeout; - private Integer maxErrMsgSize; + private Integer maxConnectionsPerRoute = 1000; + private Integer maxConnections = 2000; + private Integer requestTimeout = 120000; + private Integer connectTimeout = 120000; + private Integer socketTimeout = 120000; + private Long maxSpoolSize = -1L; + private Integer maxRedirects = 0; + private List httpHeaders = new ArrayList<>(); + private Long overallTimeout = 120000L; + private Integer maxErrMsgSize = 10000000; private String userAgent; private String jwtIssuer; private String jwtSubject; From 5ee0f0b2762a079fba1875952a12148ee93a1664 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 10:03:21 -0500 Subject: [PATCH 47/89] TIKA-4252: shorten huge line --- .../java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 7386c7853a..06dba228f3 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -209,7 +209,8 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } } PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, + HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); if (pipesResult.getEmitData() != null && pipesResult.getEmitData().getMetadataList() != null) { From 1da95c4c67d7c25f5eb945fd57697f5e3210d7c9 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 15:39:46 -0500 Subject: [PATCH 48/89] TIKA-4252: make metadata optional --- tika-pipes/tika-grpc/example-dockerfile/Dockerfile | 2 -- .../org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 10 +++++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile index 64c46cc948..dca5866a3d 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/Dockerfile +++ b/tika-pipes/tika-grpc/example-dockerfile/Dockerfile @@ -6,8 +6,6 @@ ARG JRE='openjdk-17-jre-headless' RUN set -eux \ && apt-get update \ && apt-get install --yes --no-install-recommends gnupg2 software-properties-common \ - && add-apt-repository -y ppa:alex-p/tesseract-ocr5 \ - && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends $JRE \ gdal-bin \ tesseract-ocr \ diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 06dba228f3..ce298a1af4 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -36,6 +36,7 @@ import javax.xml.transform.stream.StreamResult; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.rpc.Status; @@ -73,6 +74,7 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.FetchKey; import org.apache.tika.pipes.fetcher.config.AbstractConfig; +import org.apache.tika.utils.StringUtils; class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { private static final Logger LOG = LoggerFactory.getLogger(TikaConfigSerializer.class); @@ -188,7 +190,13 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } Metadata tikaMetadata = new Metadata(); try { - Map metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); + Map metadataJsonObject = new HashMap<>(); + if (!StringUtils.isBlank(request.getMetadataJson())) { + try { + metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); + } catch (JsonProcessingException e) { + } + } for (Map.Entry entry : metadataJsonObject.entrySet()) { if (entry.getValue() instanceof List) { List list = (List) entry.getValue(); From 88e725e779446afed2aa6084b8f3f04108623fe0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 16:03:32 -0500 Subject: [PATCH 49/89] TIKA-4252: fail when you should and get rid of empty catch --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 4 ++-- .../java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index 7f9934759c..b8b2ff4c55 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -9,8 +9,8 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. OUT_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker -mvn clean install -DskipTests=true -f "${TIKA_SRC_PATH}" -mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" +mvn clean install -DskipTests=true -f "${TIKA_SRC_PATH}" || exit +mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" || exit rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index ce298a1af4..b690558223 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -195,6 +195,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request, try { metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); } catch (JsonProcessingException e) { + metadataJsonObject = new HashMap<>(); } } for (Map.Entry entry : metadataJsonObject.entrySet()) { From a7ede04cb9d59d4b8e9be966c10e4d7643db00dd Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 21:36:48 -0500 Subject: [PATCH 50/89] TIKA-4252: duplicate fetcher - don't fail for now. --- .../apache/tika/pipes/fetcher/FetcherManager.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/FetcherManager.java b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/FetcherManager.java index 40121f9a7e..6a4c921a0a 100644 --- a/tika-core/src/main/java/org/apache/tika/pipes/fetcher/FetcherManager.java +++ b/tika-core/src/main/java/org/apache/tika/pipes/fetcher/FetcherManager.java @@ -25,6 +25,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.apache.tika.config.ConfigBase; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.exception.TikaException; @@ -35,7 +38,7 @@ * This forbids multiple fetchers supporting the same name. */ public class FetcherManager extends ConfigBase { - + private static final Logger LOG = LoggerFactory.getLogger(FetcherManager.class); public static FetcherManager load(Path p) throws IOException, TikaConfigException { try (InputStream is = Files.newInputStream(p)) { @@ -48,12 +51,12 @@ public static FetcherManager load(Path p) throws IOException, TikaConfigExceptio public FetcherManager(List fetchers) throws TikaConfigException { for (Fetcher fetcher : fetchers) { String name = fetcher.getName(); - if (name == null || name.trim().length() == 0) { - throw new TikaConfigException("fetcher name must not be blank"); + if (name == null || name.trim().isEmpty()) { + throw new TikaConfigException("Fetcher name must not be blank"); } if (fetcherMap.containsKey(fetcher.getName())) { - throw new TikaConfigException( - "Multiple fetchers cannot support the same prefix: " + fetcher.getName()); + LOG.warn("Duplicate fetcher saved in the tika-config xml: {}. Ignoring.", fetcher.getName()); + continue; } fetcherMap.put(fetcher.getName(), fetcher); } From f8fb71911c383ef5e21e319f2b35cfbe25dc4507 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 24 May 2024 16:29:23 -0500 Subject: [PATCH 51/89] TIKA-4252: add http request headers at fetcher config level --- .../tika/pipes/fetcher/http/HttpFetcher.java | 39 +++++++++++++++---- .../http/config/HttpFetcherConfig.java | 9 +++++ .../pipes/fetcher/http/HttpFetcherTest.java | 4 +- .../src/test/resources/tika-config-http.xml | 5 ++- 4 files changed, 48 insertions(+), 9 deletions(-) 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 f5b8cba707..575e9bd7a9 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 @@ -144,17 +144,19 @@ private void setHttpRequestHeaders(Metadata metadata, HttpGet get) throws TikaEx if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } + // additional http request headers can be sent in here. + // Add the headers from the Fetcher configuration. + if (httpFetcherConfig.getHttpRequestHeaders() != null) { + for (String httpRequestHeader : httpFetcherConfig.getHttpRequestHeaders()) { + placeHeaderOnGetRequest(get, httpRequestHeader); + } + } + // Additionally, headers can be specified per-fetch via the metadata. String[] httpRequestHeaders = metadata.getValues("httpRequestHeaders"); if (httpRequestHeaders != null) { for (String httpRequestHeader : httpRequestHeaders) { - int idxOfEquals = httpRequestHeader.indexOf(':'); - if (idxOfEquals == -1) { - continue; - } - String headerKey = httpRequestHeader.substring(0, idxOfEquals).trim(); - String headerValue = httpRequestHeader.substring(idxOfEquals + 1).trim(); - get.setHeader(headerKey, headerValue); + placeHeaderOnGetRequest(get, httpRequestHeader); } } if (jwtGenerator != null) { @@ -166,6 +168,16 @@ private void setHttpRequestHeaders(Metadata metadata, HttpGet get) throws TikaEx } } + private static void placeHeaderOnGetRequest(HttpGet get, String httpRequestHeader) { + int idxOfEquals = httpRequestHeader.indexOf(':'); + if (idxOfEquals == -1) { + return; + } + String headerKey = httpRequestHeader.substring(0, idxOfEquals).trim(); + String headerValue = httpRequestHeader.substring(idxOfEquals + 1).trim(); + get.setHeader(headerKey, headerValue); + } + @Override public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); @@ -430,6 +442,19 @@ public void setMaxRedirects(int maxRedirects) { httpFetcherConfig.setMaxRedirects(maxRedirects); } + /** + * Which http request headers should we send in the http fetch requests. + * + * @param headers The headers to add to the HTTP GET requests. + */ + @Field + public void setHttpRequestHeaders(List headers) { + httpFetcherConfig.setHttpRequestHeaders(new ArrayList<>()); + if (headers != null) { + httpFetcherConfig.getHttpRequestHeaders().addAll(headers); + } + } + /** * Which http headers should we capture in the metadata. * Keys will be prepended with {@link HttpFetcher#HTTP_HEADER_PREFIX} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java index ce2a3b3ab9..1988529f62 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-http/src/main/java/org/apache/tika/pipes/fetcher/http/config/HttpFetcherConfig.java @@ -36,6 +36,7 @@ public class HttpFetcherConfig extends AbstractConfig { private Long maxSpoolSize = -1L; private Integer maxRedirects = 0; private List httpHeaders = new ArrayList<>(); + private List httpRequestHeaders = new ArrayList<>(); private Long overallTimeout = 120000L; private Integer maxErrMsgSize = 10000000; private String userAgent; @@ -172,6 +173,14 @@ public HttpFetcherConfig setHttpHeaders(List httpHeaders) { return this; } + public List getHttpRequestHeaders() { + return httpRequestHeaders; + } + + public void setHttpRequestHeaders(List httpRequestHeaders) { + this.httpRequestHeaders = httpRequestHeaders; + } + public Long getOverallTimeout() { return overallTimeout; } 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 652de5d2c8..8a456ef81d 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 @@ -137,11 +137,13 @@ public String getReasonPhrase() { when(response.getEntity()).thenReturn(new StringEntity("Hi")); Metadata metadata = new Metadata(); - metadata.set(Property.externalText("httpRequestHeaders"), new String[] {"nick1=val1", "nick2=val2"}); + metadata.set(Property.externalText("httpRequestHeaders"), new String[] {"nick1: val1", "nick2: val2"}); httpFetcher.fetch("http://localhost", metadata); HttpGet httpGet = httpGetArgumentCaptor.getValue(); Assertions.assertEquals("val1", httpGet.getHeaders("nick1")[0].getValue()); Assertions.assertEquals("val2", httpGet.getHeaders("nick2")[0].getValue()); + // also make sure the headers from the fetcher config level are specified - see src/test/resources/tika-config-http.xml + Assertions.assertEquals("headerValueFromFetcherConfig", httpGet.getHeaders("headerNameFromFetcherConfig")[0].getValue()); } @Test 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 bd77de4bac..5def8f5dc4 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 @@ -24,6 +24,9 @@
Expires
Content-Length
+ +
headerNameFromFetcherConfig: headerValueFromFetcherConfig
+
- \ No newline at end of file + From 6c50dba42323f0e9fc579945d47de44b2cfaac46 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 26 May 2024 05:47:20 -0500 Subject: [PATCH 52/89] skip ossindex because i'm out of sync with master --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index b8b2ff4c55..a00f692b07 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -9,7 +9,7 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. OUT_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker -mvn clean install -DskipTests=true -f "${TIKA_SRC_PATH}" || exit +mvn clean install -Dossindex.skip -DskipTests=true -f "${TIKA_SRC_PATH}" || exit mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" || exit rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" From 829e506bd66f4f8ccdddb487dd560750d2afa458 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 26 May 2024 06:42:31 -0500 Subject: [PATCH 53/89] TIKA-4252: fix issue with config param serialization. --- .../apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index b690558223..1ef13c5a75 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -141,7 +141,17 @@ private void populateFetcherConfigs(Map fetcherConfigParams, for (var configParam : fetcherConfigParams.entrySet()) { Element configElm = tikaConfigDoc.createElement(configParam.getKey()); fetcher.appendChild(configElm); - configElm.setTextContent(Objects.toString(configParam.getValue())); + if (configParam.getValue() instanceof List) { + List configParamVal = (List) configParam.getValue(); + String singularName = configParam.getKey().substring(0, configParam.getKey().length() - 1); + for (Object configParamObj : configParamVal) { + Element childElement = tikaConfigDoc.createElement(singularName); + childElement.setTextContent(Objects.toString(configParamObj)); + configElm.appendChild(childElement); + } + } else { + configElm.setTextContent(Objects.toString(configParam.getValue())); + } } } From 6afdecac841cb4dd529f8cafdb4489d6ba5454a6 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Thu, 9 May 2024 09:32:06 -0500 Subject: [PATCH 54/89] TIKA-4252: fix metadata issue --- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 1ef13c5a75..7386c7853a 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -36,7 +36,6 @@ import javax.xml.transform.stream.StreamResult; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.rpc.Status; @@ -74,7 +73,6 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.FetchKey; import org.apache.tika.pipes.fetcher.config.AbstractConfig; -import org.apache.tika.utils.StringUtils; class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { private static final Logger LOG = LoggerFactory.getLogger(TikaConfigSerializer.class); @@ -141,17 +139,7 @@ private void populateFetcherConfigs(Map fetcherConfigParams, for (var configParam : fetcherConfigParams.entrySet()) { Element configElm = tikaConfigDoc.createElement(configParam.getKey()); fetcher.appendChild(configElm); - if (configParam.getValue() instanceof List) { - List configParamVal = (List) configParam.getValue(); - String singularName = configParam.getKey().substring(0, configParam.getKey().length() - 1); - for (Object configParamObj : configParamVal) { - Element childElement = tikaConfigDoc.createElement(singularName); - childElement.setTextContent(Objects.toString(configParamObj)); - configElm.appendChild(childElement); - } - } else { - configElm.setTextContent(Objects.toString(configParam.getValue())); - } + configElm.setTextContent(Objects.toString(configParam.getValue())); } } @@ -200,14 +188,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } Metadata tikaMetadata = new Metadata(); try { - Map metadataJsonObject = new HashMap<>(); - if (!StringUtils.isBlank(request.getMetadataJson())) { - try { - metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); - } catch (JsonProcessingException e) { - metadataJsonObject = new HashMap<>(); - } - } + Map metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); for (Map.Entry entry : metadataJsonObject.entrySet()) { if (entry.getValue() instanceof List) { List list = (List) entry.getValue(); @@ -228,8 +209,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } } PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, - HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); if (pipesResult.getEmitData() != null && pipesResult.getEmitData().getMetadataList() != null) { From fa1eb93b7f6e8b606172a6acc9e5b9cb90c5b014 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 26 May 2024 06:42:31 -0500 Subject: [PATCH 55/89] TIKA-4252: fix issue with config param serialization. --- .../apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 7386c7853a..861b8276bf 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -139,7 +139,17 @@ private void populateFetcherConfigs(Map fetcherConfigParams, for (var configParam : fetcherConfigParams.entrySet()) { Element configElm = tikaConfigDoc.createElement(configParam.getKey()); fetcher.appendChild(configElm); - configElm.setTextContent(Objects.toString(configParam.getValue())); + if (configParam.getValue() instanceof List) { + List configParamVal = (List) configParam.getValue(); + String singularName = configParam.getKey().substring(0, configParam.getKey().length() - 1); + for (Object configParamObj : configParamVal) { + Element childElement = tikaConfigDoc.createElement(singularName); + childElement.setTextContent(Objects.toString(configParamObj)); + configElm.appendChild(childElement); + } + } else { + configElm.setTextContent(Objects.toString(configParam.getValue())); + } } } From ee852b7388fb154d4c42389c3bc630a51d25640a Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 17 Jun 2024 21:34:23 -0500 Subject: [PATCH 56/89] TIKA-4252: add error path --- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 57 ++++++++++++------- .../tika-grpc/src/main/proto/tika.proto | 2 + .../tika/pipes/grpc/TikaGrpcServerTest.java | 30 +++++++--- 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 861b8276bf..fb5b15f2e1 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -36,11 +36,13 @@ import javax.xml.transform.stream.StreamResult; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.rpc.Status; import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; @@ -198,30 +200,20 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } Metadata tikaMetadata = new Metadata(); try { - Map metadataJsonObject = OBJECT_MAPPER.readValue(request.getMetadataJson(), new TypeReference<>() {}); - for (Map.Entry entry : metadataJsonObject.entrySet()) { - if (entry.getValue() instanceof List) { - List list = (List) entry.getValue(); - tikaMetadata.set(Property.externalText(entry.getKey()), list.stream() - .map(String::valueOf) - .collect(Collectors.toList()) - .toArray(new String[] {})); - } else if (entry.getValue() instanceof String) { - tikaMetadata.set(Property.externalText(entry.getKey()), (String) entry.getValue()); - } else if (entry.getValue() instanceof Integer) { - tikaMetadata.set(Property.externalText(entry.getKey()), (Integer) entry.getValue()); - } else if (entry.getValue() instanceof Double) { - tikaMetadata.set(Property.externalText(entry.getKey()), (Double) entry.getValue()); - } else if (entry.getValue() instanceof Float) { - tikaMetadata.set(Property.externalText(entry.getKey()), (Float) entry.getValue()); - } else if (entry.getValue() instanceof Boolean) { - tikaMetadata.set(Property.externalText(entry.getKey()), (Boolean) entry.getValue()); - } + String metadataJson = request.getMetadataJson(); + if (StringUtils.isNotBlank(metadataJson)) { + loadMetadata(metadataJson, tikaMetadata); } PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, + HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = - FetchAndParseReply.newBuilder().setFetchKey(request.getFetchKey()); + FetchAndParseReply.newBuilder() + .setFetchKey(request.getFetchKey()) + .setStatus(pipesResult.getStatus().name()); + if (pipesResult.getStatus().equals(PipesResult.STATUS.FETCH_EXCEPTION)) { + fetchReplyBuilder.setErrorMessage(pipesResult.getMessage()); + } if (pipesResult.getEmitData() != null && pipesResult.getEmitData().getMetadataList() != null) { for (Metadata metadata : pipesResult.getEmitData().getMetadataList()) { for (String name : metadata.names()) { @@ -240,6 +232,29 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } } + private static void loadMetadata(String metadataJson, Metadata tikaMetadata) throws JsonProcessingException { + Map metadataJsonObject = OBJECT_MAPPER.readValue(metadataJson, new TypeReference<>() {}); + for (Map.Entry entry : metadataJsonObject.entrySet()) { + if (entry.getValue() instanceof List) { + List list = (List) entry.getValue(); + tikaMetadata.set(Property.externalText(entry.getKey()), list.stream() + .map(String::valueOf) + .collect(Collectors.toList()) + .toArray(new String[] {})); + } else if (entry.getValue() instanceof String) { + tikaMetadata.set(Property.externalText(entry.getKey()), (String) entry.getValue()); + } else if (entry.getValue() instanceof Integer) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Integer) entry.getValue()); + } else if (entry.getValue() instanceof Double) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Double) entry.getValue()); + } else if (entry.getValue() instanceof Float) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Float) entry.getValue()); + } else if (entry.getValue() instanceof Boolean) { + tikaMetadata.set(Property.externalText(entry.getKey()), (Boolean) entry.getValue()); + } + } + } + @SuppressWarnings("raw") @Override public void saveFetcher(SaveFetcherRequest request, diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 18e2fd17f7..6b8ac4a7c1 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -53,6 +53,8 @@ message FetchAndParseRequest { message FetchAndParseReply { string fetch_key = 1; map fields = 2; + string status = 3; + string error_message = 4; } message DeleteFetcherRequest { diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 2def77e5b0..0b698cf2ee 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import java.io.File; import java.nio.charset.StandardCharsets; @@ -32,6 +33,7 @@ import java.util.List; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; import com.asarkar.grpc.test.GrpcCleanupExtension; import com.asarkar.grpc.test.Resources; @@ -62,13 +64,14 @@ import org.apache.tika.SaveFetcherReply; import org.apache.tika.SaveFetcherRequest; import org.apache.tika.TikaGrpc; +import org.apache.tika.pipes.PipesResult; import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher; @ExtendWith(GrpcCleanupExtension.class) public class TikaGrpcServerTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Logger LOG = LoggerFactory.getLogger(TikaGrpcServerTest.class); - public static final int NUM_TEST_DOCS = 50; + public static final int NUM_TEST_DOCS = 2; static File tikaConfigXmlTemplate = Paths .get("src", "test", "resources", "tika-pipes-test-config.xml") .toFile(); @@ -208,28 +211,34 @@ public void testBiStream(Resources resources) throws Exception { .put("basePath", targetFolder) .put("extractFileSystemMetadata", true) .build())) - .build()); assertEquals(fetcherId, reply.getFetcherId()); - List fetchAndParseReplys = Collections.synchronizedList(new ArrayList<>()); + List successes = Collections.synchronizedList(new ArrayList<>()); + List errors = Collections.synchronizedList(new ArrayList<>()); + AtomicBoolean finished = new AtomicBoolean(false); StreamObserver replyStreamObserver = new StreamObserver<>() { @Override public void onNext(FetchAndParseReply fetchAndParseReply) { LOG.debug("Fetched {} with metadata {}", fetchAndParseReply.getFetchKey(), fetchAndParseReply.getFieldsMap()); - fetchAndParseReplys.add(fetchAndParseReply); + if (PipesResult.STATUS.FETCH_EXCEPTION.name().equals(fetchAndParseReply.getStatus())) { + errors.add(fetchAndParseReply); + } else { + successes.add(fetchAndParseReply); + } } @Override public void onError(Throwable throwable) { - LOG.error("Fetched error found", throwable); + fail(throwable); } @Override public void onCompleted() { LOG.info("Stream completed"); + finished.set(true); } }; @@ -253,9 +262,16 @@ public void onCompleted() { .setFetchKey(testDocument.getAbsolutePath()) .build()); } + // Now test error condition + requestStreamObserver.onNext(FetchAndParseRequest + .newBuilder() + .setFetcherId(fetcherId) + .setFetchKey("does not exist") + .build()); requestStreamObserver.onCompleted(); - - assertEquals(NUM_TEST_DOCS, fetchAndParseReplys.size()); + assertEquals(NUM_TEST_DOCS, successes.size()); + assertEquals(1, errors.size()); + assertTrue(finished.get()); } finally { FileUtils.deleteDirectory(testDocumentFolder); } From 85dfbe6c2e26d90be750c98a4c1cd41d06402456 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 17 Jun 2024 21:43:52 -0500 Subject: [PATCH 57/89] TIKA-4252: add protection against null metadata --- .../apache/tika/pipes/grpc/TikaGrpcServerImpl.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index fb5b15f2e1..cae27122fd 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -201,9 +201,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request, Metadata tikaMetadata = new Metadata(); try { String metadataJson = request.getMetadataJson(); - if (StringUtils.isNotBlank(metadataJson)) { - loadMetadata(metadataJson, tikaMetadata); - } + loadMetadata(metadataJson, tikaMetadata); PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); @@ -233,7 +231,14 @@ private void fetchAndParseImpl(FetchAndParseRequest request, } private static void loadMetadata(String metadataJson, Metadata tikaMetadata) throws JsonProcessingException { - Map metadataJsonObject = OBJECT_MAPPER.readValue(metadataJson, new TypeReference<>() {}); + Map metadataJsonObject = new HashMap<>(); + if (!StringUtils.isBlank(metadataJson)) { + try { + metadataJsonObject = OBJECT_MAPPER.readValue(metadataJson, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + metadataJsonObject = new HashMap<>(); + } + } for (Map.Entry entry : metadataJsonObject.entrySet()) { if (entry.getValue() instanceof List) { List list = (List) entry.getValue(); From 10cc9bd45080554120d705356fbb1b73ee2b7869 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 11:58:43 -0500 Subject: [PATCH 58/89] TIKA-4252: fix merge conflicts from main --- .../apache/tika/pipes/PipesClientTest.java | 3 +- .../tika/pipes/fetcher/http/HttpFetcher.java | 133 ++++-------------- tika-pipes/tika-grpc/pom.xml | 26 +++- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 6 +- .../pipes/grpc/ExpiringFetcherStoreTest.java | 3 +- 5 files changed, 55 insertions(+), 116 deletions(-) diff --git a/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java index 464e9acbae..01cc86c0a2 100644 --- a/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java +++ b/tika-core/src/test/java/org/apache/tika/pipes/PipesClientTest.java @@ -28,6 +28,7 @@ import org.apache.tika.exception.TikaConfigException; import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.emitter.EmitKey; import org.apache.tika.pipes.fetcher.FetchKey; @@ -51,7 +52,7 @@ public void init() void process() throws IOException, InterruptedException { PipesResult pipesResult = pipesClient.process( new FetchEmitTuple(testPdfFile, new FetchKey(fetcherName, testPdfFile), - new EmitKey(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new EmitKey(), new Metadata(), new ParseContext(), FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); Assertions.assertNotNull(pipesResult.getEmitData().getMetadataList()); Assertions.assertEquals(1, pipesResult.getEmitData().getMetadataList().size()); Metadata metadata = pipesResult.getEmitData().getMetadataList().get(0); 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 575e9bd7a9..e97b6b971f 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 @@ -28,7 +28,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.security.PrivateKey; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -36,7 +35,6 @@ import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; -import com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -61,19 +59,17 @@ 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.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; +import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; -import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; -import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; -import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** @@ -125,10 +121,8 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { //back-off client that disables compression private HttpClient noCompressHttpClient; - JwtGenerator jwtGenerator; - @Override - public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, TikaException { + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException { HttpGet get = new HttpGet(fetchKey); RequestConfig requestConfig = RequestConfig .custom() @@ -136,60 +130,34 @@ public InputStream fetch(String fetchKey, Metadata metadata) throws IOException, .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) .build(); get.setConfig(requestConfig); - setHttpRequestHeaders(metadata, get); + putAdditionalHeadersOnRequest(parseContext, get); return execute(get, metadata, httpClient, true); } - private void setHttpRequestHeaders(Metadata metadata, HttpGet get) throws TikaException { - if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { - get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); - } - - // additional http request headers can be sent in here. - // Add the headers from the Fetcher configuration. - if (httpFetcherConfig.getHttpRequestHeaders() != null) { - for (String httpRequestHeader : httpFetcherConfig.getHttpRequestHeaders()) { - placeHeaderOnGetRequest(get, httpRequestHeader); - } - } - // Additionally, headers can be specified per-fetch via the metadata. - String[] httpRequestHeaders = metadata.getValues("httpRequestHeaders"); - if (httpRequestHeaders != null) { - for (String httpRequestHeader : httpRequestHeaders) { - placeHeaderOnGetRequest(get, httpRequestHeader); - } - } - if (jwtGenerator != null) { - try { - get.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); - } catch (JOSEException e) { - throw new TikaException("Could not generate JWT", e); - } - } - } + @Override + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, ParseContext parseContext) + throws IOException { + HttpGet get = new HttpGet(fetchKey); + putAdditionalHeadersOnRequest(parseContext, get); - private static void placeHeaderOnGetRequest(HttpGet get, String httpRequestHeader) { - int idxOfEquals = httpRequestHeader.indexOf(':'); - if (idxOfEquals == -1) { - return; - } - String headerKey = httpRequestHeader.substring(0, idxOfEquals).trim(); - String headerValue = httpRequestHeader.substring(idxOfEquals + 1).trim(); - get.setHeader(headerKey, headerValue); + get.setHeader("Range", "bytes=" + startRange + "-" + endRange); + return execute(get, metadata, httpClient, true); } - @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata) throws IOException, TikaException { - HttpGet get = new HttpGet(fetchKey); + private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet httpGet) { if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { - get.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); + httpGet.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); + } + AdditionalHttpHeaders additionalHttpHeaders = parseContext.get(AdditionalHttpHeaders.class); + if (additionalHttpHeaders != null) { + additionalHttpHeaders + .getHeaders() + .forEach(httpGet::setHeader); } - setHttpRequestHeaders(metadata, get); - get.setHeader("Range", "bytes=" + startRange + "-" + endRange); - return execute(get, metadata, httpClient, true); } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, + boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; final AtomicBoolean timeout = new AtomicBoolean(false); @@ -219,7 +187,6 @@ public void run() { int code = response .getStatusLine() .getStatusCode(); - LOG.info("Fetch id {} status code {}", get.getURI(), code); if (code < 200 || code > 299) { throw new IOException("bad status code: " + code + " :: " + responseToString(response)); } @@ -442,19 +409,6 @@ public void setMaxRedirects(int maxRedirects) { httpFetcherConfig.setMaxRedirects(maxRedirects); } - /** - * Which http request headers should we send in the http fetch requests. - * - * @param headers The headers to add to the HTTP GET requests. - */ - @Field - public void setHttpRequestHeaders(List headers) { - httpFetcherConfig.setHttpRequestHeaders(new ArrayList<>()); - if (headers != null) { - httpFetcherConfig.getHttpRequestHeaders().addAll(headers); - } - } - /** * Which http headers should we capture in the metadata. * Keys will be prepended with {@link HttpFetcher#HTTP_HEADER_PREFIX} @@ -496,31 +450,6 @@ public void setUserAgent(String userAgent) { httpFetcherConfig.setUserAgent(userAgent); } - @Field - public void setJwtIssuer(String jwtIssuer) { - httpFetcherConfig.setJwtIssuer(jwtIssuer); - } - - @Field - public void setJwtSubject(String jwtSubject) { - httpFetcherConfig.setJwtSubject(jwtSubject); - } - - @Field - public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { - httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); - } - - @Field - public void setJwtSecret(String jwtSecret) { - httpFetcherConfig.setJwtSecret(jwtSecret); - } - - @Field - public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { - httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); - } - @Override public void initialize(Map params) throws TikaConfigException { if (httpFetcherConfig.getSocketTimeout() != null) { @@ -554,31 +483,21 @@ public void initialize(Map params) throws TikaConfigException { HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); - - if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); - jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), - httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { - jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig.getJwtSecret().getBytes(StandardCharsets.UTF_8), - httpFetcherConfig.getJwtIssuer(), - httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); - } } @Override public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { - if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { - throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + - "specified. Only one or the other is supported"); - } } public void setHttpClientFactory(HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } - void setHttpClient(HttpClient httpClient) { + public void setHttpClient(HttpClient httpClient) { this.httpClient = httpClient; } + + public HttpClient getHttpClient() { + return httpClient; + } } diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 66d62a1af6..cd2693bab4 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -23,6 +23,8 @@ 1.2.2 11 4.2.1 + 3.0.0 + 2.28.0 @@ -51,6 +53,10 @@ com.google.errorprone error_prone_annotations + + com.google.code.gson + gson + @@ -79,6 +85,10 @@ com.google.errorprone error_prone_annotations + + com.google.j2objc + j2objc-annotations + @@ -108,22 +118,26 @@ com.google.errorprone error_prone_annotations + + com.google.j2objc + j2objc-annotations + com.google.code.gson gson - 2.10.1 + ${gson.version} com.google.j2objc j2objc-annotations - 2.8 + ${j2objc-annotations.version} com.google.errorprone error_prone_annotations - 2.20.0 + ${error_prone_annotations.version} com.google.guava @@ -149,12 +163,16 @@ com.google.guava guava - 32.0.1-jre + ${guava.version} com.google.errorprone error_prone_annotations + + com.google.j2objc + j2objc-annotations + diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index cae27122fd..daf7a34417 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -66,8 +66,8 @@ import org.apache.tika.exception.TikaConfigException; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Property; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.FetchEmitTuple; -import org.apache.tika.pipes.HandlerConfig; import org.apache.tika.pipes.PipesClient; import org.apache.tika.pipes.PipesConfig; import org.apache.tika.pipes.PipesResult; @@ -202,9 +202,9 @@ private void fetchAndParseImpl(FetchAndParseRequest request, try { String metadataJson = request.getMetadataJson(); loadMetadata(metadataJson, tikaMetadata); + ParseContext parseContext = new ParseContext(); PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), - new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, - HandlerConfig.DEFAULT_HANDLER_CONFIG, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); + new FetchKey(fetcher.getName(), request.getFetchKey()), new EmitKey(), tikaMetadata, parseContext, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP)); FetchAndParseReply.Builder fetchReplyBuilder = FetchAndParseReply.newBuilder() .setFetchKey(request.getFetchKey()) diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java index 72d92da966..b213f32a7a 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.config.AbstractConfig; @@ -32,7 +33,7 @@ void createFetcher() { try (ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 60)) { AbstractFetcher fetcher = new AbstractFetcher() { @Override - public InputStream fetch(String fetchKey, Metadata metadata) { + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) { return null; } }; From 2c48c0c47a7f4464f0f870ebd61ac68dbc1db90e Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 17:39:03 -0500 Subject: [PATCH 59/89] TIKA-4252: add json schema methods --- tika-pipes/tika-grpc/pom.xml | 6 ++ .../tika/pipes/grpc/TikaGrpcServerImpl.java | 25 +++++++ .../tika-grpc/src/main/proto/tika.proto | 67 +++++++++++++++++-- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index cd2693bab4..f6ee16aa4f 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -25,6 +25,7 @@ 4.2.1 3.0.0 2.28.0 + 2.15.0 @@ -220,6 +221,11 @@ tika-fetcher-http ${project.version} + + com.fasterxml.jackson.module + jackson-module-jsonSchema + ${jackson-module-jsonSchema.version} + com.asarkar.grpc grpc-test diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index daf7a34417..5811a557f6 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -39,6 +39,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.google.protobuf.Empty; import com.google.rpc.Status; import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; @@ -53,8 +56,11 @@ import org.apache.tika.DeleteFetcherRequest; import org.apache.tika.FetchAndParseReply; import org.apache.tika.FetchAndParseRequest; +import org.apache.tika.GetFetcherConfigJsonSchemaReply; +import org.apache.tika.GetFetcherConfigJsonSchemaRequest; import org.apache.tika.GetFetcherReply; import org.apache.tika.GetFetcherRequest; +import org.apache.tika.ListFetcherClassesReply; import org.apache.tika.ListFetchersReply; import org.apache.tika.ListFetchersRequest; import org.apache.tika.SaveFetcherReply; @@ -82,6 +88,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { static { OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); } + public static final JsonSchemaGenerator JSON_SCHEMA_GENERATOR = new JsonSchemaGenerator(OBJECT_MAPPER); /** * FetcherID is key, The pair is the Fetcher object and the Metadata @@ -399,7 +406,25 @@ public void deleteFetcher(DeleteFetcherRequest request, responseObserver.onCompleted(); } + @Override + public void getFetcherConfigJsonSchema(GetFetcherConfigJsonSchemaRequest request, StreamObserver responseObserver) { + GetFetcherConfigJsonSchemaReply.Builder builder = GetFetcherConfigJsonSchemaReply.newBuilder(); + try { + JsonSchema jsonSchema = JSON_SCHEMA_GENERATOR.generateSchema(Class.forName(request.getFetcherClass())); + builder.setFetcherConfigJsonSchema(OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(jsonSchema)); + } catch (ClassNotFoundException | JsonProcessingException e) { + throw new RuntimeException("Could not create json schema for " + request.getFetcherClass(), e); + } + responseObserver.onNext(builder.build()); + responseObserver.onCompleted(); + } + private boolean deleteFetcher(String fetcherName) { return expiringFetcherStore.deleteFetcher(fetcherName); } + + @Override + public void listFetcherClasses(Empty request, StreamObserver responseObserver) { + + } } diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 6b8ac4a7c1..86f4ecd79d 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -14,72 +14,131 @@ syntax = "proto3"; package tika; -import "google/protobuf/any.proto"; - option java_multiple_files = true; option java_package = "org.apache.tika"; option java_outer_classname = "TikaProto"; option objc_class_prefix = "HLW"; - +// The Tika Grpc Service definition service Tika { + /* + Save a fetcher to the fetcher store. + */ rpc SaveFetcher(SaveFetcherRequest) returns (SaveFetcherReply) {} + /* + Get a fetcher's data from the fetcher store. + */ rpc GetFetcher(GetFetcherRequest) returns (GetFetcherReply) {} + /* + List fetchers that are currently in the fetcher store. + */ rpc ListFetchers(ListFetchersRequest) returns (ListFetchersReply) {} + /* + Delete a fetcher from the fetcher store. + */ rpc DeleteFetcher(DeleteFetcherRequest) returns (DeleteFetcherReply) {} + /* + Using a Fetcher in the fetcher store, send a FetchAndParse request. This will fetch, parse, and return + the FetchParseTuple data output from Tika Pipes. This is a synchronous call that immediately returns 1 result. + */ rpc FetchAndParse(FetchAndParseRequest) returns (FetchAndParseReply) {} + /* + Using a Fetcher in the fetcher store, send a FetchAndParse request. This will fetch, parse, and return + the FetchParseTuple data output from Tika Pipes. This will stream the data from the server in response. + */ rpc FetchAndParseServerSideStreaming(FetchAndParseRequest) returns (stream FetchAndParseReply) {} - rpc FetchAndParseBiDirectionalStreaming(stream FetchAndParseRequest) + /* + Using a Fetcher in the fetcher store, send a FetchAndParse request. This will fetch, parse, and return + the FetchParseTuple data output from Tika Pipes. This serves a bi-directional stream of fetch inputs and + parsed outputs. + */ + rpc FetchAndParseBiDirectionalStreaming(stream FetchAndParseRequest) returns (stream FetchAndParseReply) {} + /* + Get the Fetcher Config schema for a given fetcher class. + */ + rpc GetFetcherConfigJsonSchema(GetFetcherConfigJsonSchemaRequest) returns (GetFetcherConfigJsonSchemaReply) {} } message SaveFetcherRequest { + // A unique identifier for each fetcher. If this already exists, operation will overwrite existing. string fetcher_id = 1; + // The full java class name of the fetcher class. List of + // fetcher classes is found here: https://cwiki.apache.org/confluence/display/TIKA/tika-pipes string fetcher_class = 2; + // JSON string of the fetcher config object. To see the json schema from which to build this json, + // use the GetFetcherConfigJsonSchema rpc method. string fetcher_config_json = 3; } message SaveFetcherReply { + // The fetcher_id that was saved. string fetcher_id = 1; } message FetchAndParseRequest { + // The ID of the fetcher in the fetcher store (previously saved by SaveFetcher) to use for the fetch. string fetcher_id = 1; + // The "Fetch Key" of the item that will be fetched. string fetch_key = 2; + // Additional metadata describing how to fetch and parse the item. string metadata_json = 3; } message FetchAndParseReply { + // Echoes the fetch_key that was sent in the request. string fetch_key = 1; + // Metadata fields from the parse output. map fields = 2; + // The status from the message. See javadoc for org.apache.tika.pipes.PipesResult.STATUS for the list of status. string status = 3; + // If there was an error, this will contain the error message. string error_message = 4; } message DeleteFetcherRequest { + // ID of the fetcher to delete. string fetcher_id = 1; } message DeleteFetcherReply { + // Success if the fetcher was successfully removed from the fetch store. bool success = 1; } message GetFetcherRequest { + // ID of the fetcher for which to return config. string fetcher_id = 1; } message GetFetcherReply { + // Echoes the ID of the fetcher being returned. string fetcher_id = 1; + // The full Java class name of the Fetcher. string fetcher_class = 2; + // The configuration parameters. map params = 3; } message ListFetchersRequest { + // List the fetchers starting at this page number int32 page_number = 1; + // List this many fetchers per page. int32 num_fetchers_per_page = 2; } message ListFetchersReply { + // List of fetcher configs returned by the Lists Fetchers service. repeated GetFetcherReply get_fetcher_replies = 1; } + +message GetFetcherConfigJsonSchemaRequest { + // The full java class name of the fetcher config for which to fetch json schema. + string fetcher_class = 1; +} + +message GetFetcherConfigJsonSchemaReply { + // The json schema that describes the fetcher config in string format. + string fetcher_config_json_schema = 1; +} From 29b920b6a2beeb2e3e8a70f3a856428d88ccf572 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 18:18:55 -0500 Subject: [PATCH 60/89] TIKA-4252: fix broken tests, useless method --- .../tika/pipes/fetcher/http/HttpFetcher.java | 8 +++ .../pipes/fetcher/http/HttpFetcherTest.java | 61 ++++++------------- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 7 --- 3 files changed, 28 insertions(+), 48 deletions(-) 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 e97b6b971f..464f5960b4 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 @@ -500,4 +500,12 @@ public void setHttpClient(HttpClient httpClient) { public HttpClient getHttpClient() { return httpClient; } + + public HttpFetcherConfig getHttpFetcherConfig() { + return httpFetcherConfig; + } + + public void setHttpFetcherConfig(HttpFetcherConfig httpFetcherConfig) { + this.httpFetcherConfig = httpFetcherConfig; + } } 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 8a456ef81d..7d3dbdb7ea 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 @@ -30,6 +30,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Collections; import java.util.zip.GZIPInputStream; @@ -57,21 +58,36 @@ import org.apache.tika.exception.TikaException; import org.apache.tika.io.TemporaryResources; import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.Property; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.FetcherManager; import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; +import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; + +public class HttpFetcherTest extends TikaTest { -class HttpFetcherTest extends TikaTest { private static final String TEST_URL = "wontbecalled"; private static final String CONTENT = "request content"; private HttpFetcher httpFetcher; + private HttpFetcherConfig httpFetcherConfig; + @BeforeEach public void before() throws Exception { - httpFetcher = new HttpFetcher(); + httpFetcherConfig = new HttpFetcherConfig(); + httpFetcherConfig.setHttpHeaders(new ArrayList<>()); + httpFetcherConfig.setUserAgent("Test app"); + httpFetcherConfig.setConnectTimeout(240_000); + httpFetcherConfig.setRequestTimeout(240_000); + httpFetcherConfig.setSocketTimeout(240_000); + httpFetcherConfig.setMaxConnections(500); + httpFetcherConfig.setMaxConnectionsPerRoute(20); + httpFetcherConfig.setMaxRedirects(-1); + httpFetcherConfig.setMaxErrMsgSize(500_000_000); + httpFetcherConfig.setOverallTimeout(400_000L); + httpFetcherConfig.setMaxSpoolSize(-1L); + final HttpResponse mockResponse = buildMockResponse(HttpStatus.SC_OK, IOUtils.toInputStream(CONTENT, Charset.defaultCharset())); @@ -108,44 +124,6 @@ public void test4xxResponse() throws Exception { assertEquals(TEST_URL, meta.get("http-connection:target-url")); } - @Test - public void testHttpRequestHeaders() throws Exception { - HttpClient httpClient = Mockito.mock(HttpClient.class); - httpFetcher.setHttpClient(httpClient); - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - ArgumentCaptor httpGetArgumentCaptor = ArgumentCaptor.forClass(HttpGet.class); - - when(httpClient.execute(httpGetArgumentCaptor.capture(), any(HttpContext.class))) - .thenReturn(response); - when(response.getStatusLine()).thenReturn(new StatusLine() { - @Override - public ProtocolVersion getProtocolVersion() { - return new HttpGet("http://localhost").getProtocolVersion(); - } - - @Override - public int getStatusCode() { - return 200; - } - - @Override - public String getReasonPhrase() { - return null; - } - }); - - when(response.getEntity()).thenReturn(new StringEntity("Hi")); - - Metadata metadata = new Metadata(); - metadata.set(Property.externalText("httpRequestHeaders"), new String[] {"nick1: val1", "nick2: val2"}); - httpFetcher.fetch("http://localhost", metadata); - HttpGet httpGet = httpGetArgumentCaptor.getValue(); - Assertions.assertEquals("val1", httpGet.getHeaders("nick1")[0].getValue()); - Assertions.assertEquals("val2", httpGet.getHeaders("nick2")[0].getValue()); - // also make sure the headers from the fetcher config level are specified - see src/test/resources/tika-config-http.xml - Assertions.assertEquals("headerValueFromFetcherConfig", httpGet.getHeaders("headerNameFromFetcherConfig")[0].getValue()); - } - @Test @Disabled("requires network connectivity") public void testRedirect() throws Exception { @@ -236,6 +214,7 @@ private void mockClientResponse(final HttpResponse response) throws Exception { when(clientFactory.copy()).thenReturn(clientFactory); httpFetcher.setHttpClientFactory(clientFactory); + httpFetcher.setHttpFetcherConfig(httpFetcherConfig); httpFetcher.initialize(Collections.emptyMap()); } diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 5811a557f6..50241d2bd7 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -41,7 +41,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.module.jsonSchema.JsonSchema; import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; -import com.google.protobuf.Empty; import com.google.rpc.Status; import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; @@ -60,7 +59,6 @@ import org.apache.tika.GetFetcherConfigJsonSchemaRequest; import org.apache.tika.GetFetcherReply; import org.apache.tika.GetFetcherRequest; -import org.apache.tika.ListFetcherClassesReply; import org.apache.tika.ListFetchersReply; import org.apache.tika.ListFetchersRequest; import org.apache.tika.SaveFetcherReply; @@ -422,9 +420,4 @@ public void getFetcherConfigJsonSchema(GetFetcherConfigJsonSchemaRequest request private boolean deleteFetcher(String fetcherName) { return expiringFetcherStore.deleteFetcher(fetcherName); } - - @Override - public void listFetcherClasses(Empty request, StreamObserver responseObserver) { - - } } From c30392d711162b7a750e1db8712c10d3cd3dbfc7 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 19:34:14 -0500 Subject: [PATCH 61/89] TIKA-4252: remove stupid sleep from test --- .../pipes/grpc/ExpiringFetcherStoreTest.java | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java index b213f32a7a..264c366f38 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/ExpiringFetcherStoreTest.java @@ -16,8 +16,12 @@ */ package org.apache.tika.pipes.grpc; +import static org.junit.jupiter.api.Assertions.assertNull; + import java.io.InputStream; +import java.time.Duration; +import org.awaitility.Awaitility; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -30,7 +34,7 @@ class ExpiringFetcherStoreTest { @Test void createFetcher() { - try (ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 60)) { + try (ExpiringFetcherStore expiringFetcherStore = new ExpiringFetcherStore(1, 5)) { AbstractFetcher fetcher = new AbstractFetcher() { @Override public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) { @@ -39,20 +43,23 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC }; fetcher.setName("nick"); AbstractConfig config = new AbstractConfig() { - }; expiringFetcherStore.createFetcher(fetcher, config); - Assertions.assertNotNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); + Assertions.assertNotNull(expiringFetcherStore + .getFetchers() + .get(fetcher.getName())); - try { - Thread.sleep(2000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + Awaitility + .await() + .atMost(Duration.ofSeconds(60)) + .until(() -> expiringFetcherStore + .getFetchers() + .get(fetcher.getName()) == null); - Assertions.assertNull(expiringFetcherStore.getFetchers().get(fetcher.getName())); - Assertions.assertNull(expiringFetcherStore.getFetcherConfigs().get(fetcher.getName())); + assertNull(expiringFetcherStore + .getFetcherConfigs() + .get(fetcher.getName())); } } } From d115dbadd8f48c8e4601bf8dbe3a872180cfccd0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 19:49:40 -0500 Subject: [PATCH 62/89] TIKA-4252: fix checkstyle issue --- .../tika/pipes/grpc/ExpiringFetcherStore.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java index 0618e18f4c..d21f11b08f 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/ExpiringFetcherStore.java @@ -37,11 +37,9 @@ public class ExpiringFetcherStore implements AutoCloseable { public static final long EXPIRE_JOB_INITIAL_DELAY = 1L; private final Map fetchers = Collections.synchronizedMap(new HashMap<>()); private final Map fetcherConfigs = Collections.synchronizedMap(new HashMap<>()); - private final Map fetcherLastAccessed = - Collections.synchronizedMap(new HashMap<>()); + private final Map fetcherLastAccessed = Collections.synchronizedMap(new HashMap<>()); - private final ScheduledExecutorService executorService = - Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersDelaySeconds) { executorService.scheduleAtFixedRate(() -> { @@ -49,13 +47,14 @@ public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersD for (String fetcherName : fetchers.keySet()) { Instant lastAccessed = fetcherLastAccessed.get(fetcherName); if (lastAccessed == null) { - LOG.error("Detected a fetcher with no last access time. FetcherName={}", - fetcherName); + LOG.error("Detected a fetcher with no last access time. FetcherName={}", fetcherName); expired.add(fetcherName); - } else if (Instant.now().isAfter(lastAccessed.plusSeconds(expireAfterSeconds))) { - LOG.info("Detected stale fetcher {} hasn't been access in {} seconds. " + - "Deleting.", - fetcherName, Instant.now().getEpochSecond() - lastAccessed.getEpochSecond()); + } else if (Instant + .now() + .isAfter(lastAccessed.plusSeconds(expireAfterSeconds))) { + LOG.info("Detected stale fetcher {} hasn't been accessed in {} seconds. " + "Deleting.", fetcherName, Instant + .now() + .getEpochSecond() - lastAccessed.getEpochSecond()); expired.add(fetcherName); } } @@ -64,7 +63,7 @@ public ExpiringFetcherStore(int expireAfterSeconds, int checkForExpiredFetchersD } }, EXPIRE_JOB_INITIAL_DELAY, checkForExpiredFetchersDelaySeconds, TimeUnit.SECONDS); } - + public boolean deleteFetcher(String fetcherName) { boolean success = fetchers.remove(fetcherName) != null; fetcherConfigs.remove(fetcherName); @@ -75,7 +74,7 @@ public boolean deleteFetcher(String fetcherName) { public Map getFetchers() { return fetchers; } - + public Map getFetcherConfigs() { return fetcherConfigs; } @@ -88,7 +87,7 @@ public T getFetcherAndLogAccess(String fetcherName) fetcherLastAccessed.put(fetcherName, Instant.now()); return (T) fetchers.get(fetcherName); } - + public void createFetcher(T fetcher, C config) { fetchers.put(fetcher.getName(), fetcher); fetcherConfigs.put(fetcher.getName(), config); From 296855f0b188fea66034109b8af58bf0bc55f1c6 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 22:28:32 -0500 Subject: [PATCH 63/89] TIKA-4252: log violations to console --- tika-pipes/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/tika-pipes/pom.xml b/tika-pipes/pom.xml index 61738fcd95..8a88f14096 100644 --- a/tika-pipes/pom.xml +++ b/tika-pipes/pom.xml @@ -71,6 +71,7 @@ checkstyle.xml UTF-8 false + true true ${project.basedir}/src/test/java error From bef26d83d526022a1d15a4ae8de936748466f0fb Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 22:30:23 -0500 Subject: [PATCH 64/89] TIKA-4252: log violations to console --- tika-pipes/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/pom.xml b/tika-pipes/pom.xml index 8a88f14096..293c4700d6 100644 --- a/tika-pipes/pom.xml +++ b/tika-pipes/pom.xml @@ -70,7 +70,7 @@ checkstyle.xml UTF-8 - false + true true true ${project.basedir}/src/test/java From 9c63154d84dd5a204ad26b62134425a264330958 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 19 Jun 2024 22:49:33 -0500 Subject: [PATCH 65/89] TIKA-4252: exclude generated sources --- tika-pipes/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/tika-pipes/pom.xml b/tika-pipes/pom.xml index 293c4700d6..d1de647b39 100644 --- a/tika-pipes/pom.xml +++ b/tika-pipes/pom.xml @@ -76,6 +76,7 @@ ${project.basedir}/src/test/java error true + true check From 09d499d74d5590a17ebace688df0cb58b95c1758 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sat, 22 Jun 2024 19:05:14 -0500 Subject: [PATCH 66/89] TIKA-4252: if tika config is read-only, use a tmp file for tika server so that it can be modified --- .../org/apache/tika/pipes/grpc/TikaGrpcServer.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index b205edf48c..45d54a3169 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -27,6 +27,7 @@ import io.grpc.ServerCredentials; import io.grpc.TlsServerCredentials; import io.grpc.protobuf.services.ProtoReflectionService; +import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,8 +79,16 @@ public void start() throws Exception { } else { creds = InsecureServerCredentials.create(); } + File tikaConfigFile = new File(tikaConfigXml.getAbsolutePath()); + if (!tikaConfigFile.canWrite()) { + File tmpTikaConfigFile = File.createTempFile("configCopy", tikaConfigFile.getName()); + tmpTikaConfigFile.deleteOnExit(); + LOGGER.info("Tika config file {} is read-only. Making a temporary copy to {}", tikaConfigFile, tmpTikaConfigFile); + FileUtils.copyFile(tikaConfigFile, tmpTikaConfigFile); + tikaConfigFile = tmpTikaConfigFile; + } server = Grpc.newServerBuilderForPort(port, creds) - .addService(new TikaGrpcServerImpl(tikaConfigXml.getAbsolutePath())) + .addService(new TikaGrpcServerImpl(tikaConfigFile.getAbsolutePath())) .addService(ProtoReflectionService.newInstance()) // Enable reflection .build() .start(); From 9cab0ce436025532281b23521edafb864eab0b1c Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sat, 22 Jun 2024 19:13:22 -0500 Subject: [PATCH 67/89] TIKA-4252: if tika config is read-only, use a tmp file for tika server so that it can be modified --- .../apache/tika/pipes/grpc/TikaGrpcServer.java | 8 -------- .../tika/pipes/grpc/TikaGrpcServerImpl.java | 15 +++++++++++++-- .../tika/pipes/grpc/TikaGrpcServerTest.java | 1 + 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 45d54a3169..9a7c6ae289 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -27,7 +27,6 @@ import io.grpc.ServerCredentials; import io.grpc.TlsServerCredentials; import io.grpc.protobuf.services.ProtoReflectionService; -import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,13 +79,6 @@ public void start() throws Exception { creds = InsecureServerCredentials.create(); } File tikaConfigFile = new File(tikaConfigXml.getAbsolutePath()); - if (!tikaConfigFile.canWrite()) { - File tmpTikaConfigFile = File.createTempFile("configCopy", tikaConfigFile.getName()); - tmpTikaConfigFile.deleteOnExit(); - LOGGER.info("Tika config file {} is read-only. Making a temporary copy to {}", tikaConfigFile, tmpTikaConfigFile); - FileUtils.copyFile(tikaConfigFile, tmpTikaConfigFile); - tikaConfigFile = tmpTikaConfigFile; - } server = Grpc.newServerBuilderForPort(port, creds) .addService(new TikaGrpcServerImpl(tikaConfigFile.getAbsolutePath())) .addService(ProtoReflectionService.newInstance()) // Enable reflection diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java index 50241d2bd7..42872e0875 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java @@ -16,11 +16,11 @@ */ package org.apache.tika.pipes.grpc; +import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; -import java.nio.file.Paths; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -44,6 +44,7 @@ import com.google.rpc.Status; import io.grpc.protobuf.StatusProto; import io.grpc.stub.StreamObserver; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -100,7 +101,17 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase { TikaGrpcServerImpl(String tikaConfigPath) throws TikaConfigException, IOException, ParserConfigurationException, TransformerException, SAXException { - pipesConfig = PipesConfig.load(Paths.get(tikaConfigPath)); + File tikaConfigFile = new File(tikaConfigPath); + if (!tikaConfigFile.canWrite()) { + File tmpTikaConfigFile = File.createTempFile("configCopy", tikaConfigFile.getName()); + tmpTikaConfigFile.deleteOnExit(); + LOG.info("Tika config file {} is read-only. Making a temporary copy to {}", tikaConfigFile, tmpTikaConfigFile); + String tikaConfigFileContents = FileUtils.readFileToString(tikaConfigFile, StandardCharsets.UTF_8); + FileUtils.writeStringToFile(tmpTikaConfigFile, tikaConfigFileContents, StandardCharsets.UTF_8); + tikaConfigFile = tmpTikaConfigFile; + tikaConfigPath = tikaConfigFile.getAbsolutePath(); + } + pipesConfig = PipesConfig.load(tikaConfigFile.toPath()); pipesClient = new PipesClient(pipesConfig); expiringFetcherStore = new ExpiringFetcherStore(pipesConfig.getStaleFetcherTimeoutSeconds(), diff --git a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java index 0b698cf2ee..80f391e33b 100644 --- a/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java +++ b/tika-pipes/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcServerTest.java @@ -87,6 +87,7 @@ static void init() throws Exception { @Test public void testFetcherCrud(Resources resources) throws Exception { + Assertions.assertTrue(tikaConfigXml.setWritable(false)); String serverName = InProcessServerBuilder.generateName(); Server server = InProcessServerBuilder From 9204f7a21b18380e6bc19f557e30c8bff75f0eab Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Sun, 23 Jun 2024 14:55:14 -0500 Subject: [PATCH 68/89] resolve conflicts --- .../tika/pipes/fetcher/http/HttpFetcher.java | 101 ++++++++++++++-- .../pipes/fetcher/http/HttpFetcherTest.java | 113 ++++++++++-------- .../microsoftgraph/MicrosoftGraphFetcher.java | 3 +- .../MicrosoftGraphFetcherTest.java | 3 +- .../example-dockerfile/docker-build.sh | 6 + 5 files changed, 169 insertions(+), 57 deletions(-) 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 464f5960b4..649a2b0ea4 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 @@ -28,6 +28,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.PrivateKey; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -35,6 +36,7 @@ import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; +import com.nimbusds.jose.JOSEException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.apache.http.ConnectionClosedException; @@ -59,6 +61,7 @@ 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; @@ -70,6 +73,9 @@ import org.apache.tika.pipes.fetcher.RangeFetcher; import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; +import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; +import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; +import org.apache.tika.pipes.fetcher.http.jwt.JwtSecretCreds; import org.apache.tika.utils.StringUtils; /** @@ -121,8 +127,10 @@ public HttpFetcher(HttpFetcherConfig httpFetcherConfig) { //back-off client that disables compression private HttpClient noCompressHttpClient; + JwtGenerator jwtGenerator; + @Override - public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException { + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); RequestConfig requestConfig = RequestConfig .custom() @@ -135,8 +143,8 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC } @Override - public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, ParseContext parseContext) - throws IOException { + public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, + ParseContext parseContext) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); putAdditionalHeadersOnRequest(parseContext, get); @@ -144,7 +152,7 @@ public InputStream fetch(String fetchKey, long startRange, long endRange, Metada return execute(get, metadata, httpClient, true); } - private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet httpGet) { + private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet httpGet) throws TikaException { if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { httpGet.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } @@ -154,10 +162,30 @@ private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet ht .getHeaders() .forEach(httpGet::setHeader); } + if (jwtGenerator != null) { + try { + httpGet.setHeader("Authorization", "Bearer " + jwtGenerator.jwt()); + } catch (JOSEException e) { + throw new TikaException("Could not generate JWT", e); + } + } } - private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, - boolean retryOnBadLength) throws IOException { + private static void placeHeaderOnGetRequest(HttpGet get, String httpRequestHeader) { + int idxOfEquals = httpRequestHeader.indexOf(':'); + if (idxOfEquals == -1) { + return; + } + String headerKey = httpRequestHeader + .substring(0, idxOfEquals) + .trim(); + String headerValue = httpRequestHeader + .substring(idxOfEquals + 1) + .trim(); + get.setHeader(headerKey, headerValue); + } + + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; final AtomicBoolean timeout = new AtomicBoolean(false); @@ -187,6 +215,7 @@ public void run() { int code = response .getStatusLine() .getStatusCode(); + LOG.info("Fetch id {} status code {}", get.getURI(), code); if (code < 200 || code > 299) { throw new IOException("bad status code: " + code + " :: " + responseToString(response)); } @@ -202,7 +231,8 @@ public void run() { .contains("Premature " + "end of " + "Content-Length delimited message")) { //one trigger for this is if the server sends the uncompressed length //and then compresses the stream. See HTTPCLIENT-2176 - LOG.warn("premature end of content-length delimited message; retrying with " + "content compression disabled for {}", get.getURI()); + LOG.warn("premature end of content-length delimited message; retrying with " + "content compression" + + " disabled for {}", get.getURI()); return execute(get, metadata, noCompressHttpClient, false); } throw e; @@ -409,6 +439,21 @@ public void setMaxRedirects(int maxRedirects) { httpFetcherConfig.setMaxRedirects(maxRedirects); } + /** + * Which http request headers should we send in the http fetch requests. + * + * @param headers The headers to add to the HTTP GET requests. + */ + @Field + public void setHttpRequestHeaders(List headers) { + httpFetcherConfig.setHttpRequestHeaders(new ArrayList<>()); + if (headers != null) { + httpFetcherConfig + .getHttpRequestHeaders() + .addAll(headers); + } + } + /** * Which http headers should we capture in the metadata. * Keys will be prepended with {@link HttpFetcher#HTTP_HEADER_PREFIX} @@ -419,7 +464,9 @@ public void setMaxRedirects(int maxRedirects) { public void setHttpHeaders(List headers) { httpFetcherConfig.setHttpHeaders(new ArrayList<>()); if (headers != null) { - httpFetcherConfig.getHttpHeaders().addAll(headers); + httpFetcherConfig + .getHttpHeaders() + .addAll(headers); } } @@ -450,6 +497,31 @@ public void setUserAgent(String userAgent) { httpFetcherConfig.setUserAgent(userAgent); } + @Field + public void setJwtIssuer(String jwtIssuer) { + httpFetcherConfig.setJwtIssuer(jwtIssuer); + } + + @Field + public void setJwtSubject(String jwtSubject) { + httpFetcherConfig.setJwtSubject(jwtSubject); + } + + @Field + public void setJwtExpiresInSeconds(int jwtExpiresInSeconds) { + httpFetcherConfig.setJwtExpiresInSeconds(jwtExpiresInSeconds); + } + + @Field + public void setJwtSecret(String jwtSecret) { + httpFetcherConfig.setJwtSecret(jwtSecret); + } + + @Field + public void setJwtPrivateKeyBase64(String jwtPrivateKeyBase64) { + httpFetcherConfig.setJwtPrivateKeyBase64(jwtPrivateKeyBase64); + } + @Override public void initialize(Map params) throws TikaConfigException { if (httpFetcherConfig.getSocketTimeout() != null) { @@ -483,10 +555,23 @@ public void initialize(Map params) throws TikaConfigException { HttpClientFactory cp = httpClientFactory.copy(); cp.setDisableContentCompression(true); noCompressHttpClient = cp.build(); + + if (!StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + PrivateKey key = JwtPrivateKeyCreds.convertBase64ToPrivateKey(httpFetcherConfig.getJwtPrivateKeyBase64()); + jwtGenerator = new JwtGenerator(new JwtPrivateKeyCreds(key, httpFetcherConfig.getJwtIssuer(), + httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } else if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret())) { + jwtGenerator = new JwtGenerator(new JwtSecretCreds(httpFetcherConfig + .getJwtSecret() + .getBytes(StandardCharsets.UTF_8), httpFetcherConfig.getJwtIssuer(), httpFetcherConfig.getJwtSubject(), httpFetcherConfig.getJwtExpiresInSeconds())); + } } @Override public void checkInitialization(InitializableProblemHandler problemHandler) throws TikaConfigException { + if (!StringUtils.isBlank(httpFetcherConfig.getJwtSecret()) && !StringUtils.isBlank(httpFetcherConfig.getJwtPrivateKeyBase64())) { + throw new TikaConfigException("Both JWT secret and JWT private key base 64 were " + "specified. Only one or the other is supported"); + } } public void setHttpClientFactory(HttpClientFactory httpClientFactory) { 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 7d3dbdb7ea..2e9f58e091 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 @@ -30,6 +30,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.zip.GZIPInputStream; @@ -58,14 +59,14 @@ import org.apache.tika.exception.TikaException; import org.apache.tika.io.TemporaryResources; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Property; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.FetcherManager; -import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; +import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; -public class HttpFetcherTest extends TikaTest { - +class HttpFetcherTest extends TikaTest { private static final String TEST_URL = "wontbecalled"; private static final String CONTENT = "request content"; @@ -88,8 +89,7 @@ public void before() throws Exception { httpFetcherConfig.setOverallTimeout(400_000L); httpFetcherConfig.setMaxSpoolSize(-1L); - final HttpResponse mockResponse = buildMockResponse(HttpStatus.SC_OK, - IOUtils.toInputStream(CONTENT, Charset.defaultCharset())); + final HttpResponse mockResponse = buildMockResponse(HttpStatus.SC_OK, IOUtils.toInputStream(CONTENT, Charset.defaultCharset())); mockClientResponse(mockResponse); } @@ -125,36 +125,29 @@ public void test4xxResponse() throws Exception { } @Test - @Disabled("requires network connectivity") - 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, new ParseContext())) { - IOUtils.copy(is, bos); - } - //debug(metadata); - } + public void testJwt() throws Exception { + byte[] randomBytes = new byte[32]; + new SecureRandom().nextBytes(randomBytes); - @Test - @Disabled("requires network connectivity") - public void testRange() throws Exception { - String url = - "https://commoncrawl.s3.amazonaws.com/crawl-data/CC-MAIN-2020-45/segments/1603107869785.9/warc/CC-MAIN-20201020021700-20201020051700-00529.warc.gz"; - long start = 969596307; - long end = start + 1408 - 1; - Metadata metadata = new Metadata(); - HttpFetcher httpFetcher = - (HttpFetcher) getFetcherManager("tika-config-http.xml").getFetcher("http"); - try (TemporaryResources tmp = new TemporaryResources()) { - Path tmpPath = tmp.createTempFile(metadata); - try (InputStream is = httpFetcher.fetch(url, start, end, metadata)) { - Files.copy(new GZIPInputStream(is), tmpPath, StandardCopyOption.REPLACE_EXISTING); - } - assertEquals(2461, Files.size(tmpPath)); + httpFetcher.jwtGenerator = Mockito.mock(JwtGenerator.class); + + final Metadata meta = new Metadata(); + meta.set(TikaCoreProperties.RESOURCE_NAME_KEY, "fileName"); + + try (final InputStream ignored = httpFetcher.fetch(TEST_URL, meta, new ParseContext())) { + // HTTP headers added into meta + assertEquals("200", meta.get("http-header:status-code")); + assertEquals(TEST_URL, meta.get("http-connection:target-url")); + // Content size included in meta + assertEquals("15", meta.get("Content-Length")); + + // Filename passed in should be preserved + assertEquals("fileName", meta.get(TikaCoreProperties.RESOURCE_NAME_KEY)); } + + Mockito + .verify(httpFetcher.jwtGenerator) + .jwt(); } @Test @@ -164,8 +157,7 @@ public void testHttpRequestHeaders() throws Exception { CloseableHttpResponse response = mock(CloseableHttpResponse.class); ArgumentCaptor httpGetArgumentCaptor = ArgumentCaptor.forClass(HttpGet.class); - when(httpClient.execute(httpGetArgumentCaptor.capture(), any(HttpContext.class))) - .thenReturn(response); + when(httpClient.execute(httpGetArgumentCaptor.capture(), any(HttpContext.class))).thenReturn(response); when(response.getStatusLine()).thenReturn(new StatusLine() { @Override public ProtocolVersion getProtocolVersion() { @@ -186,20 +178,49 @@ public String getReasonPhrase() { when(response.getEntity()).thenReturn(new StringEntity("Hi")); Metadata metadata = new Metadata(); - ParseContext parseContext = new ParseContext(); - AdditionalHttpHeaders additionalHttpHeaders = new AdditionalHttpHeaders(); - additionalHttpHeaders.getHeaders().put("nick1", "val1"); - additionalHttpHeaders.getHeaders().put("nick2", "val2"); - parseContext.set(AdditionalHttpHeaders.class, additionalHttpHeaders); - httpFetcher.fetch("http://localhost", metadata, parseContext); + metadata.set(Property.externalText("httpRequestHeaders"), new String[]{"nick1: val1", "nick2: val2"}); + httpFetcher.fetch("http://localhost", metadata, new ParseContext()); HttpGet httpGet = httpGetArgumentCaptor.getValue(); Assertions.assertEquals("val1", httpGet.getHeaders("nick1")[0].getValue()); Assertions.assertEquals("val2", httpGet.getHeaders("nick2")[0].getValue()); + // also make sure the headers from the fetcher config level are specified - see src/test/resources/tika-config-http.xml + Assertions.assertEquals("headerValueFromFetcherConfig", httpGet.getHeaders("headerNameFromFetcherConfig")[0].getValue()); + } + + @Test + @Disabled("requires network connectivity") + 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, new ParseContext())) { + IOUtils.copy(is, bos); + } + //debug(metadata); + } + + @Test + @Disabled("requires network connectivity") + public void testRange() throws Exception { + String url = "https://commoncrawl.s3.amazonaws.com/crawl-data/CC-MAIN-2020-45/segments/1603107869785.9/warc/CC-MAIN-20201020021700-20201020051700-00529.warc.gz"; + long start = 969596307; + long end = start + 1408 - 1; + Metadata metadata = new Metadata(); + HttpFetcher httpFetcher = (HttpFetcher) getFetcherManager("tika-config-http.xml").getFetcher("http"); + try (TemporaryResources tmp = new TemporaryResources()) { + Path tmpPath = tmp.createTempFile(metadata); + try (InputStream is = httpFetcher.fetch(url, start, end, metadata)) { + Files.copy(new GZIPInputStream(is), tmpPath, StandardCopyOption.REPLACE_EXISTING); + } + assertEquals(2461, Files.size(tmpPath)); + } } FetcherManager getFetcherManager(String path) throws Exception { - return FetcherManager.load( - Paths.get(HttpFetcherTest.class.getResource("/" + path).toURI())); + return FetcherManager.load(Paths.get(HttpFetcherTest.class + .getResource("/" + path) + .toURI())); } private void mockClientResponse(final HttpResponse response) throws Exception { @@ -208,8 +229,7 @@ private void mockClientResponse(final HttpResponse response) throws Exception { final HttpClient httpClient = mock(HttpClient.class); final HttpClientFactory clientFactory = mock(HttpClientFactory.class); - when(httpClient.execute( - any(HttpUriRequest.class), any(HttpContext.class))).thenReturn(response); + when(httpClient.execute(any(HttpUriRequest.class), any(HttpContext.class))).thenReturn(response); when(clientFactory.build()).thenReturn(httpClient); when(clientFactory.copy()).thenReturn(clientFactory); @@ -218,8 +238,7 @@ private void mockClientResponse(final HttpResponse response) throws Exception { httpFetcher.initialize(Collections.emptyMap()); } - private static HttpResponse buildMockResponse(final int statusCode, final InputStream is) - throws IOException { + private static HttpResponse buildMockResponse(final int statusCode, final InputStream is) throws IOException { final HttpResponse response = mock(HttpResponse.class); final StatusLine status = mock(StatusLine.class); final HttpEntity entity = mock(HttpEntity.class); diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java index aae696ad2d..61b4863999 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -34,6 +34,7 @@ import org.apache.tika.exception.TikaConfigException; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientSecretCredentialsConfig; @@ -109,7 +110,7 @@ public void checkInitialization(InitializableProblemHandler initializableProblem } @Override - public InputStream fetch(String fetchKey, Metadata metadata) throws TikaException, IOException { + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws TikaException, IOException { int tries = 0; Exception ex; do { diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java index 0fafdfa470..6a2e718555 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java @@ -41,6 +41,7 @@ import org.slf4j.LoggerFactory; import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; @@ -95,7 +96,7 @@ void fetch() throws Exception { Mockito.when(contentRequestBuilder.get()) .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); InputStream resultingInputStream = - microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata()); + microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata(), new ParseContext()); Assertions.assertEquals(content, IOUtils.toString(resultingInputStream, StandardCharsets.UTF_8)); } diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index a00f692b07..cb0df8d9f7 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -15,6 +15,12 @@ rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-gcs/target/tika-fetcher-gcs-"*".jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-az-blob/target/tika-fetcher-az-blob-"*".jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-http/target/tika-fetcher-http-"*".jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/target/tika-fetcher-microsoft-graph-"*".jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-s3/target/tika-fetcher-s3-"*".jar" "${OUT_DIR}/libs" + cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-"*".jar" "${OUT_DIR}/libs" cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml" "${OUT_DIR}" cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml" "${OUT_DIR}/tika-config.xml" From 15ed7da7523a68e760ce6b2344cf0d5b4755a5f5 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 24 Jun 2024 11:28:01 -0500 Subject: [PATCH 69/89] improve build script --- .../tika-grpc/example-dockerfile/docker-build.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index cb0df8d9f7..d117a5c250 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -14,14 +14,16 @@ mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" || e rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" +project_version=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout -f "${TIKA_SRC_PATH}") + cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/dependency" "${OUT_DIR}/libs" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-gcs/target/tika-fetcher-gcs-"*".jar" "${OUT_DIR}/libs" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-az-blob/target/tika-fetcher-az-blob-"*".jar" "${OUT_DIR}/libs" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-http/target/tika-fetcher-http-"*".jar" "${OUT_DIR}/libs" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/target/tika-fetcher-microsoft-graph-"*".jar" "${OUT_DIR}/libs" -cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-s3/target/tika-fetcher-s3-"*".jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-gcs/target/tika-fetcher-gcs-${project_version}.jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-az-blob/target/tika-fetcher-az-blob-${project_version}.jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-http/target/tika-fetcher-http-${project_version}.jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/target/tika-fetcher-microsoft-graph-${project_version}.jar" "${OUT_DIR}/libs" +cp -r "${TIKA_SRC_PATH}/tika-pipes/tika-fetchers/tika-fetcher-s3/target/tika-fetcher-s3-${project_version}.jar" "${OUT_DIR}/libs" -cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-"*".jar" "${OUT_DIR}/libs" +cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-grpc-${project_version}.jar" "${OUT_DIR}/libs" cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/log4j2.xml" "${OUT_DIR}" cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml" "${OUT_DIR}/tika-config.xml" cp "${TIKA_SRC_PATH}/tika-pipes/tika-grpc/example-dockerfile/Dockerfile" "${OUT_DIR}/Dockerfile" From d69976c9c61b9099f3ab8683439d2b3575608c6d Mon Sep 17 00:00:00 2001 From: Tilman Hausherr Date: Fri, 21 Jun 2024 09:32:49 +0200 Subject: [PATCH 70/89] TIKA-4166: update commons-collections4 --- tika-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml index f01a3c11c3..5e0427b84e 100644 --- a/tika-parent/pom.xml +++ b/tika-parent/pom.xml @@ -328,7 +328,7 @@ 0.10.1 1.8.0 1.17.0 - 4.5.0-M1 + 4.5.0-M2 1.26.2 1.11.0 1.4.0 From cf5bc7554ef63639a33f70f652afddbd652db872 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Mon, 24 Jun 2024 11:47:53 -0500 Subject: [PATCH 71/89] add a health check --- .../tika/pipes/grpc/TikaGrpcServer.java | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java index 9a7c6ae289..2cd4da941c 100644 --- a/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java +++ b/tika-pipes/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServer.java @@ -16,6 +16,8 @@ */ package org.apache.tika.pipes.grpc; +import static io.grpc.health.v1.HealthCheckResponse.ServingStatus; + import java.io.File; import java.util.concurrent.TimeUnit; @@ -26,6 +28,7 @@ import io.grpc.Server; import io.grpc.ServerCredentials; import io.grpc.TlsServerCredentials; +import io.grpc.protobuf.services.HealthStatusManager; import io.grpc.protobuf.services.ProtoReflectionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,6 +67,7 @@ public class TikaGrpcServer { private boolean help; public void start() throws Exception { + HealthStatusManager healthStatusManager = new HealthStatusManager(); ServerCredentials creds; if (secure) { TlsServerCredentials.Builder channelCredBuilder = TlsServerCredentials.newBuilder(); @@ -79,27 +83,35 @@ public void start() throws Exception { creds = InsecureServerCredentials.create(); } File tikaConfigFile = new File(tikaConfigXml.getAbsolutePath()); - server = Grpc.newServerBuilderForPort(port, creds) - .addService(new TikaGrpcServerImpl(tikaConfigFile.getAbsolutePath())) - .addService(ProtoReflectionService.newInstance()) // Enable reflection - .build() - .start(); + healthStatusManager.setStatus(TikaGrpcServer.class.getSimpleName(), ServingStatus.SERVING); + server = Grpc + .newServerBuilderForPort(port, creds) + .addService(new TikaGrpcServerImpl(tikaConfigFile.getAbsolutePath())) + .addService(healthStatusManager.getHealthService()) + .addService(ProtoReflectionService.newInstance()) // Enable reflection + .build() + .start(); LOGGER.info("Server started, listening on " + port); - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - // Use stderr here since the logger may have been reset by its JVM shutdown hook. - System.err.println("*** shutting down gRPC server since JVM is shutting down"); - try { - TikaGrpcServer.this.stop(); - } catch (InterruptedException e) { - e.printStackTrace(System.err); - } - System.err.println("*** server shut down"); - })); + Runtime + .getRuntime() + .addShutdownHook(new Thread(() -> { + // Use stderr here since the logger may have been reset by its JVM shutdown hook. + System.err.println("*** shutting down gRPC server since JVM is shutting down"); + healthStatusManager.clearStatus(TikaGrpcServer.class.getSimpleName()); + try { + TikaGrpcServer.this.stop(); + } catch (InterruptedException e) { + e.printStackTrace(System.err); + } + System.err.println("*** server shut down"); + })); } public void stop() throws InterruptedException { if (server != null) { - server.shutdown().awaitTermination(30, TimeUnit.SECONDS); + server + .shutdown() + .awaitTermination(30, TimeUnit.SECONDS); } } From 7121da68bba0f11cddf69637ae9f2e48548ec9d0 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 10 Jul 2024 20:57:57 -0500 Subject: [PATCH 72/89] fix wrongly named config class --- .../microsoftgraph/MicrosoftGraphFetcher.java | 19 ++++++++++--------- ....java => MicrosoftGraphFetcherConfig.java} | 10 +++++----- .../MicrosoftGraphFetcherTest.java | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) rename tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/{MsGraphFetcherConfig.java => MicrosoftGraphFetcherConfig.java} (81%) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java index 61b4863999..3c27795d3a 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -38,7 +38,7 @@ import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientSecretCredentialsConfig; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.MicrosoftGraphFetcherConfig; /** * Fetches files from Microsoft Graph API. @@ -47,15 +47,15 @@ public class MicrosoftGraphFetcher extends AbstractFetcher implements Initializable { private static final Logger LOGGER = LoggerFactory.getLogger(MicrosoftGraphFetcher.class); private GraphServiceClient graphClient; - private MsGraphFetcherConfig msGraphFetcherConfig; + private MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig; private long[] throttleSeconds; public MicrosoftGraphFetcher() { } - public MicrosoftGraphFetcher(MsGraphFetcherConfig msGraphFetcherConfig) { - this.msGraphFetcherConfig = msGraphFetcherConfig; + public MicrosoftGraphFetcher(MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig) { + this.microsoftGraphFetcherConfig = microsoftGraphFetcherConfig; } /** @@ -84,19 +84,20 @@ public void setThrottleSeconds(long[] throttleSeconds) { @Override public void initialize(Map map) { - String[] scopes = msGraphFetcherConfig.getScopes().toArray(new String[0]); - if (msGraphFetcherConfig.getCredentials() instanceof ClientCertificateCredentialsConfig) { + String[] scopes = microsoftGraphFetcherConfig + .getScopes().toArray(new String[0]); + if (microsoftGraphFetcherConfig.getCredentials() instanceof ClientCertificateCredentialsConfig) { ClientCertificateCredentialsConfig credentials = - (ClientCertificateCredentialsConfig) msGraphFetcherConfig.getCredentials(); + (ClientCertificateCredentialsConfig) microsoftGraphFetcherConfig.getCredentials(); graphClient = new GraphServiceClient( new ClientCertificateCredentialBuilder().clientId(credentials.getClientId()) .tenantId(credentials.getTenantId()).pfxCertificate( new ByteArrayInputStream(credentials.getCertificateBytes())) .clientCertificatePassword(credentials.getCertificatePassword()) .build(), scopes); - } else if (msGraphFetcherConfig.getCredentials() instanceof ClientSecretCredentialsConfig) { + } else if (microsoftGraphFetcherConfig.getCredentials() instanceof ClientSecretCredentialsConfig) { ClientSecretCredentialsConfig credentials = - (ClientSecretCredentialsConfig) msGraphFetcherConfig.getCredentials(); + (ClientSecretCredentialsConfig) microsoftGraphFetcherConfig.getCredentials(); graphClient = new GraphServiceClient( new ClientSecretCredentialBuilder().tenantId(credentials.getTenantId()) .clientId(credentials.getClientId()) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java similarity index 81% rename from tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java rename to tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java index fe72c8c311..04a43e000f 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MsGraphFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java @@ -21,7 +21,7 @@ import org.apache.tika.pipes.fetcher.config.AbstractConfig; -public class MsGraphFetcherConfig extends AbstractConfig { +public class MicrosoftGraphFetcherConfig extends AbstractConfig { private long[] throttleSeconds; private boolean spoolToTemp; private AadCredentialConfigBase credentials; @@ -32,7 +32,7 @@ public boolean isSpoolToTemp() { return spoolToTemp; } - public MsGraphFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + public MicrosoftGraphFetcherConfig setSpoolToTemp(boolean spoolToTemp) { this.spoolToTemp = spoolToTemp; return this; } @@ -41,7 +41,7 @@ public long[] getThrottleSeconds() { return throttleSeconds; } - public MsGraphFetcherConfig setThrottleSeconds(long[] throttleSeconds) { + public MicrosoftGraphFetcherConfig setThrottleSeconds(long[] throttleSeconds) { this.throttleSeconds = throttleSeconds; return this; } @@ -50,7 +50,7 @@ public AadCredentialConfigBase getCredentials() { return credentials; } - public MsGraphFetcherConfig setCredentials(AadCredentialConfigBase credentials) { + public MicrosoftGraphFetcherConfig setCredentials(AadCredentialConfigBase credentials) { this.credentials = credentials; return this; } @@ -59,7 +59,7 @@ public List getScopes() { return scopes; } - public MsGraphFetcherConfig setScopes(List scopes) { + public MicrosoftGraphFetcherConfig setScopes(List scopes) { this.scopes = scopes; return this; } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java index 6a2e718555..7dda9fb7a9 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java @@ -43,7 +43,7 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.MsGraphFetcherConfig; +import org.apache.tika.pipes.fetchers.microsoftgraph.config.MicrosoftGraphFetcherConfig; @ExtendWith(MockitoExtension.class) class MicrosoftGraphFetcherTest { @@ -59,7 +59,7 @@ class MicrosoftGraphFetcherTest { GraphServiceClient graphClient; @Spy @SuppressWarnings("unused") - MsGraphFetcherConfig msGraphFetcherConfig = new MsGraphFetcherConfig().setCredentials( + MicrosoftGraphFetcherConfig msGraphFetcherConfig = new MicrosoftGraphFetcherConfig().setCredentials( new ClientCertificateCredentialsConfig().setCertificateBytes(certificateBytes) .setCertificatePassword(certificatePassword).setClientId(clientId) .setTenantId(tenantId)).setScopes(Collections.singletonList(".default")); From a473bf6aa7f3c3d551c5c1df31fca956dae41f86 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 12 Jul 2024 08:49:43 -0500 Subject: [PATCH 73/89] add a go package --- tika-pipes/tika-grpc/src/main/proto/tika.proto | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tika-pipes/tika-grpc/src/main/proto/tika.proto b/tika-pipes/tika-grpc/src/main/proto/tika.proto index 86f4ecd79d..18761aac03 100644 --- a/tika-pipes/tika-grpc/src/main/proto/tika.proto +++ b/tika-pipes/tika-grpc/src/main/proto/tika.proto @@ -14,6 +14,8 @@ syntax = "proto3"; package tika; +option go_package = "apache.org/tika"; + option java_multiple_files = true; option java_package = "org.apache.tika"; option java_outer_classname = "TikaProto"; From b1f4b703d5221834cf05c27b94f670d8e4653fd6 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 6 Sep 2024 11:24:58 -0500 Subject: [PATCH 74/89] TIKA-4272: fix issue with headers for all requests --- .../tika/pipes/fetcher/http/HttpFetcher.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 649a2b0ea4..4047e77e88 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 @@ -169,9 +169,18 @@ private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet ht throw new TikaException("Could not generate JWT", e); } } + placeHeadersOnGetRequest(httpGet); } - private static void placeHeaderOnGetRequest(HttpGet get, String httpRequestHeader) { + private void placeHeadersOnGetRequest(HttpGet httpGet) { + if (httpFetcherConfig.getHttpRequestHeaders() != null) { + for (String httpRequestHeader : httpFetcherConfig.getHttpRequestHeaders()) { + placeHeaderOnGetRequest(httpGet, httpRequestHeader); + } + } + } + + private void placeHeaderOnGetRequest(HttpGet httpGet, String httpRequestHeader) { int idxOfEquals = httpRequestHeader.indexOf(':'); if (idxOfEquals == -1) { return; @@ -182,9 +191,10 @@ private static void placeHeaderOnGetRequest(HttpGet get, String httpRequestHeade String headerValue = httpRequestHeader .substring(idxOfEquals + 1) .trim(); - get.setHeader(headerKey, headerValue); + httpGet.setHeader(headerKey, headerValue); } + private InputStream execute(HttpGet get, Metadata metadata, HttpClient client, boolean retryOnBadLength) throws IOException { HttpClientContext context = HttpClientContext.create(); HttpResponse response = null; @@ -299,7 +309,7 @@ private void updateMetadata(String url, HttpResponse response, HttpClientContext .getValue()); } - //load headers + //load response headers if (httpFetcherConfig.getHttpHeaders() != null) { for (String h : httpFetcherConfig.getHttpHeaders()) { Header[] headers = response.getHeaders(h); From 7aaa3b9fc10d56d3bd90ebdc9af931ff68307403 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 6 Sep 2024 11:52:36 -0500 Subject: [PATCH 75/89] TIKA-4272: back to additional metadata the old way --- .../tika/pipes/fetcher/http/HttpFetcher.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) 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 4047e77e88..b64d6bc8a7 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 @@ -71,7 +71,6 @@ import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; import org.apache.tika.pipes.fetcher.RangeFetcher; -import org.apache.tika.pipes.fetcher.http.config.AdditionalHttpHeaders; import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig; import org.apache.tika.pipes.fetcher.http.jwt.JwtGenerator; import org.apache.tika.pipes.fetcher.http.jwt.JwtPrivateKeyCreds; @@ -138,7 +137,7 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC .setRedirectsEnabled(httpFetcherConfig.getMaxRedirects() > 0) .build(); get.setConfig(requestConfig); - putAdditionalHeadersOnRequest(parseContext, get); + putAdditionalHeadersOnRequest(get, metadata); return execute(get, metadata, httpClient, true); } @@ -146,21 +145,23 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC public InputStream fetch(String fetchKey, long startRange, long endRange, Metadata metadata, ParseContext parseContext) throws IOException, TikaException { HttpGet get = new HttpGet(fetchKey); - putAdditionalHeadersOnRequest(parseContext, get); + putAdditionalHeadersOnRequest(get, metadata); get.setHeader("Range", "bytes=" + startRange + "-" + endRange); return execute(get, metadata, httpClient, true); } - private void putAdditionalHeadersOnRequest(ParseContext parseContext, HttpGet httpGet) throws TikaException { + private void putAdditionalHeadersOnRequest(HttpGet httpGet, Metadata requestMetadata) throws TikaException { if (!StringUtils.isBlank(httpFetcherConfig.getUserAgent())) { httpGet.setHeader(USER_AGENT, httpFetcherConfig.getUserAgent()); } - AdditionalHttpHeaders additionalHttpHeaders = parseContext.get(AdditionalHttpHeaders.class); - if (additionalHttpHeaders != null) { - additionalHttpHeaders - .getHeaders() - .forEach(httpGet::setHeader); + if (requestMetadata != null) { + String [] httpRequestHeaders = requestMetadata.getValues("httpRequestHeaders"); + if (httpRequestHeaders != null) { + for (String httpRequestHeader : httpRequestHeaders) { + placeHeaderOnGetRequest(httpGet, httpRequestHeader); + } + } } if (jwtGenerator != null) { try { From 6a4734855c149682222f19456a22a314b58f7693 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 13:45:53 -0500 Subject: [PATCH 76/89] TIKA-4272: fix an issue where the aad credential was not serializing properly --- .../tika-fetcher-microsoft-graph/pom.xml | 2 +- .../config/AadCredentialConfigBase.java | 10 +++ .../config/AadCredentialConfigBaseTest.java | 63 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml index 6169c28582..6d4f31e458 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml @@ -36,7 +36,7 @@ 1.11.0 6.4.0 1.1.1 - 5.9.2 + 5.11.0-M2 3.3.1 5.3.1 9.37.3 diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java index e4204739ce..9d9b3a6953 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java @@ -16,6 +16,16 @@ */ package org.apache.tika.pipes.fetchers.microsoftgraph.config; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ClientCertificateCredentialsConfig.class, name = "ClientCertificateCredentials"), + @JsonSubTypes.Type(value = ClientSecretCredentialsConfig.class, name = "ClientSecretCredentials") } +) public abstract class AadCredentialConfigBase { private String tenantId; private String clientId; diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java new file mode 100644 index 0000000000..f54406fbb5 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java @@ -0,0 +1,63 @@ +/* + * + * * 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. + * + * + */ + +/* + * + * * 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.tika.pipes.fetchers.microsoftgraph.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class AadCredentialConfigBaseTest { + @Test + void checkFormat() throws JsonProcessingException { + MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig = new MicrosoftGraphFetcherConfig(); + var creds = new ClientCertificateCredentialsConfig(); + microsoftGraphFetcherConfig.setCredentials(creds); + creds.setCertificateBytes("nick".getBytes()); + creds.setCertificatePassword("xx"); + creds.setClientId("clientid"); + creds.setTenantId("tenantid"); + + String str = new ObjectMapper().writeValueAsString(microsoftGraphFetcherConfig); + MicrosoftGraphFetcherConfig backAgain = new ObjectMapper().readValue(str, MicrosoftGraphFetcherConfig.class); + Assertions.assertEquals(microsoftGraphFetcherConfig.getCredentials().getClientId(), backAgain.getCredentials().getClientId()); + } +} From 17b17f1b68f06223127b7de0a24bc368579b39e9 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 13:45:56 -0500 Subject: [PATCH 77/89] TIKA-4272: fix an issue where the aad credential was not serializing properly --- .../Client2CertificateCredentialsConfig.java | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java deleted file mode 100644 index d9128373e9..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/Client2CertificateCredentialsConfig.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.tika.pipes.fetchers.microsoftgraph.config; - -public class Client2CertificateCredentialsConfig { - private String tenantId; - private String clientId; - private String clientSecret; - - public String getTenantId() { - return tenantId; - } - - public Client2CertificateCredentialsConfig setTenantId(String tenantId) { - this.tenantId = tenantId; - return this; - } - - public String getClientId() { - return clientId; - } - - public Client2CertificateCredentialsConfig setClientId(String clientId) { - this.clientId = clientId; - return this; - } - - public String getClientSecret() { - return clientSecret; - } - - public Client2CertificateCredentialsConfig setClientSecret(String clientSecret) { - this.clientSecret = clientSecret; - return this; - } -} From b21f1d664ba54fe175cf000f8c3d4ea03827e5e7 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 13:49:08 -0500 Subject: [PATCH 78/89] TIKA-4272: add another assertion --- .../microsoftgraph/config/AadCredentialConfigBaseTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java index f54406fbb5..1935d49506 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java @@ -59,5 +59,8 @@ void checkFormat() throws JsonProcessingException { String str = new ObjectMapper().writeValueAsString(microsoftGraphFetcherConfig); MicrosoftGraphFetcherConfig backAgain = new ObjectMapper().readValue(str, MicrosoftGraphFetcherConfig.class); Assertions.assertEquals(microsoftGraphFetcherConfig.getCredentials().getClientId(), backAgain.getCredentials().getClientId()); + ClientCertificateCredentialsConfig backAgainCreds = (ClientCertificateCredentialsConfig) backAgain.getCredentials(); + Assertions.assertEquals(microsoftGraphFetcherConfig.getCredentials().getClientId(), backAgain.getCredentials().getClientId()); + Assertions.assertEquals("nick", new String(backAgainCreds.getCertificateBytes())); } } From 9566bc8aa7d15fe15118517f1e47f846760da5fe Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 14:25:37 -0500 Subject: [PATCH 79/89] TIKA-4272: add microsoft graph to tika server --- .../tika-fetcher-microsoft-graph/pom.xml | 28 ------------------- tika-pipes/tika-grpc/pom.xml | 5 ++++ 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml index 6d4f31e458..99d8e8f575 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/pom.xml @@ -47,20 +47,6 @@ com.azure azure-identity ${azure-identity.version} - - - com.nimbusds - nimbus-jose-jwt - - - net.java.dev.jna - jna-platform - - - com.microsoft.azure - msal4j - - ${project.groupId} @@ -71,20 +57,6 @@ com.microsoft.graph microsoft-graph ${microsoft-graph.version} - - - com.nimbusds - nimbus-jose-jwt - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - - org.junit.jupiter diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index f6ee16aa4f..1716e3f376 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -221,6 +221,11 @@ tika-fetcher-http ${project.version} + + org.apache.tika + tika-fetcher-microsoft-graph + ${project.version} + com.fasterxml.jackson.module jackson-module-jsonSchema From 678f7b80c8501f195656fd994c8d34425bcb2a9b Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 15:48:19 -0500 Subject: [PATCH 80/89] TIKA-4272: apply fixes to ms graph connector --- .../microsoftgraph/MicrosoftGraphFetcher.java | 121 +++++++++++++----- .../config/AadCredentialConfigBase.java | 50 -------- .../ClientCertificateCredentialsConfig.java | 40 ------ .../config/ClientSecretCredentialsConfig.java | 30 ----- .../config/MicrosoftGraphFetcherConfig.java | 50 +++++++- .../MicrosoftGraphFetcherTest.java | 104 --------------- .../config/AadCredentialConfigBaseTest.java | 66 ---------- .../test/resources/tika-pipes-test-config.xml | 8 +- 8 files changed, 136 insertions(+), 333 deletions(-) delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java delete mode 100644 tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java index 3c27795d3a..6733fb3a74 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -17,13 +17,18 @@ package org.apache.tika.pipes.fetchers.microsoftgraph; import java.io.ByteArrayInputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.util.Base64; +import java.util.List; import java.util.Map; import com.azure.identity.ClientCertificateCredentialBuilder; import com.azure.identity.ClientSecretCredentialBuilder; import com.microsoft.graph.serviceclient.GraphServiceClient; +import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,11 +38,10 @@ import org.apache.tika.config.Param; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.exception.TikaException; +import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientSecretCredentialsConfig; import org.apache.tika.pipes.fetchers.microsoftgraph.config.MicrosoftGraphFetcherConfig; /** @@ -47,15 +51,17 @@ public class MicrosoftGraphFetcher extends AbstractFetcher implements Initializable { private static final Logger LOGGER = LoggerFactory.getLogger(MicrosoftGraphFetcher.class); private GraphServiceClient graphClient; - private MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig; + private MicrosoftGraphFetcherConfig config = new MicrosoftGraphFetcherConfig(); private long[] throttleSeconds; + private boolean spoolToTemp; + public MicrosoftGraphFetcher() { } - public MicrosoftGraphFetcher(MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig) { - this.microsoftGraphFetcherConfig = microsoftGraphFetcherConfig; + public MicrosoftGraphFetcher(MicrosoftGraphFetcherConfig config) { + this.config = config; } /** @@ -82,32 +88,64 @@ public void setThrottleSeconds(long[] throttleSeconds) { this.throttleSeconds = throttleSeconds; } + @Field + public void setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + } + + @Field + public void setTenantId(String tenantId) { + config.setTenantId(tenantId); + } + + @Field + public void setClientId(String clientId) { + config.setClientId(clientId); + } + + @Field + public void setClientSecret(String clientSecret) { + config.setClientSecret(clientSecret); + } + + @Field + public void setCertificateBytesBase64(String certificateBytesBase64) { + config.setCertificateBytesBase64(certificateBytesBase64); + } + + @Field + public void setCertificatePassword(String certificatePassword) { + config.setCertificatePassword(certificatePassword); + } + + @Field + public void setScopes(List scopes) { + this.config.setScopes(scopes); + } + @Override public void initialize(Map map) { - String[] scopes = microsoftGraphFetcherConfig - .getScopes().toArray(new String[0]); - if (microsoftGraphFetcherConfig.getCredentials() instanceof ClientCertificateCredentialsConfig) { - ClientCertificateCredentialsConfig credentials = - (ClientCertificateCredentialsConfig) microsoftGraphFetcherConfig.getCredentials(); - graphClient = new GraphServiceClient( - new ClientCertificateCredentialBuilder().clientId(credentials.getClientId()) - .tenantId(credentials.getTenantId()).pfxCertificate( - new ByteArrayInputStream(credentials.getCertificateBytes())) - .clientCertificatePassword(credentials.getCertificatePassword()) - .build(), scopes); - } else if (microsoftGraphFetcherConfig.getCredentials() instanceof ClientSecretCredentialsConfig) { - ClientSecretCredentialsConfig credentials = - (ClientSecretCredentialsConfig) microsoftGraphFetcherConfig.getCredentials(); - graphClient = new GraphServiceClient( - new ClientSecretCredentialBuilder().tenantId(credentials.getTenantId()) - .clientId(credentials.getClientId()) - .clientSecret(credentials.getClientSecret()).build(), scopes); + String[] scopes = config + .getScopes() + .toArray(new String[0]); + if (config.getCertificateBytesBase64() != null) { + graphClient = new GraphServiceClient(new ClientCertificateCredentialBuilder() + .clientId(config.getClientId()) + .tenantId(config.getTenantId()) + .pfxCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(config.getCertificateBytesBase64()))) + .clientCertificatePassword(config.getCertificatePassword()) + .build(), scopes); + } else if (config.getClientSecret() != null) { + graphClient = new GraphServiceClient(new ClientSecretCredentialBuilder() + .tenantId(config.getTenantId()) + .clientId(config.getClientId()) + .clientSecret(config.getClientSecret()) + .build(), scopes); } } @Override - public void checkInitialization(InitializableProblemHandler initializableProblemHandler) - throws TikaConfigException { + public void checkInitialization(InitializableProblemHandler initializableProblemHandler) throws TikaConfigException { } @Override @@ -115,26 +153,45 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC int tries = 0; Exception ex; do { + long start = System.currentTimeMillis(); try { - long start = System.currentTimeMillis(); String[] fetchKeySplit = fetchKey.split(","); String siteDriveId = fetchKeySplit[0]; String driveItemId = fetchKeySplit[1]; - InputStream is = graphClient.drives().byDriveId(siteDriveId).items() - .byDriveItemId(driveItemId).content().get(); + InputStream is = graphClient + .drives() + .byDriveId(siteDriveId) + .items() + .byDriveItemId(driveItemId) + .content() + .get(); - long elapsed = System.currentTimeMillis() - start; - LOGGER.debug("Total to fetch {}", elapsed); - return is; + if (is == null) { + throw new IOException("Empty input stream when we tried to parse " + fetchKey); + } + if (spoolToTemp) { + File tempFile = Files + .createTempFile("spooled-temp", ".dat") + .toFile(); + FileUtils.copyInputStreamToFile(is, tempFile); + LOGGER.info("Spooled to temp file {}", tempFile); + return TikaInputStream.get(tempFile.toPath()); + } + return TikaInputStream.get(is); } catch (Exception e) { LOGGER.warn("Exception fetching on retry=" + tries, e); ex = e; + } finally { + long elapsed = System.currentTimeMillis() - start; + LOGGER.debug("Total to fetch {}", elapsed); } LOGGER.warn("Sleeping for {} seconds before retry", throttleSeconds[tries]); try { Thread.sleep(throttleSeconds[tries]); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread + .currentThread() + .interrupt(); } } while (++tries < throttleSeconds.length); throw new TikaException("Could not parse " + fetchKey, ex); diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java deleted file mode 100644 index 9d9b3a6953..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBase.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.tika.pipes.fetchers.microsoftgraph.config; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; - -@JsonIgnoreProperties(ignoreUnknown = true) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME) -@JsonSubTypes({ - @JsonSubTypes.Type(value = ClientCertificateCredentialsConfig.class, name = "ClientCertificateCredentials"), - @JsonSubTypes.Type(value = ClientSecretCredentialsConfig.class, name = "ClientSecretCredentials") } -) -public abstract class AadCredentialConfigBase { - private String tenantId; - private String clientId; - - public String getTenantId() { - return tenantId; - } - - public AadCredentialConfigBase setTenantId(String tenantId) { - this.tenantId = tenantId; - return this; - } - - public String getClientId() { - return clientId; - } - - public AadCredentialConfigBase setClientId(String clientId) { - this.clientId = clientId; - return this; - } -} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java deleted file mode 100644 index 2927519f1d..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientCertificateCredentialsConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.tika.pipes.fetchers.microsoftgraph.config; - -public class ClientCertificateCredentialsConfig extends AadCredentialConfigBase { - private byte[] certificateBytes; - private String certificatePassword; - - public byte[] getCertificateBytes() { - return certificateBytes; - } - - public ClientCertificateCredentialsConfig setCertificateBytes(byte[] certificateBytes) { - this.certificateBytes = certificateBytes; - return this; - } - - public String getCertificatePassword() { - return certificatePassword; - } - - public ClientCertificateCredentialsConfig setCertificatePassword(String certificatePassword) { - this.certificatePassword = certificatePassword; - return this; - } -} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java deleted file mode 100644 index 2989af9417..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/ClientSecretCredentialsConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.tika.pipes.fetchers.microsoftgraph.config; - -public class ClientSecretCredentialsConfig extends AadCredentialConfigBase { - private String clientSecret; - - public String getClientSecret() { - return clientSecret; - } - - public ClientSecretCredentialsConfig setClientSecret(String clientSecret) { - this.clientSecret = clientSecret; - return this; - } -} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java index 04a43e000f..f9bc5b1b84 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/MicrosoftGraphFetcherConfig.java @@ -24,8 +24,11 @@ public class MicrosoftGraphFetcherConfig extends AbstractConfig { private long[] throttleSeconds; private boolean spoolToTemp; - private AadCredentialConfigBase credentials; - + protected String tenantId; + protected String clientId; + private String clientSecret; + private String certificateBytesBase64; + private String certificatePassword; private List scopes = new ArrayList<>(); public boolean isSpoolToTemp() { @@ -46,12 +49,47 @@ public MicrosoftGraphFetcherConfig setThrottleSeconds(long[] throttleSeconds) { return this; } - public AadCredentialConfigBase getCredentials() { - return credentials; + public String getTenantId() { + return tenantId; + } + + public MicrosoftGraphFetcherConfig setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + public String getClientId() { + return clientId; + } + + public MicrosoftGraphFetcherConfig setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public String getClientSecret() { + return clientSecret; + } + + public MicrosoftGraphFetcherConfig setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public String getCertificateBytesBase64() { + return certificateBytesBase64; + } + + public void setCertificateBytesBase64(String certificateBytesBase64) { + this.certificateBytesBase64 = certificateBytesBase64; + } + + public String getCertificatePassword() { + return certificatePassword; } - public MicrosoftGraphFetcherConfig setCredentials(AadCredentialConfigBase credentials) { - this.credentials = credentials; + public MicrosoftGraphFetcherConfig setCertificatePassword(String certificatePassword) { + this.certificatePassword = certificatePassword; return this; } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java deleted file mode 100644 index 7dda9fb7a9..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcherTest.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.tika.pipes.fetchers.microsoftgraph; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; - -import com.microsoft.graph.drives.DrivesRequestBuilder; -import com.microsoft.graph.drives.item.DriveItemRequestBuilder; -import com.microsoft.graph.drives.item.items.ItemsRequestBuilder; -import com.microsoft.graph.drives.item.items.item.DriveItemItemRequestBuilder; -import com.microsoft.graph.drives.item.items.item.content.ContentRequestBuilder; -import com.microsoft.graph.serviceclient.GraphServiceClient; -import org.apache.commons.io.IOUtils; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.apache.tika.metadata.Metadata; -import org.apache.tika.parser.ParseContext; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.ClientCertificateCredentialsConfig; -import org.apache.tika.pipes.fetchers.microsoftgraph.config.MicrosoftGraphFetcherConfig; - -@ExtendWith(MockitoExtension.class) -class MicrosoftGraphFetcherTest { - private static final Logger LOGGER = LoggerFactory.getLogger(MicrosoftGraphFetcherTest.class); - static byte[] certificateBytes = "test cert file here".getBytes(StandardCharsets.UTF_8); - static String certificatePassword = "somepasswordhere"; - static String clientId = "12312312-1234-1234-1234-112312312313"; - static String tenantId = "32132132-4332-5432-4321-121231231232"; - static String siteDriveId = "99999999-1234-1111-1111-12312312312"; - static String driveItemid = "asfsadfsadfsafdusahdfiuhfdsusadfjuafiagfaigf"; - - @Mock - GraphServiceClient graphClient; - @Spy - @SuppressWarnings("unused") - MicrosoftGraphFetcherConfig msGraphFetcherConfig = new MicrosoftGraphFetcherConfig().setCredentials( - new ClientCertificateCredentialsConfig().setCertificateBytes(certificateBytes) - .setCertificatePassword(certificatePassword).setClientId(clientId) - .setTenantId(tenantId)).setScopes(Collections.singletonList(".default")); - - @Mock - DrivesRequestBuilder drivesRequestBuilder; - - @Mock - DriveItemRequestBuilder driveItemRequestBuilder; - - @Mock - ItemsRequestBuilder itemsRequestBuilder; - - @Mock - DriveItemItemRequestBuilder driveItemItemRequestBuilder; - - @Mock - ContentRequestBuilder contentRequestBuilder; - - @InjectMocks - MicrosoftGraphFetcher microsoftGraphFetcher; - - @Test - void fetch() throws Exception { - try (AutoCloseable ignored = MockitoAnnotations.openMocks(this)) { - Mockito.when(graphClient.drives()).thenReturn(drivesRequestBuilder); - Mockito.when(drivesRequestBuilder.byDriveId(siteDriveId)) - .thenReturn(driveItemRequestBuilder); - Mockito.when(driveItemRequestBuilder.items()).thenReturn(itemsRequestBuilder); - Mockito.when(itemsRequestBuilder.byDriveItemId(driveItemid)) - .thenReturn(driveItemItemRequestBuilder); - Mockito.when(driveItemItemRequestBuilder.content()).thenReturn(contentRequestBuilder); - String content = "content"; - Mockito.when(contentRequestBuilder.get()) - .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); - InputStream resultingInputStream = - microsoftGraphFetcher.fetch(siteDriveId + "," + driveItemid, new Metadata(), new ParseContext()); - Assertions.assertEquals(content, - IOUtils.toString(resultingInputStream, StandardCharsets.UTF_8)); - } - } -} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java deleted file mode 100644 index 1935d49506..0000000000 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/test/java/org/apache/tika/pipes/fetchers/microsoftgraph/config/AadCredentialConfigBaseTest.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * - * * 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. - * - * - */ - -/* - * - * * 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.tika.pipes.fetchers.microsoftgraph.config; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class AadCredentialConfigBaseTest { - @Test - void checkFormat() throws JsonProcessingException { - MicrosoftGraphFetcherConfig microsoftGraphFetcherConfig = new MicrosoftGraphFetcherConfig(); - var creds = new ClientCertificateCredentialsConfig(); - microsoftGraphFetcherConfig.setCredentials(creds); - creds.setCertificateBytes("nick".getBytes()); - creds.setCertificatePassword("xx"); - creds.setClientId("clientid"); - creds.setTenantId("tenantid"); - - String str = new ObjectMapper().writeValueAsString(microsoftGraphFetcherConfig); - MicrosoftGraphFetcherConfig backAgain = new ObjectMapper().readValue(str, MicrosoftGraphFetcherConfig.class); - Assertions.assertEquals(microsoftGraphFetcherConfig.getCredentials().getClientId(), backAgain.getCredentials().getClientId()); - ClientCertificateCredentialsConfig backAgainCreds = (ClientCertificateCredentialsConfig) backAgain.getCredentials(); - Assertions.assertEquals(microsoftGraphFetcherConfig.getCredentials().getClientId(), backAgain.getCredentials().getClientId()); - Assertions.assertEquals("nick", new String(backAgainCreds.getCertificateBytes())); - } -} diff --git a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml index e4006edb35..374be17c38 100644 --- a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml +++ b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml @@ -13,8 +13,7 @@ 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. ---> - +--> 600 60 @@ -30,6 +29,5 @@ -1 - - - + testgraphtrue4f55380c-fc4a-4ee1-9922-f64ceafa086ba6a88ddb-5938-470c-a11a-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gkowefqHrryTFPhttps://graph.microsoft.com/.default + \ No newline at end of file From 165c21c4851ea981641d83924a212c350d3c9ddf Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 16:42:31 -0500 Subject: [PATCH 81/89] TIKA-4272: don't check in fetcher --- .../tika-grpc/src/test/resources/tika-pipes-test-config.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml index 374be17c38..da42b21414 100644 --- a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml +++ b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml @@ -29,5 +29,4 @@ -1 - testgraphtrue4f55380c-fc4a-4ee1-9922-f64ceafa086ba6a88ddb-5938-470c-a11a-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gkowefqHrryTFPhttps://graph.microsoft.com/.default - \ No newline at end of file + From b44d7c20fb55c7dc8f94dabf3978b48012e257ee Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Fri, 1 Nov 2024 16:42:45 -0500 Subject: [PATCH 82/89] TIKA-4272: don't check in fetcher --- tika-pipes/tika-grpc/example-dockerfile/docker-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh index d117a5c250..329e15366b 100644 --- a/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh +++ b/tika-pipes/tika-grpc/example-dockerfile/docker-build.sh @@ -9,7 +9,7 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) TIKA_SRC_PATH=${SCRIPT_DIR}/../../.. OUT_DIR=${TIKA_SRC_PATH}/tika-pipes/tika-grpc/target/tika-docker -mvn clean install -Dossindex.skip -DskipTests=true -f "${TIKA_SRC_PATH}" || exit +mvn clean install -Dossindex.skip -DskipTests=true -Denforcer.skip=true -Dossindex.skip=true -f "${TIKA_SRC_PATH}" || exit mvn dependency:copy-dependencies -f "${TIKA_SRC_PATH}/tika-pipes/tika-grpc" || exit rm -rf "${OUT_DIR}" mkdir -p "${OUT_DIR}" From cde6e8af445120779c89be0d6379c13ecba80692 Mon Sep 17 00:00:00 2001 From: Nicholas DiPiazza Date: Wed, 6 Nov 2024 19:12:07 -0600 Subject: [PATCH 83/89] TIKA-4272: push config --- .../fetchers/microsoftgraph/MicrosoftGraphFetcher.java | 8 ++++++-- .../src/test/resources/tika-pipes-test-config.xml | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java index 6733fb3a74..b9f6ceafa8 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-microsoft-graph/src/main/java/org/apache/tika/pipes/fetchers/microsoftgraph/MicrosoftGraphFetcher.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.Map; @@ -72,7 +73,7 @@ public MicrosoftGraphFetcher(MicrosoftGraphFetcherConfig config) { */ @Field public void setThrottleSeconds(String commaDelimitedLongs) throws TikaConfigException { - String[] longStrings = commaDelimitedLongs.split(","); + String[] longStrings = (commaDelimitedLongs == null ? "" : commaDelimitedLongs).split(","); long[] seconds = new long[longStrings.length]; for (int i = 0; i < longStrings.length; i++) { try { @@ -120,7 +121,10 @@ public void setCertificatePassword(String certificatePassword) { @Field public void setScopes(List scopes) { - this.config.setScopes(scopes); + config.setScopes(new ArrayList<>(scopes)); + if (config.getScopes().isEmpty()) { + config.getScopes().add("https://graph.microsoft.com/.default"); + } } @Override diff --git a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml index da42b21414..e4006edb35 100644 --- a/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml +++ b/tika-pipes/tika-grpc/src/test/resources/tika-pipes-test-config.xml @@ -13,7 +13,8 @@ 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. ---> +--> + 600 60 @@ -29,4 +30,6 @@ -1 + + From 79227d7a4b461ff1e375ca580c1f273e00d8de80 Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Thu, 28 Nov 2024 15:50:22 -0400 Subject: [PATCH 84/89] Add GoogleFetcher This allows the fetching of items using files.get from Google Drive --- tika-pipes/tika-fetchers/pom.xml | 3 +- .../tika-fetchers/tika-fetcher-google/pom.xml | 96 +++++++++ .../pipes/fetchers/google/GoogleFetcher.java | 199 ++++++++++++++++++ .../google/config/GoogleFetcherConfig.java | 75 +++++++ tika-pipes/tika-grpc/pom.xml | 5 + 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java create mode 100644 tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java diff --git a/tika-pipes/tika-fetchers/pom.xml b/tika-pipes/tika-fetchers/pom.xml index 8b957e8cf9..2507de6e0d 100644 --- a/tika-pipes/tika-fetchers/pom.xml +++ b/tika-pipes/tika-fetchers/pom.xml @@ -37,6 +37,7 @@ tika-fetcher-gcs tika-fetcher-az-blob tika-fetcher-microsoft-graph + tika-fetcher-google @@ -45,4 +46,4 @@ 3.0.0-BETA-rc1 - \ No newline at end of file + diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml new file mode 100644 index 0000000000..09a92027a5 --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml @@ -0,0 +1,96 @@ + + 4.0.0 + + + tika-fetchers + org.apache.tika + 3.0.0-SNAPSHOT + + + tika-fetcher-google + Google Tika Pipes Fetcher + + + 2.2.0 + 11 + 11 + UTF-8 + 1.11.0 + 6.4.0 + 1.1.1 + 5.11.0-M2 + 3.3.1 + 5.3.1 + 9.37.3 + + + + + + ${project.groupId} + tika-core + ${project.version} + + + + + com.google.api-client + google-api-client + ${google.api.client.version} + + + + com.google.auth + google-auth-library-oauth2-http + 1.19.0 + + + + + com.google.apis + google-api-services-drive + v3-rev20241027-2.0.0 + + + + + org.slf4j + slf4j-api + + + + + commons-io + commons-io + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.apache.tika.pipes.fetcher.s3 + + + + + + + + + 3.0.0-BETA-rc1 + + diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java new file mode 100644 index 0000000000..485aedea1a --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java @@ -0,0 +1,199 @@ +/* + * 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.tika.pipes.fetchers.google; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.tika.config.Field; +import org.apache.tika.config.Initializable; +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.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.pipes.fetcher.AbstractFetcher; +import org.apache.tika.pipes.fetchers.google.config.GoogleFetcherConfig; + + +/** + * Google Fetcher allows the fetching of files from a Google Drive, using a + * service account key. + * + * Fetch Keys are ${fileId},${subjectUser}, where the subject user is the + * organizer of the file. This user is necessary as part of the key as the + * service account must act on behalf of the user when querying for the file. + */ +public class GoogleFetcher extends AbstractFetcher implements Initializable { + private static final Logger LOGGER = LoggerFactory.getLogger(GoogleFetcher.class); + private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + + private GoogleCredentials baseCredentials; + + private Drive driveService; + private boolean spoolToTemp; + private List scopes; + + private GoogleFetcherConfig config = new GoogleFetcherConfig(); + + public GoogleFetcher() { + scopes = new ArrayList<>(); + scopes.add(DriveScopes.DRIVE_READONLY); + } + + public GoogleFetcher(GoogleFetcherConfig config) { + this.config = config; + } + + @Field + public void setThrottleSeconds(String commaDelimitedLongs) throws TikaConfigException { + String[] longStrings = (commaDelimitedLongs == null ? "" : commaDelimitedLongs).split(","); + long[] seconds = new long[longStrings.length]; + for (int i = 0; i < longStrings.length; i++) { + try { + seconds[i] = Long.parseLong(longStrings[i]); + } catch (NumberFormatException e) { + throw new TikaConfigException(e.getMessage()); + } + } + setThrottleSeconds(seconds); + } + + public void setThrottleSeconds(long[] throttleSeconds) { + config.setThrottleSeconds(throttleSeconds); + } + + @Field + public void setSpoolToTemp(boolean spoolToTemp) { + config.setSpoolToTemp(spoolToTemp); + } + + @Field + public void setServiceAccountKeyBase64(String serviceAccountKeyBase64) { + config.setServiceAccountKeyBase64(serviceAccountKeyBase64); + } + + @Field + public void setSubjectUser(String subjectUser) { + config.setSubjectUser(subjectUser); + } + + @Field + public void setScopes(List scopes) { + config.setScopes(new ArrayList<>(scopes)); + if (config.getScopes().isEmpty()) { + config.getScopes().add(DriveScopes.DRIVE_READONLY); + } + } + + @Override + public void initialize(Map map) throws TikaConfigException { + try { + baseCredentials = GoogleCredentials + .fromStream(new ByteArrayInputStream(Base64.getDecoder().decode(config.getServiceAccountKeyBase64()))) + .createScoped(scopes); + } catch (IOException e) { + throw new TikaConfigException("Failed to initialize Google Drive service", e); + } + } + + @Override + public void checkInitialization(InitializableProblemHandler initializableProblemHandler) throws TikaConfigException { + } + + @Override + public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws TikaException, IOException { + int tries = 0; + Exception ex = null; + + do { + long start = System.currentTimeMillis(); + try { + String[] fetchKeySplit = fetchKey.split(","); + if (fetchKeySplit.length != 2) { + throw new TikaException("Invalid fetch key, expected format ${fileId},${subjectUser}: " + fetchKey); + } + + String fileId = fetchKeySplit[0]; + String subjectUser = fetchKeySplit[1]; + + GoogleCredentials delegatedCredentials = baseCredentials.createDelegated(subjectUser); + final HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(delegatedCredentials); + + driveService = new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + JSON_FACTORY, + requestInitializer).setApplicationName("tika-fetcher-google").build(); + + InputStream is = driveService.files() + .get(fileId) + .executeMediaAsInputStream(); + + if (is == null) { + throw new IOException("Empty input stream when we tried to parse " + fetchKey); + } + + if (spoolToTemp) { + File tempFile = Files.createTempFile("spooled-temp", ".dat").toFile(); + FileUtils.copyInputStreamToFile(is, tempFile); + LOGGER.info("Spooled to temp file {}", tempFile); + return TikaInputStream.get(tempFile.toPath()); + } + return TikaInputStream.get(is); + + } catch (Exception e) { + LOGGER.warn("Exception fetching on retry=" + tries, e); + ex = e; + } finally { + long elapsed = System.currentTimeMillis() - start; + LOGGER.debug("Total to fetch {}", elapsed); + } + + long[] throttleSeconds = config.getThrottleSeconds(); + + LOGGER.warn("Sleeping for {} seconds before retry", throttleSeconds[tries]); + try { + Thread.sleep(throttleSeconds[tries] * 1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } while (++tries < config.getThrottleSeconds().length); + + throw new TikaException("Could not fetch " + fetchKey, ex); + } +} diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java new file mode 100644 index 0000000000..a5a768192c --- /dev/null +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java @@ -0,0 +1,75 @@ +/* + * 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.tika.pipes.fetchers.google.config; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.tika.pipes.fetcher.config.AbstractConfig; + +public class GoogleFetcherConfig extends AbstractConfig { + private long[] throttleSeconds; + private boolean spoolToTemp; + protected String serviceAccountKeyBase64; + protected String subjectUser; + private List scopes = new ArrayList<>(); + + public boolean isSpoolToTemp() { + return spoolToTemp; + } + + public GoogleFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + this.spoolToTemp = spoolToTemp; + return this; + } + + public long[] getThrottleSeconds() { + return throttleSeconds; + } + + public GoogleFetcherConfig setThrottleSeconds(long[] throttleSeconds) { + this.throttleSeconds = throttleSeconds; + return this; + } + + public String getServiceAccountKeyBase64() { + return serviceAccountKeyBase64; + } + + public GoogleFetcherConfig setServiceAccountKeyBase64(String serviceAccountKeyBase64) { + this.serviceAccountKeyBase64 = serviceAccountKeyBase64; + return this; + } + + public String getSubjectUser() { + return subjectUser; + } + + public GoogleFetcherConfig setSubjectUser(String subjectUser) { + this.subjectUser = subjectUser; + return this; + } + + public List getScopes() { + return scopes; + } + + public GoogleFetcherConfig setScopes(List scopes) { + this.scopes = scopes; + return this; + } +} diff --git a/tika-pipes/tika-grpc/pom.xml b/tika-pipes/tika-grpc/pom.xml index 1716e3f376..e442e94198 100644 --- a/tika-pipes/tika-grpc/pom.xml +++ b/tika-pipes/tika-grpc/pom.xml @@ -226,6 +226,11 @@ tika-fetcher-microsoft-graph ${project.version} + + org.apache.tika + tika-fetcher-google + ${project.version} + com.fasterxml.jackson.module jackson-module-jsonSchema From 3822c9b71820decb54282523dbaa3fd9d3df7260 Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Wed, 4 Dec 2024 21:56:33 -0400 Subject: [PATCH 85/89] fixup! Add GoogleFetcher --- .../tika-fetchers/tika-fetcher-google/pom.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml index 09a92027a5..f631cdb31b 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml @@ -1,3 +1,22 @@ + + From bff877450c405ee57c3b8af6953875f3da1c5b8e Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Wed, 4 Dec 2024 22:09:40 -0400 Subject: [PATCH 86/89] fixup! Add GoogleFetcher --- .../tika/pipes/fetchers/google/GoogleFetcher.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java index 485aedea1a..c722c16520 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java @@ -17,10 +17,10 @@ package org.apache.tika.pipes.fetchers.google; import java.io.ByteArrayInputStream; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -34,7 +34,6 @@ import com.google.api.services.drive.DriveScopes; import com.google.auth.http.HttpCredentialsAdapter; import com.google.auth.oauth2.GoogleCredentials; -import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +43,7 @@ import org.apache.tika.config.Param; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.exception.TikaException; +import org.apache.tika.io.TemporaryResources; import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; @@ -140,6 +140,7 @@ public void checkInitialization(InitializableProblemHandler initializableProblem public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext) throws TikaException, IOException { int tries = 0; Exception ex = null; + TemporaryResources tmp = null; do { long start = System.currentTimeMillis(); @@ -169,10 +170,10 @@ public InputStream fetch(String fetchKey, Metadata metadata, ParseContext parseC } if (spoolToTemp) { - File tempFile = Files.createTempFile("spooled-temp", ".dat").toFile(); - FileUtils.copyInputStreamToFile(is, tempFile); - LOGGER.info("Spooled to temp file {}", tempFile); - return TikaInputStream.get(tempFile.toPath()); + tmp = new TemporaryResources(); + Path tmpPath = tmp.createTempFile(fileId + ".dat"); + Files.copy(is, tmpPath); + return TikaInputStream.get(tmpPath); } return TikaInputStream.get(is); From 5be2d80ca575dbba9ee2470d5b36fbea6777ddbb Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Thu, 5 Dec 2024 10:16:27 -0400 Subject: [PATCH 87/89] fixup! Add GoogleFetcher Rename to GoogleDriveFetcher. This name is more appropriate as the files.get call is specific to Google Drive --- ...{GoogleFetcher.java => GoogleDriveFetcher.java} | 14 +++++++------- ...erConfig.java => GoogleDriveFetcherConfig.java} | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) rename tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/{GoogleFetcher.java => GoogleDriveFetcher.java} (94%) rename tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/{GoogleFetcherConfig.java => GoogleDriveFetcherConfig.java} (80%) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleDriveFetcher.java similarity index 94% rename from tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java rename to tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleDriveFetcher.java index c722c16520..94a21740ee 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleFetcher.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/GoogleDriveFetcher.java @@ -48,19 +48,19 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; import org.apache.tika.pipes.fetcher.AbstractFetcher; -import org.apache.tika.pipes.fetchers.google.config.GoogleFetcherConfig; +import org.apache.tika.pipes.fetchers.google.config.GoogleDriveFetcherConfig; /** - * Google Fetcher allows the fetching of files from a Google Drive, using a + * GoogleDrive Fetcher allows the fetching of files from a Google Drive, using a * service account key. * * Fetch Keys are ${fileId},${subjectUser}, where the subject user is the * organizer of the file. This user is necessary as part of the key as the * service account must act on behalf of the user when querying for the file. */ -public class GoogleFetcher extends AbstractFetcher implements Initializable { - private static final Logger LOGGER = LoggerFactory.getLogger(GoogleFetcher.class); +public class GoogleDriveFetcher extends AbstractFetcher implements Initializable { + private static final Logger LOGGER = LoggerFactory.getLogger(GoogleDriveFetcher.class); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); private GoogleCredentials baseCredentials; @@ -69,14 +69,14 @@ public class GoogleFetcher extends AbstractFetcher implements Initializable { private boolean spoolToTemp; private List scopes; - private GoogleFetcherConfig config = new GoogleFetcherConfig(); + private GoogleDriveFetcherConfig config = new GoogleDriveFetcherConfig(); - public GoogleFetcher() { + public GoogleDriveFetcher() { scopes = new ArrayList<>(); scopes.add(DriveScopes.DRIVE_READONLY); } - public GoogleFetcher(GoogleFetcherConfig config) { + public GoogleDriveFetcher(GoogleDriveFetcherConfig config) { this.config = config; } diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java similarity index 80% rename from tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java rename to tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java index a5a768192c..7cc556eb69 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java @@ -21,7 +21,7 @@ import org.apache.tika.pipes.fetcher.config.AbstractConfig; -public class GoogleFetcherConfig extends AbstractConfig { +public class GoogleDriveFetcherConfig extends AbstractConfig { private long[] throttleSeconds; private boolean spoolToTemp; protected String serviceAccountKeyBase64; @@ -32,7 +32,7 @@ public boolean isSpoolToTemp() { return spoolToTemp; } - public GoogleFetcherConfig setSpoolToTemp(boolean spoolToTemp) { + public GoogleDriveFetcherConfig setSpoolToTemp(boolean spoolToTemp) { this.spoolToTemp = spoolToTemp; return this; } @@ -41,7 +41,7 @@ public long[] getThrottleSeconds() { return throttleSeconds; } - public GoogleFetcherConfig setThrottleSeconds(long[] throttleSeconds) { + public GoogleDriveFetcherConfig setThrottleSeconds(long[] throttleSeconds) { this.throttleSeconds = throttleSeconds; return this; } @@ -50,7 +50,7 @@ public String getServiceAccountKeyBase64() { return serviceAccountKeyBase64; } - public GoogleFetcherConfig setServiceAccountKeyBase64(String serviceAccountKeyBase64) { + public GoogleDriveFetcherConfig setServiceAccountKeyBase64(String serviceAccountKeyBase64) { this.serviceAccountKeyBase64 = serviceAccountKeyBase64; return this; } @@ -59,7 +59,7 @@ public String getSubjectUser() { return subjectUser; } - public GoogleFetcherConfig setSubjectUser(String subjectUser) { + public GoogleDriveFetcherConfig setSubjectUser(String subjectUser) { this.subjectUser = subjectUser; return this; } @@ -68,7 +68,7 @@ public List getScopes() { return scopes; } - public GoogleFetcherConfig setScopes(List scopes) { + public GoogleDriveFetcherConfig setScopes(List scopes) { this.scopes = scopes; return this; } From 1956f24eeac764e7f70c9a02f313708b653fcb86 Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Thu, 5 Dec 2024 12:01:52 -0400 Subject: [PATCH 88/89] fixup! Add GoogleFetcher --- .../pipes/fetchers/google/config/GoogleDriveFetcherConfig.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java index 7cc556eb69..f03db46955 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/src/main/java/org/apache/tika/pipes/fetchers/google/config/GoogleDriveFetcherConfig.java @@ -38,6 +38,9 @@ public GoogleDriveFetcherConfig setSpoolToTemp(boolean spoolToTemp) { } public long[] getThrottleSeconds() { + if (throttleSeconds == null) { + return new long[]{5, 10, 15}; // Default retry intervals + } return throttleSeconds; } From 4c254df14b20317afd9bdc657529d4f4f8e60322 Mon Sep 17 00:00:00 2001 From: Bartek Ciszkowski Date: Thu, 5 Dec 2024 20:46:14 -0400 Subject: [PATCH 89/89] Target tika-fetcher-google for 4.0.0-SNAPSHOT --- tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml index f631cdb31b..a7098309da 100644 --- a/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml +++ b/tika-pipes/tika-fetchers/tika-fetcher-google/pom.xml @@ -25,7 +25,7 @@ tika-fetchers org.apache.tika - 3.0.0-SNAPSHOT + 4.0.0-SNAPSHOT tika-fetcher-google