A pipes plugin is a zip that Tika loads at runtime through
PF4J, contributing one or more fetchers, emitters, iterators or
reporters. This page walks through building one, using
tika-pipes-file-system
as the reference implementation — every shipped plugin follows the same shape.
The path is: three classes, one plugin.properties, and a build that produces a zip with a
specific internal layout.
One per zip. Subclass org.pf4j.Plugin with a (PluginWrapper) constructor. Overriding
start() / stop() is optional.
public class MyPipesPlugin extends Plugin {
public MyPipesPlugin(PluginWrapper wrapper) {
super(wrapper);
}
}One per extension, and this — not the fetcher or emitter itself — is what carries
@org.pf4j.Extension. Tika needs to build several configured instances of one type, so the
discovered thing is a factory.
@Extension
public class MyFetcherFactory implements FetcherFactory {
@Override
public String getName() {
return "my-fetcher"; // (1)
}
@Override
public Fetcher buildExtension(ExtensionConfig extensionConfig) throws IOException, TikaConfigException {
return new MyFetcher(extensionConfig);
}
@Override
public Class<?> getConfigClass() { // (2)
return MyFetcherConfig.class;
}
}-
The name users write in
tika-config.json. It must be unique across every loaded plugin. -
FetcherFactoryonly. It backs the gRPCGetFetcherConfigJsonSchemacall.
Pick the factory interface for what you are contributing:
| Contributing | Factory interface (org.apache.tika.pipes.api.*) |
Extension interface |
|---|---|---|
Fetcher |
|
|
Emitter |
|
|
Iterator |
|
|
Reporter |
|
|
All four extend org.apache.tika.plugins.TikaExtensionFactory<T>, which is the PF4J extension
point.
Extend org.apache.tika.plugins.AbstractTikaExtension (which just holds the ExtensionConfig)
and implement the interface. Iterators and reporters have richer bases —
PipesIteratorBase in tika-pipes-iterator-commons, whose only abstract method is
enqueue(), and PipesReporterBase in tika-pipes-reporter-commons.
public class MyFetcher extends AbstractTikaExtension implements Fetcher {
private final MyFetcherConfig config;
public MyFetcher(ExtensionConfig extensionConfig) throws TikaConfigException {
super(extensionConfig);
config = MyFetcherConfig.load(extensionConfig.json()); // (1)
}
@Override
public TikaInputStream fetch(String fetchKey, Metadata metadata, ParseContext parseContext)
throws TikaException, IOException {
...
}
}-
Config arrives as a JSON string; nothing richer crosses the boundary — see Classloading: what you must not bundle. The in-tree plugins parse it with
org.apache.tika.plugins.PluginJson(PluginJson.read(json, MyFetcherConfig.class)), the host’s strict mapper: comments allowed, unknown keys, duplicate keys and numbers-for-enums rejected. Using it is optional — the boundary is theFetcher, not how it is configured — but see the Jackson rule under Classloading: what you must not bundle before deciding.
|
Important
|
Fetcher implementations must be thread-safe. One instance serves every concurrent
request against that fetcher id.
|
PF4J finds the plugin through src/main/resources/plugin.properties. There are no
Plugin-Id / Plugin-Class manifest entries; this file is the whole descriptor.
plugin.id=my-pipes-plugin
plugin.class=com.example.tika.MyPipesPlugin
plugin.version=${project.version}
plugin.provider=Example Corp
plugin.description=Fetches documents from Example${project.version} needs resource filtering turned on for this file — and only this file, so
that config examples keep their literal ${…}:
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes><include>plugin.properties</include></includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes><exclude>plugin.properties</exclude></excludes>
</resource>
</resources>Packaging stays jar; the zip is an assembly built alongside it.
Everything on the type boundary between host and plugin must be provided, so it is compiled
against but never shipped inside the plugin:
<dependency><groupId>org.pf4j</groupId><artifactId>pf4j</artifactId><scope>provided</scope></dependency>
<dependency><groupId>org.apache.tika</groupId><artifactId>tika-core</artifactId><scope>provided</scope></dependency>
<dependency><groupId>org.apache.tika</groupId><artifactId>tika-plugins-core</artifactId><scope>provided</scope></dependency>
<dependency><groupId>org.apache.tika</groupId><artifactId>tika-pipes-api</artifactId><scope>provided</scope></dependency>
<dependency><groupId>org.apache.tika</groupId><artifactId>tika-serialization</artifactId><scope>provided</scope></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><scope>provided</scope></dependency>PF4J’s annotation processor writes META-INF/extensions.idx — the file the host reads to find
your factories. It is on the classpath already via PF4J’s own SPI registration; naming it
explicitly pins it:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessors>
<annotationProcessor>org.pf4j.processor.ExtensionAnnotationProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>|
Warning
|
Listing annotationProcessors disables processor auto-discovery for that module. If you
also use another processor, list it here too.
|
The result, in target/classes/META-INF/extensions.idx, is one fully-qualified factory name per
line:
# Generated by PF4J org.apache.tika.pipes.fetcher.fs.FileSystemFetcherFactory org.apache.tika.pipes.emitter.fs.FileSystemEmitterFactory org.apache.tika.pipes.iterator.fs.FileSystemPipesIteratorFactory org.apache.tika.pipes.reporter.fs.FileSystemReporterFactory
If that file is missing or empty, the plugin loads and contributes nothing.
maven-dependency-plugin copies runtime dependencies to target/lib. There is no
exclusion list to maintain: every boundary artifact is declared provided, and
includeScope=runtime excludes provided by definition. A boundary artifact can
only end up in lib/ if a pom re-declares it at compile scope — do not.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals><goal>copy-dependencies</goal></goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>maven-assembly-plugin with a descriptor that sets includeBaseDirectory=false and produces
exactly this layout:
plugin.properties (1) classes/META-INF/extensions.idx (2) lib/my-pipes-plugin-1.0.0.jar (3) lib/<runtime dependencies>.jar LICENSE NOTICE
-
At the zip root. PF4J reads it from there.
-
Under
classes/, which is where PF4J looks for a plugin’s class directory. -
Your own jar goes in
lib/with everything else.
A plugin’s classloader prefers the plugin’s own classes over the host’s. So if your zip ships
tika-core, tika-pipes-api, tika-plugins-core or tika-serialization, your
MyFetcherFactory implements a FetcherFactory that is a different Class object from the
host’s. The host then finds no extensions, or fails casting one.
That is what the provided scoping above prevents, and it is the usual cause
of a plugin that loads cleanly and then behaves as if it were not there — or of a
NoClassDefFoundError / ClassCastException naming a Tika type.
The same reasoning covers logging: leave org.slf4j and org.apache.logging.log4j to the host so
plugin logs land in the host’s configuration.
Jackson is the one library with a choice, and it is either/or:
-
Host Jackson (the default, what the in-tree plugins do):
jackson-core,jackson-databindandjackson-annotationsprovided, absent fromlib/. Every Tika host carries them (viatika-serialization), and you may then usePluginJsonand other host Jackson types. -
Your own Jackson: only if a library you depend on needs a version the host does not ship. Bundle it (
compilescope), and then never touch a host Jackson object — notPluginJson, notJsonMetadataList, nothing returning anObjectMapperorJsonNode. YourObjectMapperand the host’s are differentClassobjects, and the first assignment between them fails with aLinkageErrororClassCastException. ParseExtensionConfig.json()with your own mapper.
Never both. The in-tree plugins' parent pom enforces the first choice; a third-party plugin choosing the second must not inherit that rule.
Everything else — your own transitive libraries — belongs in lib/.
plugin-roots is a directory of zip files, not of unpacked plugin directories:
/opt/tika/plugins/ tika-pipes-file-system-X.Y.Z.zip my-pipes-plugin-1.0.0.zip
Tika unzips each one to a sibling directory named after the zip, writing a completion marker when it finishes. A directory without that marker is treated as a failed extraction and deleted, so unpacking a plugin there by hand does not work.
plugin-roots accepts a single path or an array. tika-server, tika-app and PipesForkParser
all fill it in when you do not: a plugins directory beside the running jar, else one in the
working directory. Loading through TikaPluginManager directly with no plugin-roots fails with
plugin-roots must be specified.
The JSON never names a class. It names your factory’s getName().
fetchers and emitters are keyed by instance id first, component name second — one type per
instance:
{
"plugin-roots": "/opt/tika/plugins",
"fetchers": {
"my-fetcher-id": {
"my-fetcher": { "endpoint": "https://example.invalid", "timeoutMillis": 30000 }
}
},
"emitters": {
"my-emitter-id": {
"file-system-emitter": { "basePath": "/data/output" }
}
}
}pipes-iterator and pipes-reporters are keyed by component name directly — there is no instance
id, because a pipeline has one iterator and reporters are not referenced by id:
{
"pipes-iterator": {
"file-system-pipes-iterator": {
"basePath": "/data/input",
"fetcherId": "my-fetcher-id",
"emitterId": "my-emitter-id"
}
},
"pipes-reporters": {
"es-pipes-reporter": { "esUrl": "https://es.example.invalid:9200/tika-status" }
}
}Whatever object sits innermost is re-serialized to a string and handed to your factory as
ExtensionConfig.json(). Configs are validated when the config loads — an unknown component name
fails immediately, listing the names that are available — but instances are built lazily, on
first use.
An array in any of these sections is rejected outright rather than silently ignored.
Set tika.plugin.dev.mode=true (or TIKA_PLUGIN_DEV_MODE=true) and each entry in plugin-roots
is treated as one already-exploded plugin directory rather than a directory of zips. Point them at
your module’s target/classes, and no zip is built or unpacked.
java -Dtika.plugin.dev.mode=true ...-
plugin.propertiesat the zip root, withplugin.idandplugin.class. -
@Extensionon the factory, never on the fetcher or emitter. -
META-INF/extensions.idxnon-empty intarget/classesafter compiling. -
tika-core,tika-pipes-api,tika-plugins-core,tika-serialization,tika-pipes-core,tika-pipes-iterator-commons,pf4jand the logging implementationsprovided, and absent fromlib/. -
Jackson either
provided(and thenPluginJsonis yours to use) or bundled (and then no host Jackson type is) — never a mix. -
The zip — not an unpacked directory — dropped in a
plugin-rootsdirectory. -
getName()unique against every other loaded plugin.
-
tika-pipes-file-system— the smallest plugin that implements all four extension points -
org.apache.tika.plugins—TikaExtensionFactory,ExtensionConfig,TikaPluginManager -
org.apache.tika.pipes.api— the four extension interfaces