Skip to content

Commit 80275fe

Browse files
authored
TIKA-4794 - Metadata key passthrough (#2971)
1 parent c2ab806 commit 80275fe

41 files changed

Lines changed: 1547 additions & 120 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

tika-core/src/main/java/org/apache/tika/metadata/DWG.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public interface DWG {
2323

2424
String DWG_PREFIX = "dwg" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
2525

26+
PassthroughPrefix RAW_FIELD =
27+
PassthroughPrefix.file(DWG_PREFIX, "DWGRead JSON header/summary field names");
28+
2629
Property APPLICATION_NAME = Property.externalText(DWG_PREFIX + "applicationName");
2730

2831
Property APPLICATION_VERSION = Property.externalText(DWG_PREFIX + "applicationVersion");

tika-core/src/main/java/org/apache/tika/metadata/HTML.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
public interface HTML {
2020
String PREFIX_HTML_META = "html" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
2121

22+
PassthroughPrefix SCRAPED_META = PassthroughPrefix.file(PREFIX_HTML_META,
23+
"scraped <meta>/http-equiv/OpenGraph names not mapped to a Property");
24+
2225

2326
/**
2427
* If a script element contains a src value, this value

tika-core/src/main/java/org/apache/tika/metadata/MAPI.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public interface MAPI {
2727
String PREFIX_MAPI_META = "mapi" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
2828
String PREFIX_MAPI_ATTACH_META = "mapi:attach" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
2929
String PREFIX_MAPI_PROPERTY = PREFIX_MAPI_META + "property" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
30+
PassthroughPrefix PROPERTY = PassthroughPrefix.file(PREFIX_MAPI_PROPERTY, "MAPI named properties");
3031

3132
/**
3233
* MAPI message class. What type of .msg/MAPI file is it?

tika-core/src/main/java/org/apache/tika/metadata/Message.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public interface Message {
2727
String MESSAGE_RAW_HEADER_PREFIX =
2828
MESSAGE_PREFIX + "Raw-Header" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
2929

30+
PassthroughPrefix RAW_HEADER = PassthroughPrefix.file(MESSAGE_RAW_HEADER_PREFIX,
31+
"RFC822 / Outlook raw email header names");
32+
3033
String MESSAGE_RECIPIENT_ADDRESS = "Message-Recipient-Address";
3134

3235
String MESSAGE_FROM = "Message-From";

tika-core/src/main/java/org/apache/tika/metadata/Office.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ public interface Office {
3737
*/
3838
String USER_DEFINED_METADATA_NAME_PREFIX = "custom:";
3939

40+
PassthroughPrefix USER_DEFINED = PassthroughPrefix.file(USER_DEFINED_METADATA_NAME_PREFIX,
41+
"OOXML/OLE2/ODF user-defined document properties");
42+
4043

4144
/**
4245
* Keywords pertaining to a document. Also populates {@link DublinCore#SUBJECT}.

tika-core/src/main/java/org/apache/tika/metadata/PDF.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public interface PDF {
5151
String PDF_DOC_INFO_CUSTOM_PREFIX =
5252
PDF_DOC_INFO_PREFIX + "custom" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
5353

54+
PassthroughPrefix DOC_INFO_CUSTOM =
55+
PassthroughPrefix.file(PDF_DOC_INFO_CUSTOM_PREFIX, "PDF Info-dict custom keys");
56+
5457
Property DOC_INFO_CREATED = Property.internalDate(PDF_DOC_INFO_PREFIX + "created");
5558

5659
Property DOC_INFO_CREATOR = Property.internalText(PDF_DOC_INFO_PREFIX + "creator");
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.metadata;
18+
19+
import java.util.Collection;
20+
import java.util.Collections;
21+
import java.util.Map;
22+
import java.util.concurrent.ConcurrentHashMap;
23+
24+
/**
25+
* A Tika-owned prefix under which keys are passed through from the source: the prefix is fixed, but
26+
* each key name comes verbatim from the document or tool, so keys are unbounded and can't be
27+
* {@link Property} constants. Declaring one self-registers it, so the open set is enumerable (as
28+
* {@link Property} makes the closed set) and lintable: a String write is legitimate iff its key is a
29+
* registered {@link Property} or its prefix is a registered {@code PassthroughPrefix}.
30+
*
31+
* @since Apache Tika 4.0.0
32+
*/
33+
public final class PassthroughPrefix {
34+
35+
public enum Provenance { FILE, TOOL }
36+
37+
private static final Map<String, PassthroughPrefix> REGISTRY = new ConcurrentHashMap<>();
38+
39+
private final String prefix;
40+
private final Provenance provenance;
41+
private final String description;
42+
43+
private PassthroughPrefix(String prefix, Provenance provenance, String description) {
44+
this.prefix = prefix;
45+
this.provenance = provenance;
46+
this.description = description;
47+
REGISTRY.put(prefix, this);
48+
}
49+
50+
public static PassthroughPrefix file(String prefix, String description) {
51+
return new PassthroughPrefix(prefix, Provenance.FILE, description);
52+
}
53+
54+
public static PassthroughPrefix tool(String prefix, String description) {
55+
return new PassthroughPrefix(prefix, Provenance.TOOL, description);
56+
}
57+
58+
/** The full key for a source-derived {@code suffix}. */
59+
public String key(String suffix) {
60+
return prefix + suffix;
61+
}
62+
63+
public String prefix() {
64+
return prefix;
65+
}
66+
67+
public Provenance provenance() {
68+
return provenance;
69+
}
70+
71+
public String description() {
72+
return description;
73+
}
74+
75+
/** Declared prefixes, from loaded classes only. */
76+
public static Collection<PassthroughPrefix> registered() {
77+
return Collections.unmodifiableCollection(REGISTRY.values());
78+
}
79+
}

tika-metadata-schema/README.md

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@
1616
-->
1717
# tika-metadata-schema
1818

19-
A machine-readable schema of Apache Tika's metadata keys. Two registries, because Tika has two
20-
kinds of keys:
19+
A machine-readable schema of Apache Tika's metadata keys, plus a registry-driven validator
20+
(`MetadataKeyValidator`) that classifies any key as CLOSED / OPEN / TEMPLATE / UNKNOWN.
21+
22+
**Scope: `tika-core` + the standard parser bundle.** The heavier/optional parser families
23+
(scientific, sqlite3, nlp, vlm) are *not* scanned — pulling their runtime deps (netcdf, grib,
24+
opennlp, DL4J, sqlite-jdbc) into a build-time schema module isn't worth it. Their keys are the only
25+
ones absent (e.g. `sqlite3:`, `vlm:`, `grib:`, `netcdf:`, `ctakes:`, `NER_`). `MetadataCoverageTest`
26+
enforces this: any module declaring keys that is neither scanned nor on its explicit out-of-scope
27+
list fails the build, so nothing escapes *silently*.
2128

2229
## `metadata-keys.json` — the closed set (generated + gated)
2330
Every key Tika declares as a `Property` constant, plus the bounded digest cross-product
@@ -29,22 +36,49 @@ declare a `Property` field, force-loads them, reads the global `Property` table,
2936
sorted JSON. `MetadataSchemaTest` regenerates in-memory and asserts it matches the committed file, so
3037
the registry can never drift from the declarations.
3138

32-
Regenerate after adding/changing a `Property`:
39+
Regenerate after adding/changing a `Property` **or** a `PassthroughPrefix` (writes both files):
3340
```
3441
java -cp <tika-metadata-schema + deps classpath> \
3542
org.apache.tika.metadata.schema.SchemaGenerator \
36-
src/main/resources/org/apache/tika/metadata/metadata-keys.json
43+
src/main/resources/org/apache/tika/metadata/metadata-keys.json \
44+
src/main/resources/org/apache/tika/metadata/metadata-open-namespaces.json
3745
```
3846

39-
## `metadata-open-namespaces.json` — the open sets (curated)
40-
Keys minted at **runtime** whose names are not `Property` constants, so they cannot be generated:
41-
- **open/passthrough namespaces** — file-controlled key names (scraped HTML `<meta>` under `html:`,
42-
OOXML `custom:`, email `Message:Raw-Header:`, Access `MDB_PROP:`, Vorbis comments, GRIB/NetCDF/FLV
43-
attributes, …);
44-
- **templates** — e.g. XMP `rdf:Alt` language variants `<base-key>:<lang>` (`dc:title:fr`).
47+
## `metadata-open-namespaces.json` — the open sets (generated + gated)
48+
The **prefixes** under which parsers mint file-controlled key names at runtime — names that are not
49+
`Property` constants, so the individual keys cannot be enumerated (scraped HTML `<meta>` under
50+
`html:`, OOXML `custom:`, email `Message:Raw-Header:`, Access `MDB_PROP:`, Vorbis comments, FLV
51+
attributes, unmapped image/XMP tags, …). Each record: `{ prefix, provenance, description }`.
52+
53+
**Generated from the `PassthroughPrefix` declarations, never hand-edited.** Every such prefix is a
54+
registered `PassthroughPrefix` constant; `SchemaGenerator` reads that registry the same way it reads
55+
the `Property` table, and `MetadataSchemaTest` gates it identically. Adding a passthrough prefix in a
56+
parser and forgetting to regenerate fails the build.
57+
58+
Not covered here: **templates** — parameterized key families like XMP `rdf:Alt` language variants
59+
`<base-key>:<lang>` (`dc:title:fr`), where the *suffix* rather than the prefix is open. These are
60+
documented by rule, not enumerated.
61+
62+
## `metadata-string-keys.json` — legacy bare-String closed keys (curated + gated)
63+
A handful of closed keys predate `Property` and are still declared as bare `String` constants
64+
(`HttpHeaders.CONTENT_TYPE` = `Content-Type`, the `Content-*`/`Location` family, `Message-*` /
65+
`Multipart-*`, `tika:chunks`). They self-register nowhere, so the `Property` scan can't see them —
66+
yet Tika emits them constantly. Each record: `{ key, source }`.
67+
68+
**Curated, but gated against the code:** `MetadataStringKeysTest` reflects each `source` constant
69+
(e.g. `HttpHeaders.CONTENT_TYPE`) and asserts its live value equals `key`, so a rename/retype/value
70+
change fails the build. The right long-term fix is to make these `Property` constants (then they'd
71+
move to `metadata-keys.json` automatically); that's a large, `Content-Type`-blast-radius change left
72+
for a future major release.
4573

46-
**Curated (hand-maintained, reviewed), not generated**, and possibly not exhaustive; the
47-
closed-namespace lint (a follow-up) is the intended completeness backstop.
74+
## `MetadataKeyValidator` — the registry-driven lint
75+
Classifies any key by reading the three registries above (no parser classes needed):
76+
`CLOSED` (in `metadata-keys.json` or `metadata-string-keys.json`), `OPEN` (under a registered
77+
passthrough prefix), `TEMPLATE` (a `<closed-key>:<lang>` lang-alt instance), or `UNKNOWN` — a typo,
78+
an unregistered namespace, or a key nobody declared. This is the payoff the registries exist for: a
79+
data-driven legitimacy check instead of a hand-coded regex.
4880

49-
Together the two files describe the whole key space: closed keys are enumerated and gated; open keys
50-
are described by rule.
81+
Together the files describe the key space of the scanned bundle: closed keys are enumerated and
82+
gated (Property-backed and legacy-String alike); open namespaces are enumerated by prefix and gated;
83+
templates are described by rule; and `MetadataCoverageTest` guarantees no scanned-bundle module is
84+
silently missed. Keys from the out-of-scope families above are excluded by design.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.metadata.schema;
18+
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
import java.io.UncheckedIOException;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.ArrayList;
24+
import java.util.Comparator;
25+
import java.util.HashSet;
26+
import java.util.List;
27+
import java.util.Set;
28+
import java.util.regex.Pattern;
29+
30+
/**
31+
* Registry-driven lint: classifies a metadata key against the committed registries rather than a
32+
* hand-coded rule. A key is legitimate iff it is an enumerated closed key
33+
* ({@code metadata-keys.json}), sits under a registered passthrough prefix
34+
* ({@code metadata-open-namespaces.json}), or is a documented template instance. Anything else is
35+
* {@link Classification#UNKNOWN} — a typo, an unregistered namespace, or a key someone forgot to
36+
* declare.
37+
*
38+
* <p>Reads the JSON snapshots (which {@code MetadataSchemaTest} gates against the live declarations),
39+
* so it needs no parser classes loaded and stays dependency-free.
40+
*/
41+
public final class MetadataKeyValidator {
42+
43+
public enum Classification { CLOSED, OPEN, TEMPLATE, UNKNOWN }
44+
45+
private static final String KEYS = "/org/apache/tika/metadata/metadata-keys.json";
46+
private static final String OPEN = "/org/apache/tika/metadata/metadata-open-namespaces.json";
47+
// Legacy closed keys declared as bare String constants (not Property), so absent from KEYS.
48+
private static final String STRING_KEYS = "/org/apache/tika/metadata/metadata-string-keys.json";
49+
50+
// Conservative BCP-47 subset for the XMP lang-alt template suffix (<closed-key>:<lang>).
51+
private static final Pattern LANG_TAG =
52+
Pattern.compile("x-default|[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*");
53+
54+
private final Set<String> closedKeys;
55+
private final List<String> openPrefixes; // longest-first: most specific prefix wins
56+
57+
MetadataKeyValidator(Set<String> closedKeys, List<String> openPrefixes) {
58+
this.closedKeys = Set.copyOf(closedKeys);
59+
List<String> sorted = new ArrayList<>(openPrefixes);
60+
sorted.sort(Comparator.comparingInt(String::length).reversed());
61+
this.openPrefixes = List.copyOf(sorted);
62+
}
63+
64+
/** Loads the validator from the committed registries on the classpath. */
65+
public static MetadataKeyValidator fromClasspath() {
66+
Set<String> closed = new HashSet<>(readValues(KEYS, "key"));
67+
closed.addAll(readValues(STRING_KEYS, "key"));
68+
return new MetadataKeyValidator(closed, readValues(OPEN, "prefix"));
69+
}
70+
71+
public Classification classify(String key) {
72+
if (key == null || key.isEmpty()) {
73+
return Classification.UNKNOWN;
74+
}
75+
if (closedKeys.contains(key)) {
76+
return Classification.CLOSED;
77+
}
78+
for (String prefix : openPrefixes) {
79+
if (key.length() > prefix.length() && key.startsWith(prefix)) {
80+
return Classification.OPEN;
81+
}
82+
}
83+
if (isLangAltInstance(key)) {
84+
return Classification.TEMPLATE;
85+
}
86+
return Classification.UNKNOWN;
87+
}
88+
89+
public boolean isLegitimate(String key) {
90+
return classify(key) != Classification.UNKNOWN;
91+
}
92+
93+
/** The unregistered keys among {@code names}, in encounter order. */
94+
public List<String> findUnknown(Iterable<String> names) {
95+
List<String> unknown = new ArrayList<>();
96+
for (String name : names) {
97+
if (classify(name) == Classification.UNKNOWN) {
98+
unknown.add(name);
99+
}
100+
}
101+
return unknown;
102+
}
103+
104+
/** {@code <closed-key>:<lang>} — the XMP rdf:Alt language variants (dc:title:fr, dc:title:x-default). */
105+
private boolean isLangAltInstance(String key) {
106+
int i = key.lastIndexOf(':');
107+
if (i <= 0 || i == key.length() - 1) {
108+
return false;
109+
}
110+
return closedKeys.contains(key.substring(0, i)) && LANG_TAG.matcher(key.substring(i + 1)).matches();
111+
}
112+
113+
private static List<String> readValues(String resource, String field) {
114+
String json = readResource(resource);
115+
List<String> out = new ArrayList<>();
116+
String marker = '"' + field + "\":\"";
117+
int i = 0;
118+
while ((i = json.indexOf(marker, i)) >= 0) {
119+
i += marker.length();
120+
StringBuilder sb = new StringBuilder();
121+
while (i < json.length()) {
122+
char c = json.charAt(i++);
123+
if (c == '\\' && i < json.length()) {
124+
sb.append(json.charAt(i++)); // unescape \" and \\
125+
} else if (c == '"') {
126+
break;
127+
} else {
128+
sb.append(c);
129+
}
130+
}
131+
out.add(sb.toString());
132+
}
133+
return out;
134+
}
135+
136+
private static String readResource(String resource) {
137+
try (InputStream in = MetadataKeyValidator.class.getResourceAsStream(resource)) {
138+
if (in == null) {
139+
throw new IllegalStateException("missing registry resource " + resource);
140+
}
141+
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
142+
} catch (IOException e) {
143+
throw new UncheckedIOException(e);
144+
}
145+
}
146+
}

0 commit comments

Comments
 (0)