This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 62fe6fcf74 TIKA-4969: opt-in typed metadata values in JSON output
(#3341)
62fe6fcf74 is described below
commit 62fe6fcf74321c55e9fed1853d99c090be5eb72f
Author: Tim Allison <[email protected]>
AuthorDate: Mon Oct 19 14:29:31 2026 +0100
TIKA-4969: opt-in typed metadata values in JSON output (#3341)
---
CHANGES.txt | 6 +
docs/modules/ROOT/pages/configuration/index.adoc | 39 ++-
.../pages/migration-to-4x/chunk-strategies.adoc | 4 +-
.../java/org/apache/tika/metadata/Property.java | 18 +-
.../apache/tika/metadata/TikaCoreProperties.java | 2 +-
.../org/apache/tika/metadata/metadata-keys.json | 2 +-
.../core/serialization/EmitDataDeserializer.java | 18 ++
.../core/serialization/EmitDataSerializer.java | 17 ++
.../core/serialization/EmitDataValueTypesTest.java | 85 +++++++
.../org/apache/tika/pipes/emitter/es/ESClient.java | 15 +-
.../pipes/emitter/opensearch/OpenSearchClient.java | 16 +-
.../org/apache/tika/config/loader/TikaLoader.java | 25 +-
.../apache/tika/serialization/JsonMetadata.java | 38 ++-
.../tika/serialization/JsonMetadataList.java | 38 ++-
.../tika/serialization/MetadataValueTypes.java | 94 +++++++
.../serialization/serdes/MetadataDeserializer.java | 64 ++++-
.../serialization/serdes/MetadataSerializer.java | 102 +++++++-
.../tika/serialization/MetadataValueTypesTest.java | 97 ++++++++
.../serialization/TypedMetadataValuesTest.java | 272 +++++++++++++++++++++
19 files changed, 906 insertions(+), 46 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 16556bfc19..1c02d2fd92 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,11 @@
Release 4.2.0 - unreleased
+ * Opt-in "metadata-list": {"typedValues": true} writes INTEGER, REAL and
+ BOOLEAN metadata as JSON numbers and booleans, and tk:chunks as nested
+ JSON rather than an escaped string, in -J, /rmeta and the JSON emitters.
+ New Property.ValueType.JSON; tk:chunks is now JSON-typed. Readers accept
+ both forms (TIKA-4969).
+
* The docx parser wraps OMML equations (m:oMath) in <span class="math">;
their
text was previously emitted as unmarked body text (TIKA-4968).
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc
b/docs/modules/ROOT/pages/configuration/index.adoc
index 0421974bc5..77a0c40ea4 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -57,7 +57,7 @@ optional; anything you omit uses its defaults.
"pipes-iterator": { /* iterator (one per pipeline) */ },
"pipes-reporters": { /* per-document status reporters */ },
"plugin-roots": "/path/to/plugins",
- "metadata-list": { /* Jackson read limits for metadata JSON */ },
+ "metadata-list": { /* metadata JSON: Jackson read limits, typedValues */ },
"service-loader": { /* SPI load-failure policy */ },
"xml-reader-utils": { /* XML parser pool size */ }
}
@@ -80,8 +80,8 @@ Unknown keys are rejected almost everywhere: at the top
level, inside a componen
and inside a `FetchEmitTuple`. Four places swallow them silently instead, so a
misspelling there
produces no error and no effect:
-* the `metadata-list` body — only `maxStringLength`, `maxNestingDepth` and
`maxNumberLength` are
- read;
+* the `metadata-list` body — only `maxStringLength`, `maxNestingDepth`,
`maxNumberLength` and
+ `typedValues` are read;
* the body of a self-configuring component under `parse-context` — it is
handed to the component
untouched;
* `_mime-include` / `_mime-exclude` when the value is neither a JSON array nor
a string — a bare
@@ -94,6 +94,39 @@ produces no error and no effect:
If a setting appears to do nothing, check its spelling against one of those
four first.
+[#typed-values]
+=== Typed values in metadata JSON (`typedValues`)
+
+By default every metadata value in JSON output (`tika-app -J`, tika-server
`/rmeta`, and the
+file-system, S3, GCS and Azure emitters) is a string, so `tk:chunks` arrives
as an escaped
+string that consumers must parse a second time. Opt in to native types:
+
+[source,json]
+----
+{ "metadata-list": { "typedValues": true } }
+----
+
+Values of keys registered as `INTEGER` or `REAL` are then written as JSON
numbers, `BOOLEAN`
+as `true`/`false`, and `JSON` keys such as `tk:chunks` as nested JSON:
+
+[source,json]
+----
+{ "audio:channels": 2, "audio:has-drm": false, "tk:chunks": [ { "text": "...",
... } ] }
+----
+
+The key's type comes from its `Property` declaration; `tika-metadata-schema`'s
+`metadata-keys.json` lists it as `valueType`. A value that does not fit its
type (`"abc"` on an
+`INTEGER` key, a JSON value with more than one value on the key) stays a
string, as does a JSON
+value nested deeper than `maxNestingDepth` allows, so the output always reads
back. Only a
+single-valued object, or an array holding an object or array, is written as
JSON: a JSON scalar
+or an array of scalars would be indistinguishable from an ordinary value or
values. Tika's own
+readers accept either form; it is off by default because the change in types
breaks consumers
+that expect strings, and an index that maps a key from its first value (as
Elasticsearch does)
+rejects a later document whose value for that key is malformed and stays a
string.
+
+The Elasticsearch and OpenSearch emitters always write `JSON` keys as JSON,
whatever this
+setting.
+
[#component-names]
== Component names
diff --git a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
index 84888fa275..7b2aa0a414 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
@@ -28,7 +28,9 @@ are kept for earlier shapes.
== The `tk:chunks` Field
-All chunk data lands in one metadata field, `tk:chunks`, as a JSON array.
There is no
+All chunk data lands in one metadata field, `tk:chunks`, as a JSON array (in
JSON output, an
+escaped string unless
+xref:configuration/index.adoc#typed-values[`metadata-list.typedValues`] is
on). There is no
separate field for image embeddings versus text embeddings — a chunk is a
chunk, identified
by which locators it carries:
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Property.java
b/tika-core/src/main/java/org/apache/tika/metadata/Property.java
index 10d3394ad4..bbb414a3c8 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Property.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Property.java
@@ -255,6 +255,12 @@ public final class Property implements
Comparable<Property> {
return new Property(name, true, ValueType.RATIONAL);
}
+ /** A single value holding one JSON document; see {@link ValueType#JSON}.
*/
+ public static Property internalJson(String name) {
+ requireNotReserved(name);
+ return new Property(name, true, ValueType.JSON);
+ }
+
public static Property internalOpenChoice(String name, String... choices) {
requireNotReserved(name);
return new Property(name, true, ValueType.OPEN_CHOICE, choices);
@@ -377,6 +383,11 @@ public final class Property implements
Comparable<Property> {
return new Property(name, true, ValueType.RATIONAL);
}
+ static Property reservedInternalJson(String name) {
+ requireReserved(name);
+ return new Property(name, true, ValueType.JSON);
+ }
+
static Property reservedInternalText(String name) {
requireReserved(name);
return new Property(name, true, ValueType.TEXT);
@@ -563,7 +574,12 @@ public final class Property implements
Comparable<Property> {
public enum ValueType {
BOOLEAN, OPEN_CHOICE, CLOSED_CHOICE, DATE, INTEGER,
- RATIONAL, REAL, TEXT, URI, PROPERTY
+ RATIONAL, REAL, TEXT, URI, PROPERTY,
+ /**
+ * A serialized JSON document. Not an XMP type: Tika's own, for
structured values
+ * such as chunks. Stored as a String like any other value.
+ */
+ JSON
}
}
diff --git
a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
index d1587b1873..797be9fa10 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
@@ -151,7 +151,7 @@ public interface TikaCoreProperties {
* Used by inference parsers and metadata filters to attach chunked
representations
* of document content for downstream indexing and semantic search.
*/
- Property TIKA_CHUNKS = Property.reservedInternalText("tk:chunks");
+ Property TIKA_CHUNKS = Property.reservedInternalJson("tk:chunks");
/**
* The inference inputs a parser released for this document (TEXT, PAGES,
...), written
diff --git
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index 3f57a2f533..cac32492ea 100644
---
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -553,7 +553,7 @@
{"key":"tiff:Software","namespace":"tiff","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tiff:XResolution","namespace":"tiff","valueType":"RATIONAL","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tiff:YResolution","namespace":"tiff","valueType":"RATIONAL","cardinality":"SIMPLE","module":"tika-core"},
-
{"key":"tk:chunks","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
+
{"key":"tk:chunks","namespace":"tk","valueType":"JSON","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tk:content","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tk:content-handler-type","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tk:content-type-hint","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataDeserializer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataDeserializer.java
index 1f63fa9325..741149720c 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataDeserializer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataDeserializer.java
@@ -22,7 +22,9 @@ import static
org.apache.tika.pipes.core.serialization.EmitDataSerializer.METADA
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
@@ -32,6 +34,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.pipes.core.emitter.EmitDataImpl;
+import org.apache.tika.serialization.MetadataValueTypes;
import org.apache.tika.utils.StringUtils;
public class EmitDataDeserializer extends JsonDeserializer<EmitDataImpl> {
@@ -42,6 +45,7 @@ public class EmitDataDeserializer extends
JsonDeserializer<EmitDataImpl> {
ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();
String emitKey = readString(EMIT_KEY, root, null, true);
+ learnValueTypes(root);
List<Metadata> metadataList = readMetadataList(root, mapper);
String containerStackTrace = readString(CONTAINER_STACK_TRACE, root,
StringUtils.EMPTY, false);
@@ -54,6 +58,20 @@ public class EmitDataDeserializer extends
JsonDeserializer<EmitDataImpl> {
return emitData;
}
+ private static void learnValueTypes(JsonNode root) {
+ JsonNode types = root.get(EmitDataSerializer.VALUE_TYPES);
+ if (types == null || !types.isObject()) {
+ return;
+ }
+ Iterator<Map.Entry<String, JsonNode>> fields = types.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> e = fields.next();
+ if (e.getValue().isTextual()) {
+ MetadataValueTypes.learn(e.getKey(), e.getValue().textValue());
+ }
+ }
+ }
+
private static List<Metadata> readMetadataList(JsonNode root, ObjectMapper
mapper) throws IOException {
JsonNode metadataListNode = root.get(METADATA_LIST);
if (metadataListNode == null || !metadataListNode.isArray()) {
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataSerializer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataSerializer.java
index 5f7b87b397..502cddd820 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataSerializer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/EmitDataSerializer.java
@@ -17,12 +17,16 @@
package org.apache.tika.pipes.core.serialization;
import java.io.IOException;
+import java.util.Map;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
+import org.apache.tika.metadata.Property;
import org.apache.tika.pipes.api.emitter.EmitData;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.serialization.MetadataValueTypes;
import org.apache.tika.utils.StringUtils;
public class EmitDataSerializer extends JsonSerializer<EmitData> {
@@ -31,12 +35,25 @@ public class EmitDataSerializer extends
JsonSerializer<EmitData> {
public static final String METADATA_LIST = "metadataList";
public static final String CONTAINER_STACK_TRACE = "containerStackTrace";
public static final String CONTENT_BYTES = "contentBytes";
+ public static final String VALUE_TYPES = "valueTypes";
@Override
public void serialize(EmitData emitData, JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartObject();
jsonGenerator.writeStringField(EMIT_KEY, emitData.getEmitKey());
jsonGenerator.writeObjectField(METADATA_LIST,
emitData.getMetadataList());
+ // the parent may never load the classes that declare these keys
+ if (JsonMetadataList.isTypedValues() && emitData.getMetadataList() !=
null) {
+ Map<String, Property.ValueType> types =
+ MetadataValueTypes.describe(emitData.getMetadataList());
+ if (!types.isEmpty()) {
+ jsonGenerator.writeObjectFieldStart(VALUE_TYPES);
+ for (Map.Entry<String, Property.ValueType> e :
types.entrySet()) {
+ jsonGenerator.writeStringField(e.getKey(),
e.getValue().name());
+ }
+ jsonGenerator.writeEndObject();
+ }
+ }
if (!StringUtils.isBlank(emitData.getContainerStackTrace())) {
jsonGenerator.writeStringField(CONTAINER_STACK_TRACE,
emitData.getContainerStackTrace());
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/EmitDataValueTypesTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/EmitDataValueTypesTest.java
new file mode 100644
index 0000000000..057eef8fa6
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/EmitDataValueTypesTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.core.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.StringWriter;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.dataformat.smile.SmileFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Audio;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.pipes.core.emitter.EmitDataImpl;
+import org.apache.tika.serialization.JsonMetadataList;
+
+/**
+ * A pipes parent serializes metadata whose keys are declared by classes only
the fork loaded,
+ * so the fork sends their types along.
+ */
+public class EmitDataValueTypesTest {
+
+ private static final ObjectMapper SMILE = new ObjectMapper(new
SmileFactory());
+
+ @AfterEach
+ public void reset() {
+ JsonMetadataList.setTypedValues(false);
+ }
+
+ @Test
+ public void testForkReportsTypedKeys() throws Exception {
+ JsonMetadataList.setTypedValues(true);
+ JsonNode types =
SMILE.readTree(JsonPipesIpc.toBytes(emitData())).get("valueTypes");
+ assertEquals("{\"audio:channels\":\"INTEGER\"}", types.toString());
+ }
+
+ @Test
+ public void testNoTypesWhenUntyped() throws Exception {
+
assertNull(SMILE.readTree(JsonPipesIpc.toBytes(emitData())).get("valueTypes"));
+ }
+
+ @Test
+ public void testParentWritesReportedTypes() throws Exception {
+ // a key this JVM does not declare, as a plugin-declared key is in
tika-server
+ String key = "test-emit-value-types:count";
+ ObjectNode wire = SMILE.createObjectNode();
+ wire.put("emitKey", "key");
+ wire.putArray("metadataList").addObject().put(key, "7");
+ wire.putObject("valueTypes").put(key, "INTEGER");
+ EmitDataImpl back =
JsonPipesIpc.fromBytes(SMILE.writeValueAsBytes(wire), EmitDataImpl.class);
+
+ JsonMetadataList.setTypedValues(true);
+ StringWriter writer = new StringWriter();
+ JsonMetadataList.toJson(back.getMetadataList(), writer);
+ assertEquals(7, new
ObjectMapper().readTree(writer.toString()).get(0).get(key).intValue());
+ }
+
+ private static EmitDataImpl emitData() {
+ Metadata m = new Metadata();
+ m.set(Audio.CHANNELS, "2");
+ m.set(TikaCoreProperties.TITLE, "t");
+ return new EmitDataImpl("key", List.of(m));
+ }
+}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-es/src/main/java/org/apache/tika/pipes/emitter/es/ESClient.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-es/src/main/java/org/apache/tika/pipes/emitter/es/ESClient.java
index 4652fa7a7e..3c32f6c413 100644
---
a/tika-pipes/tika-pipes-plugins/tika-pipes-es/src/main/java/org/apache/tika/pipes/emitter/es/ESClient.java
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-es/src/main/java/org/apache/tika/pipes/emitter/es/ESClient.java
@@ -29,7 +29,6 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.UUID;
import com.fasterxml.jackson.core.JsonFactory;
@@ -50,7 +49,9 @@ import org.slf4j.LoggerFactory;
import org.apache.tika.client.TikaClientException;
import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
import org.apache.tika.pipes.api.emitter.EmitData;
+import org.apache.tika.serialization.MetadataValueTypes;
import org.apache.tika.utils.StringUtils;
/**
@@ -486,19 +487,19 @@ public class ESClient {
}
/**
- * Metadata fields whose values are serialized JSON from the
- * tika-inference pipeline. These must be written as raw JSON
- * (arrays/objects) rather than escaped strings so that
- * ES can index vectors, locators, etc. natively.
+ * Keys declared {@link Property.ValueType#JSON}, e.g. {@code tk:chunks}:
written as JSON
+ * rather than escaped strings so that ES can index vectors, locators,
etc. natively.
*/
- static final Set<String> INFERENCE_JSON_FIELDS = Set.of("tk:chunks");
+ static boolean isJsonField(String name) {
+ return MetadataValueTypes.get(name) == Property.ValueType.JSON;
+ }
private static void writeMetadata(Metadata metadata, JsonGenerator
jsonGenerator)
throws IOException {
for (String n : metadata.names()) {
String[] vals = metadata.getValues(n);
if (vals.length == 1) {
- if (INFERENCE_JSON_FIELDS.contains(n) && isValidJson(vals[0]))
{
+ if (isJsonField(n) && isValidJson(vals[0])) {
jsonGenerator.writeFieldName(n);
jsonGenerator.writeRawValue(vals[0]);
} else {
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-opensearch/src/main/java/org/apache/tika/pipes/emitter/opensearch/OpenSearchClient.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-opensearch/src/main/java/org/apache/tika/pipes/emitter/opensearch/OpenSearchClient.java
index 66c7609d97..18e1705517 100644
---
a/tika-pipes/tika-pipes-plugins/tika-pipes-opensearch/src/main/java/org/apache/tika/pipes/emitter/opensearch/OpenSearchClient.java
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-opensearch/src/main/java/org/apache/tika/pipes/emitter/opensearch/OpenSearchClient.java
@@ -29,7 +29,6 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.UUID;
import com.fasterxml.jackson.core.JsonFactory;
@@ -49,7 +48,9 @@ import org.slf4j.LoggerFactory;
import org.apache.tika.client.TikaClientException;
import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
import org.apache.tika.pipes.api.emitter.EmitData;
+import org.apache.tika.serialization.MetadataValueTypes;
import org.apache.tika.utils.StringUtils;
public class OpenSearchClient {
@@ -468,13 +469,12 @@ public class OpenSearchClient {
}
/**
- * Metadata fields whose values are serialized JSON from the
- * tika-inference pipeline. These must be written as raw JSON
- * (arrays/objects) rather than escaped strings so that
- * OpenSearch can index vectors, locators, etc. natively.
+ * Keys declared {@link Property.ValueType#JSON}, e.g. {@code tk:chunks}:
written as JSON
+ * rather than escaped strings so that OpenSearch can index vectors,
locators, etc. natively.
*/
- static final Set<String> INFERENCE_JSON_FIELDS = Set.of(
- "tk:chunks");
+ static boolean isJsonField(String name) {
+ return MetadataValueTypes.get(name) == Property.ValueType.JSON;
+ }
private static void writeMetadata(Metadata metadata, JsonGenerator
jsonGenerator) throws IOException {
//writes the metadata without the start { or the end }
@@ -482,7 +482,7 @@ public class OpenSearchClient {
for (String n : metadata.names()) {
String[] vals = metadata.getValues(n);
if (vals.length == 1) {
- if (INFERENCE_JSON_FIELDS.contains(n)
+ if (isJsonField(n)
&& isValidJson(vals[0])) {
jsonGenerator.writeFieldName(n);
jsonGenerator.writeRawValue(vals[0]);
diff --git
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index ef9b422016..faa05736ed 100644
---
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -581,7 +581,7 @@ public class TikaLoader {
*
* <p>Settings include:
* <ul>
- * <li>metadata-list - Jackson StreamReadConstraints for
JsonMetadata/JsonMetadataList serialization</li>
+ * <li>metadata-list - Jackson StreamReadConstraints and typedValues for
JsonMetadata/JsonMetadataList serialization</li>
* <li>service-loader - Service loader configuration</li>
* <li>xml-reader-utils - XML parser security settings</li>
* </ul>
@@ -636,22 +636,39 @@ public class TikaLoader {
* Loads the metadata-list configuration section and applies it to
* JsonMetadata and JsonMetadataList serializers.
* <p>
- * Configuration uses Jackson's StreamReadConstraints property names:
+ * Read limits use Jackson's StreamReadConstraints property names; {@code
typedValues}
+ * opts in to native numbers, booleans and JSON values (see
+ * {@link JsonMetadataList#setTypedValues(boolean)}):
* <pre>
* {
* "metadata-list": {
* "maxStringLength": 20000000,
* "maxNestingDepth": 10,
- * "maxNumberLength": 500
+ * "maxNumberLength": 500,
+ * "typedValues": true
* }
* }
* </pre>
*/
- private void loadMetadataListConfig() {
+ private void loadMetadataListConfig() throws TikaConfigException {
JsonNode metadataListNode = config.getRootNode().get("metadata-list");
if (metadataListNode == null) {
return;
}
+ if (metadataListNode.has("typedValues")) {
+ JsonNode typedNode = metadataListNode.get("typedValues");
+ if (!typedNode.isBoolean()) {
+ throw new TikaConfigException(
+ "metadata-list.typedValues must be true or false, not
" + typedNode);
+ }
+ boolean typed = typedNode.booleanValue();
+ JsonMetadata.setTypedValues(typed);
+ JsonMetadataList.setTypedValues(typed);
+ }
+ if (!metadataListNode.has("maxStringLength") &&
!metadataListNode.has("maxNestingDepth")
+ && !metadataListNode.has("maxNumberLength")) {
+ return;
+ }
StreamReadConstraints.Builder builder =
StreamReadConstraints.builder();
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadata.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadata.java
index 049d9d0327..1cd123df14 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadata.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadata.java
@@ -44,6 +44,7 @@ public class JsonMetadata {
.build();
private static volatile StreamReadConstraints streamReadConstraints =
DEFAULT_CONSTRAINTS;
+ private static volatile boolean typedValues = false;
private static volatile ObjectMapper OBJECT_MAPPER;
private static volatile ObjectMapper PRETTY_SERIALIZER;
@@ -58,12 +59,18 @@ public class JsonMetadata {
// Use TikaModule which includes Metadata serializers
ObjectMapper mapper = new ObjectMapper(factory);
mapper.registerModule(new TikaModule());
+ StreamReadConstraints valueConstraints = typedValueConstraints();
+ if (valueConstraints != null) {
+ // registered after TikaModule, so this serializer wins
+ mapper.registerModule(new SimpleModule("TypedMetadata")
+ .addSerializer(Metadata.class, new
MetadataSerializer(false, valueConstraints)));
+ }
OBJECT_MAPPER = mapper;
// Pretty printer needs custom serializer with sort flag
ObjectMapper prettyMapper = new ObjectMapper(factory);
SimpleModule prettySerializerModule = new SimpleModule();
- prettySerializerModule.addSerializer(Metadata.class, new
MetadataSerializer(true));
+ prettySerializerModule.addSerializer(Metadata.class, new
MetadataSerializer(true, valueConstraints));
prettyMapper.registerModule(prettySerializerModule);
PRETTY_SERIALIZER = prettyMapper;
}
@@ -82,6 +89,35 @@ public class JsonMetadata {
rebuildObjectMappers();
}
+ /**
+ * The constraints a value nested in a metadata object must meet so that
{@link #fromJson(Reader)}
+ * can read the whole back; null when values are written as strings.
+ */
+ private static StreamReadConstraints typedValueConstraints() {
+ if (!typedValues) {
+ return null;
+ }
+ return streamReadConstraints.rebuild()
+ .maxNestingDepth(Math.max(0,
streamReadConstraints.getMaxNestingDepth() - 1))
+ .build();
+ }
+
+ /**
+ * Opt-in: write values of registered INTEGER, REAL and BOOLEAN keys as
JSON numbers and
+ * booleans, and of JSON keys (e.g. {@code tk:chunks}) as JSON rather than
an escaped string.
+ * Off by default for backwards compatibility. {@link #fromJson(Reader)}
reads either form.
+ * <p>
+ * Typically called by TikaLoader from the "metadata-list" configuration
section.
+ */
+ public static synchronized void setTypedValues(boolean typed) {
+ typedValues = typed;
+ rebuildObjectMappers();
+ }
+
+ public static boolean isTypedValues() {
+ return typedValues;
+ }
+
/**
* Gets the current stream read constraints.
*
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadataList.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadataList.java
index 03d6b53cc0..206a1e0423 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadataList.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/JsonMetadataList.java
@@ -45,6 +45,7 @@ public class JsonMetadataList {
.build();
private static volatile StreamReadConstraints streamReadConstraints =
DEFAULT_CONSTRAINTS;
+ private static volatile boolean typedValues = false;
private static volatile ObjectMapper OBJECT_MAPPER;
private static volatile ObjectMapper PRETTY_SERIALIZER;
@@ -59,12 +60,18 @@ public class JsonMetadataList {
// Use TikaModule which includes Metadata serializers
ObjectMapper mapper = new ObjectMapper(factory);
mapper.registerModule(new TikaModule());
+ StreamReadConstraints valueConstraints = typedValueConstraints();
+ if (valueConstraints != null) {
+ // registered after TikaModule, so this serializer wins
+ mapper.registerModule(new SimpleModule("TypedMetadata")
+ .addSerializer(Metadata.class, new
MetadataSerializer(false, valueConstraints)));
+ }
OBJECT_MAPPER = mapper;
// Pretty printer needs custom serializer with sort flag
ObjectMapper prettyMapper = new ObjectMapper(factory);
SimpleModule prettySerializerModule = new SimpleModule();
- prettySerializerModule.addSerializer(Metadata.class, new
MetadataSerializer(true));
+ prettySerializerModule.addSerializer(Metadata.class, new
MetadataSerializer(true, valueConstraints));
prettyMapper.registerModule(prettySerializerModule);
PRETTY_SERIALIZER = prettyMapper;
}
@@ -83,6 +90,35 @@ public class JsonMetadataList {
rebuildObjectMappers();
}
+ /**
+ * The constraints a value nested in a list of metadata must meet so that
{@link #fromJson(Reader)}
+ * can read the whole back; null when values are written as strings.
+ */
+ private static StreamReadConstraints typedValueConstraints() {
+ if (!typedValues) {
+ return null;
+ }
+ return streamReadConstraints.rebuild()
+ .maxNestingDepth(Math.max(0,
streamReadConstraints.getMaxNestingDepth() - 2))
+ .build();
+ }
+
+ /**
+ * Opt-in: write values of registered INTEGER, REAL and BOOLEAN keys as
JSON numbers and
+ * booleans, and of JSON keys (e.g. {@code tk:chunks}) as JSON rather than
an escaped string.
+ * Off by default for backwards compatibility. {@link #fromJson(Reader)}
reads either form.
+ * <p>
+ * Typically called by TikaLoader from the "metadata-list" configuration
section.
+ */
+ public static synchronized void setTypedValues(boolean typed) {
+ typedValues = typed;
+ rebuildObjectMappers();
+ }
+
+ public static boolean isTypedValues() {
+ return typedValues;
+ }
+
/**
* Gets the current stream read constraints.
*
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/MetadataValueTypes.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/MetadataValueTypes.java
new file mode 100644
index 0000000000..a3881849af
--- /dev/null
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/MetadataValueTypes.java
@@ -0,0 +1,94 @@
+/*
+ * 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.serialization;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+
+/**
+ * Value types of metadata keys for typed JSON output.
+ * <p>
+ * The {@link Property} registry knows a key only once its declaring class has
loaded in this JVM.
+ * A pipes parent (tika-server's {@code /rmeta}, for one) serializes metadata
from parsers and
+ * plugins it never loads, so the fork reports the types of the keys it wrote
+ * ({@link #describe}) and the parent records them ({@link #learn}) before
serializing.
+ */
+public final class MetadataValueTypes {
+
+ // bounds what a misbehaving fork can add
+ static final int MAX_LEARNED = 10_000;
+
+ private static final Set<Property.ValueType> TYPED =
EnumSet.of(Property.ValueType.BOOLEAN,
+ Property.ValueType.INTEGER, Property.ValueType.REAL,
Property.ValueType.JSON);
+
+ private static final Map<String, Property.ValueType> LEARNED = new
ConcurrentHashMap<>();
+
+ private MetadataValueTypes() {
+ }
+
+ /**
+ * @return the key's type from its {@link Property} declaration in this
JVM, else as reported
+ * by a fork, else null
+ */
+ public static Property.ValueType get(String key) {
+ Property property = Property.get(key);
+ return property != null ? property.getValueType() : LEARNED.get(key);
+ }
+
+ /**
+ * @return the keys in the list that typed output writes as other than
strings, with their
+ * types
+ */
+ public static Map<String, Property.ValueType> describe(List<Metadata>
metadataList) {
+ Map<String, Property.ValueType> types = new TreeMap<>();
+ for (Metadata metadata : metadataList) {
+ for (String name : metadata.names()) {
+ Property.ValueType type = get(name);
+ if (type != null && TYPED.contains(type)) {
+ types.put(name, type);
+ }
+ }
+ }
+ return types;
+ }
+
+ /**
+ * Records a type reported by a fork. Ignored when this JVM declares the
key itself, the key
+ * was already learned, the type name is unknown or written as a string,
or the cap is reached.
+ */
+ public static void learn(String key, String typeName) {
+ if (Property.get(key) != null || LEARNED.size() >= MAX_LEARNED) {
+ return;
+ }
+ Property.ValueType type;
+ try {
+ type = Property.ValueType.valueOf(typeName);
+ } catch (IllegalArgumentException e) {
+ return;
+ }
+ if (TYPED.contains(type)) {
+ LEARNED.putIfAbsent(key, type);
+ }
+ }
+}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataDeserializer.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataDeserializer.java
index 248783f97f..0aef15de59 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataDeserializer.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataDeserializer.java
@@ -17,28 +17,40 @@
package org.apache.tika.serialization.serdes;
import java.io.IOException;
+import java.io.StringWriter;
import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.util.TokenBuffer;
import org.apache.tika.metadata.Metadata;
+/**
+ * Reads what {@link MetadataSerializer} writes, typed or not: a scalar is one
value, an array of
+ * scalars is several, and an object, or an array holding an object or array,
is one JSON value.
+ * Needs no knowledge of the keys' types.
+ */
public class MetadataDeserializer extends JsonDeserializer<Metadata> {
+ private static final JsonFactory JSON_FACTORY = new JsonFactory();
+
@Override
public Metadata deserialize(JsonParser jsonParser, DeserializationContext
deserializationContext) throws IOException, JacksonException {
Metadata metadata = new Metadata();
boolean keepGoing = true;
while (keepGoing) {
- keepGoing = addField(jsonParser, metadata);
+ keepGoing = addField(jsonParser, deserializationContext, metadata);
}
return metadata;
}
- private boolean addField(JsonParser jsonParser, Metadata metadata) throws
IOException {
+ private boolean addField(JsonParser jsonParser, DeserializationContext
ctxt, Metadata metadata)
+ throws IOException {
String field = jsonParser.nextFieldName();
if (field == null) {
return false;
@@ -51,21 +63,47 @@ public class MetadataDeserializer extends
JsonDeserializer<Metadata> {
if (token.isScalarValue()) {
metadata.reconstruct(field, jsonParser.getText(), false);
- } else if (jsonParser.isExpectedStartArrayToken()) {
- token = jsonParser.nextToken();
- while (token != null) {
- if (token == JsonToken.END_ARRAY) {
+ return true;
+ }
+ if (!token.isStructStart()) {
+ return false;
+ }
+ TokenBuffer buffer = new TokenBuffer(jsonParser, ctxt);
+ buffer.copyCurrentStructure(jsonParser);
+ if (isOneValue(buffer)) {
+ metadata.reconstruct(field, toJson(buffer), false);
+ return true;
+ }
+ try (JsonParser values = buffer.asParser()) {
+ values.nextToken();
+ while (values.nextToken() != JsonToken.END_ARRAY) {
+ metadata.reconstruct(field, values.getText(), true);
+ }
+ }
+ return true;
+ }
+
+ /** True for an object, or an array holding an object or array; false for
an array of scalars. */
+ static boolean isOneValue(TokenBuffer buffer) throws IOException {
+ try (JsonParser parser = buffer.asParser()) {
+ if (parser.nextToken() != JsonToken.START_ARRAY) {
+ return parser.currentToken() == JsonToken.START_OBJECT;
+ }
+ JsonToken token;
+ while ((token = parser.nextToken()) != JsonToken.END_ARRAY) {
+ if (token.isStructStart()) {
return true;
- } else if (token.isScalarValue()) {
- metadata.reconstruct(field, jsonParser.getText(), true);
- } else {
- break;
}
- token = jsonParser.nextToken();
}
- } else {
return false;
}
- return true;
+ }
+
+ private static String toJson(TokenBuffer buffer) throws IOException {
+ StringWriter writer = new StringWriter();
+ try (JsonGenerator generator = JSON_FACTORY.createGenerator(writer)) {
+ buffer.serialize(generator);
+ }
+ return writer.toString();
}
}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
index db67f6e51a..5487192625 100644
---
a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
+++
b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/MetadataSerializer.java
@@ -19,17 +19,29 @@ package org.apache.tika.serialization.serdes;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;
+import java.util.regex.Pattern;
+import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.util.TokenBuffer;
import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.serialization.MetadataValueTypes;
public class MetadataSerializer extends JsonSerializer<Metadata> {
private static final String TIKA_CONTENT_KEY =
TikaCoreProperties.TIKA_CONTENT.getName();
+ private static final Pattern JSON_INTEGER =
Pattern.compile("-?(?:0|[1-9][0-9]*)");
+ private static final Pattern JSON_NUMBER =
+
Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?");
+
//always sort the content at the end
private static final Comparator<String> METADATA_KEY_COMPARATOR = new
Comparator<String>() {
@Override
@@ -46,13 +58,35 @@ public class MetadataSerializer extends
JsonSerializer<Metadata> {
private boolean prettyPrint = false;
- public MetadataSerializer() {
+ // null: every value is written as a string
+ private final JsonFactory typedValueFactory;
+ public MetadataSerializer() {
+ this(false);
}
public MetadataSerializer(boolean prettyPrint) {
+ this(prettyPrint, null);
+ }
+
+ /**
+ * @param typedValueConstraints if non-null, values of registered {@link
Property.ValueType#INTEGER},
+ * {@link Property.ValueType#REAL}, {@link
Property.ValueType#BOOLEAN}
+ * and {@link Property.ValueType#JSON} keys
are written as JSON numbers,
+ * booleans and structures. A value that is
malformed for its type, or
+ * that a reader held to these constraints
could not read back, stays
+ * a string.
+ */
+ public MetadataSerializer(boolean prettyPrint, StreamReadConstraints
typedValueConstraints) {
this.prettyPrint = prettyPrint;
+ if (typedValueConstraints == null) {
+ this.typedValueFactory = null;
+ } else {
+ this.typedValueFactory = new JsonFactory();
+
this.typedValueFactory.setStreamReadConstraints(typedValueConstraints);
+ }
}
+
@Override
public void serialize(Metadata metadata, JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartObject();
@@ -64,14 +98,72 @@ public class MetadataSerializer extends
JsonSerializer<Metadata> {
String[] v = metadata.getValues(n);
if (v.length == 0) {
continue;
- } else if (v.length == 1) {
- jsonGenerator.writeStringField(n, v[0]);
+ }
+ Property.ValueType type = valueType(n);
+ jsonGenerator.writeFieldName(n);
+ if (v.length == 1) {
+ if (type != Property.ValueType.JSON || !writeJson(v[0],
jsonGenerator)) {
+ writeValue(type, v[0], jsonGenerator);
+ }
} else {
- jsonGenerator.writeFieldName(n);
- jsonGenerator.writeArray(v, 0, v.length);
+ jsonGenerator.writeStartArray();
+ for (String s : v) {
+ writeValue(type, s, jsonGenerator);
+ }
+ jsonGenerator.writeEndArray();
}
}
jsonGenerator.writeEndObject();
}
+ private Property.ValueType valueType(String name) {
+ if (typedValueFactory == null) {
+ return null;
+ }
+ return MetadataValueTypes.get(name);
+ }
+
+ private void writeValue(Property.ValueType type, String value,
JsonGenerator jsonGenerator)
+ throws IOException {
+ if (type == Property.ValueType.BOOLEAN && ("true".equals(value) ||
"false".equals(value))) {
+ jsonGenerator.writeBoolean(Boolean.parseBoolean(value));
+ } else if ((type == Property.ValueType.INTEGER &&
isNumber(JSON_INTEGER, value))
+ || (type == Property.ValueType.REAL && isNumber(JSON_NUMBER,
value))) {
+ jsonGenerator.writeNumber(value);
+ } else {
+ jsonGenerator.writeString(value);
+ }
+ }
+
+ private boolean isNumber(Pattern pattern, String value) {
+ return value.length() <=
typedValueFactory.streamReadConstraints().getMaxNumberLength()
+ && pattern.matcher(value).matches();
+ }
+
+ /**
+ * Writes the value as JSON if it is one JSON document that {@link
MetadataDeserializer}
+ * reads back as this one value, not as several.
+ */
+ private boolean writeJson(String value, JsonGenerator jsonGenerator)
throws IOException {
+ TokenBuffer buffer;
+ try (JsonParser parser = typedValueFactory.createParser(value)) {
+ if (parser.nextToken() == null) {
+ return false;
+ }
+ buffer = new TokenBuffer(parser);
+ buffer.copyCurrentStructure(parser);
+ if (parser.nextToken() != null) {
+ return false;
+ }
+ } catch (JsonProcessingException e) {
+ return false;
+ }
+ if (!MetadataDeserializer.isOneValue(buffer)) {
+ return false;
+ }
+ // the buffer, not writeRawValue: keeps number text exact and honors
pretty printing
+ buffer.serialize(jsonGenerator);
+ return true;
+ }
+
}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/serialization/MetadataValueTypesTest.java
b/tika-serialization/src/test/java/org/apache/tika/serialization/MetadataValueTypesTest.java
new file mode 100644
index 0000000000..ab49ded299
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/serialization/MetadataValueTypesTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.StringWriter;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Audio;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class MetadataValueTypesTest {
+
+ // keys are never declared, so only learn() can type them; each test uses
its own
+ private static final String PREFIX = "test-value-types-";
+
+ @AfterEach
+ public void reset() {
+ JsonMetadataList.setTypedValues(false);
+ }
+
+ @Test
+ public void testLearnedTypeIsWrittenTyped() throws Exception {
+ String key = PREFIX + "learned:count";
+ Metadata m = new Metadata();
+ m.set(key, "7");
+ JsonMetadataList.setTypedValues(true);
+ assertEquals("7", toJson(m).get(key).textValue());
+
+ MetadataValueTypes.learn(key, "INTEGER");
+ assertEquals(7, toJson(m).get(key).intValue());
+ }
+
+ @Test
+ public void testLocalDeclarationWins() {
+ String title = TikaCoreProperties.TITLE.getName();
+ MetadataValueTypes.learn(title, "INTEGER");
+ assertEquals(Property.ValueType.TEXT, MetadataValueTypes.get(title));
+ }
+
+ @Test
+ public void testFirstLearnedWins() {
+ String key = PREFIX + "first:flag";
+ MetadataValueTypes.learn(key, "BOOLEAN");
+ MetadataValueTypes.learn(key, "INTEGER");
+ assertEquals(Property.ValueType.BOOLEAN, MetadataValueTypes.get(key));
+ }
+
+ @Test
+ public void testUntypedAndUnknownTypesIgnored() {
+ for (String type : new String[]{"TEXT", "DATE", "nope", ""}) {
+ String key = PREFIX + "ignored:" + type;
+ MetadataValueTypes.learn(key, type);
+ assertNull(MetadataValueTypes.get(key), type);
+ }
+ }
+
+ @Test
+ public void testDescribeReportsOnlyTypedKeys() {
+ Metadata m = new Metadata();
+ m.set(Audio.CHANNELS, "2");
+ m.set(TikaCoreProperties.TITLE, "t");
+ m.set(PREFIX + "undeclared", "3");
+ assertEquals(Map.of(Audio.CHANNELS.getName(),
Property.ValueType.INTEGER),
+ MetadataValueTypes.describe(List.of(m)));
+ }
+
+ private static JsonNode toJson(Metadata m) throws Exception {
+ StringWriter writer = new StringWriter();
+ JsonMetadataList.toJson(List.of(m), writer);
+ return new ObjectMapper().readTree(writer.toString()).get(0);
+ }
+}
diff --git
a/tika-serialization/src/test/java/org/apache/tika/serialization/TypedMetadataValuesTest.java
b/tika-serialization/src/test/java/org/apache/tika/serialization/TypedMetadataValuesTest.java
new file mode 100644
index 0000000000..fc159fd123
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/serialization/TypedMetadataValuesTest.java
@@ -0,0 +1,272 @@
+/*
+ * 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.serialization;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.metadata.Audio;
+import org.apache.tika.metadata.Geographic;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class TypedMetadataValuesTest {
+
+ private static final String CHUNKS =
TikaCoreProperties.TIKA_CHUNKS.getName();
+ private static final Property TEST_JSON =
Property.internalJson("test-typed:json");
+ // number text a double would rewrite
+ private static final String CHUNKS_JSON =
+ "[{\"text\":\"a
\\\"b\\\"\",\"x\":1.50,\"y\":1e-5,\"z\":12345678901234567890123," +
+ "\"locators\":{\"text\":[{\"start\":0,\"end\":5}]}}]";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @TempDir
+ Path tmp;
+
+ @AfterEach
+ public void reset() {
+ setTyped(false);
+ }
+
+ @Test
+ public void testOffByDefault() throws Exception {
+ Metadata m = chunks(CHUNKS_JSON);
+ m.set(Audio.CHANNELS, "2");
+ JsonNode node = MAPPER.readTree(toJsonList(m)).get(0);
+ assertEquals(CHUNKS_JSON, node.get(CHUNKS).textValue());
+ assertEquals("2", node.get(Audio.CHANNELS.getName()).textValue());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testChunksAreJson(boolean pretty) throws Exception {
+ setTyped(true);
+ Metadata m = chunks(CHUNKS_JSON);
+ String json = toJsonList(m, pretty);
+ JsonNode chunks = MAPPER.readTree(json).get(0).get(CHUNKS);
+ assertTrue(chunks.isArray(), json);
+ assertEquals("a \"b\"", chunks.get(0).get("text").textValue());
+ assertTrue(json.contains("1.50") && json.contains("1e-5"), "number
text kept: " + json);
+
+ Metadata back = JsonMetadataList.fromJson(new
StringReader(json)).get(0);
+ assertEquals(CHUNKS_JSON, back.get(CHUNKS));
+ }
+
+ @Test
+ public void testSingleMetadataChunksAreJson() throws Exception {
+ setTyped(true);
+ StringWriter writer = new StringWriter();
+ JsonMetadata.toJson(chunks(CHUNKS_JSON), writer);
+ assertTrue(MAPPER.readTree(writer.toString()).get(CHUNKS).isArray());
+ assertEquals(CHUNKS_JSON,
+ JsonMetadata.fromJson(new
StringReader(writer.toString())).get(CHUNKS));
+ }
+
+ @Test
+ public void testNumbersAndBooleans() throws Exception {
+ setTyped(true);
+ Metadata m = new Metadata();
+ m.set(Audio.CHANNELS, "2");
+ m.set(Geographic.LATITUDE, "-12.50");
+ m.set(Audio.HAS_DRM, "true");
+ JsonNode node = MAPPER.readTree(toJsonList(m)).get(0);
+ assertTrue(node.get(Audio.CHANNELS.getName()).isIntegralNumber());
+ assertTrue(node.get(Geographic.LATITUDE.getName()).isNumber());
+ assertTrue(node.get(Audio.HAS_DRM.getName()).isBoolean());
+ assertRoundTrips(m);
+ }
+
+ /** A value malformed for its type stays a string rather than becoming
invalid JSON. */
+ @ParameterizedTest
+ @ValueSource(strings = {"abc", "0042", "1.", "+1", "NaN", "Infinity",
"1,5", " 1", ""})
+ public void testMalformedNumbersStayStrings(String value) throws Exception
{
+ setTyped(true);
+ Metadata m = new Metadata();
+ m.set(Audio.CHANNELS, value);
+ m.set(Geographic.LATITUDE, value);
+ m.set(Audio.HAS_DRM, value);
+ JsonNode node = MAPPER.readTree(toJsonList(m)).get(0);
+ assertTrue(node.get(Audio.CHANNELS.getName()).isTextual());
+ assertTrue(node.get(Geographic.LATITUDE.getName()).isTextual());
+ assertTrue(node.get(Audio.HAS_DRM.getName()).isTextual());
+ assertRoundTrips(m);
+ }
+
+ /**
+ * Each would read back as something other than the one value written: a
scalar or an
+ * array of scalars looks like an ordinary value or values; the rest are
not one document.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"[\"a\",\"b\"]", "[]", "[1,2]", "\"a\"", "5",
"null", "{\"a\":",
+ "{} {}", "not json", ""})
+ public void testJsonThatWouldNotRoundTripStaysAString(String value) throws
Exception {
+ setTyped(true);
+ JsonNode node = MAPPER.readTree(toJsonList(chunks(value))).get(0);
+ assertTrue(node.get(CHUNKS).isTextual(), value);
+ assertRoundTrips(chunks(value));
+ }
+
+ @Test
+ public void testMultipleJsonValuesStayStrings() throws Exception {
+ setTyped(true);
+ Metadata m = new Metadata();
+ m.add(TEST_JSON.getName(), CHUNKS_JSON);
+ m.add(TEST_JSON.getName(), "{\"a\":1}");
+ JsonNode node =
MAPPER.readTree(toJsonList(m)).get(0).get(TEST_JSON.getName());
+ assertTrue(node.isArray() && node.get(0).isTextual() &&
node.get(1).isTextual());
+ assertRoundTrips(m);
+ }
+
+ /** Deeper than the reader's nesting limit allows stays a string, so
fromJson still works. */
+ @Test
+ public void testTooDeepStaysAString() throws Exception {
+ setTyped(true);
+ int max =
JsonMetadataList.getStreamReadConstraints().getMaxNestingDepth();
+ // list + metadata object leave max - 2 for the value
+ String fits = "[".repeat(max - 3) + "{}" + "]".repeat(max - 3);
+ String tooDeep = "[" + fits + "]";
+
assertTrue(MAPPER.readTree(toJsonList(chunks(fits))).get(0).get(CHUNKS).isArray());
+
assertTrue(MAPPER.readTree(toJsonList(chunks(tooDeep))).get(0).get(CHUNKS).isTextual());
+ assertRoundTrips(chunks(fits));
+ assertRoundTrips(chunks(tooDeep));
+ }
+
+ /** Typed output from a writer reads back the same whatever the keys and
values. */
+ @Test
+ public void testRandomRoundTrip() throws Exception {
+ long seed = System.nanoTime();
+ Random random = new Random(seed);
+ String[] keys = {TEST_JSON.getName(), Audio.CHANNELS.getName(),
Geographic.LATITUDE.getName(),
+ Audio.HAS_DRM.getName(), "custom:key"};
+ String[] values = {CHUNKS_JSON, "{\"a\":[1,{\"b\":null}]}", "[[1],2]",
"[\"x\"]", "[]",
+ "{}", "0", "-0.5e+3", "007", "true", "false", "TRUE", "", "a",
"null", "\"q\"",
+ "1.0E-5"};
+ for (boolean typed : new boolean[]{false, true}) {
+ setTyped(typed);
+ for (int i = 0; i < 200; i++) {
+ Metadata m = random.nextBoolean() ?
chunks(values[random.nextInt(values.length)])
+ : new Metadata();
+ for (String key : keys) {
+ int n = random.nextInt(3);
+ for (int j = 0; j < n; j++) {
+ m.add(key, values[random.nextInt(values.length)]);
+ }
+ }
+ assertRoundTrips(m, "seed=" + seed);
+ }
+ }
+ }
+
+ @Test
+ public void testConfig() throws Exception {
+ int depth =
JsonMetadataList.getStreamReadConstraints().getMaxNestingDepth();
+ TikaLoader.load(config("{\"metadata-list\": {\"typedValues\":
true}}"));
+ assertTrue(JsonMetadataList.isTypedValues());
+ assertTrue(JsonMetadata.isTypedValues());
+ // typedValues alone leaves the read limits as they were
+ assertEquals(depth,
JsonMetadataList.getStreamReadConstraints().getMaxNestingDepth());
+
+ TikaLoader.load(config("{\"metadata-list\": {\"typedValues\":
false}}"));
+ assertFalse(JsonMetadataList.isTypedValues());
+ assertFalse(JsonMetadata.isTypedValues());
+
+ assertThrows(TikaConfigException.class,
+ () -> TikaLoader.load(config("{\"metadata-list\":
{\"typedValues\": \"yes\"}}")));
+ }
+
+ private Path config(String json) throws Exception {
+ Path p = Files.createTempFile(tmp, "tika-config", ".json");
+ Files.writeString(p, json, StandardCharsets.UTF_8);
+ return p;
+ }
+
+ private static void setTyped(boolean typed) {
+ JsonMetadata.setTypedValues(typed);
+ JsonMetadataList.setTypedValues(typed);
+ }
+
+ private static Metadata chunks(String value) {
+ Metadata m = new Metadata();
+ m.set(TikaCoreProperties.TIKA_CHUNKS, value);
+ return m;
+ }
+
+ private static String toJsonList(Metadata m) throws Exception {
+ return toJsonList(m, false);
+ }
+
+ private static String toJsonList(Metadata m, boolean pretty) throws
Exception {
+ StringWriter writer = new StringWriter();
+ JsonMetadataList.toJson(List.of(m), writer, pretty);
+ return writer.toString();
+ }
+
+ private static void assertRoundTrips(Metadata m) throws Exception {
+ assertRoundTrips(m, "");
+ }
+
+ private static void assertRoundTrips(Metadata m, String message) throws
Exception {
+ for (boolean pretty : new boolean[]{false, true}) {
+ String json = toJsonList(m, pretty);
+ Metadata back = JsonMetadataList.fromJson(new
StringReader(json)).get(0);
+ assertArrayEquals(m.names().length == 0 ? new String[0] :
sorted(m.names()),
+ sorted(back.names()), message + " " + json);
+ for (String name : m.names()) {
+ assertArrayEquals(m.getValues(name), back.getValues(name),
+ message + " " + name + " " + json);
+ }
+
+ StringWriter writer = new StringWriter();
+ JsonMetadata.toJson(m, writer);
+ Metadata single = JsonMetadata.fromJson(new
StringReader(writer.toString()));
+ for (String name : m.names()) {
+ assertArrayEquals(m.getValues(name), single.getValues(name),
+ message + " " + name + " " + writer);
+ }
+ }
+ }
+
+ private static String[] sorted(String[] names) {
+ String[] copy = names.clone();
+ Arrays.sort(copy);
+ return copy;
+ }
+}