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

tballison pushed a commit to branch metadata-key-passthrough
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/metadata-key-passthrough by 
this push:
     new 82a558484d add validation
82a558484d is described below

commit 82a558484d9beebcc20f6ed110514a64b2cda4c1
Author: tallison <[email protected]>
AuthorDate: Mon Jul 27 16:20:39 2026 -0400

    add validation
---
 tika-metadata-schema/README.md                     |  29 +++-
 .../tika/metadata/schema/MetadataKeyValidator.java | 146 +++++++++++++++++++++
 .../apache/tika/metadata/metadata-string-keys.json |  17 +++
 .../metadata/schema/MetadataKeyValidatorTest.java  |  90 +++++++++++++
 .../metadata/schema/MetadataStringKeysTest.java    |  65 +++++++++
 5 files changed, 342 insertions(+), 5 deletions(-)

diff --git a/tika-metadata-schema/README.md b/tika-metadata-schema/README.md
index 105b4de77a..84c6d0f5ff 100644
--- a/tika-metadata-schema/README.md
+++ b/tika-metadata-schema/README.md
@@ -16,8 +16,8 @@
 -->
 # tika-metadata-schema
 
-A machine-readable schema of Apache Tika's metadata keys. Two registries, 
because Tika has two
-kinds of keys.
+A machine-readable schema of Apache Tika's metadata keys, plus a 
registry-driven validator
+(`MetadataKeyValidator`) that classifies any key as CLOSED / OPEN / TEMPLATE / 
UNKNOWN.
 
 **Scope: `tika-core` + the standard parser bundle.** The heavier/optional 
parser families
 (scientific, sqlite3, nlp, vlm) are *not* scanned — pulling their runtime deps 
(netcdf, grib,
@@ -59,7 +59,26 @@ Not covered here: **templates** — parameterized key families 
like XMP `rdf:Alt
 `<base-key>:<lang>` (`dc:title:fr`), where the *suffix* rather than the prefix 
is open. These are
 documented by rule, not enumerated.
 
+## `metadata-string-keys.json` — legacy bare-String closed keys (curated + 
gated)
+A handful of closed keys predate `Property` and are still declared as bare 
`String` constants
+(`HttpHeaders.CONTENT_TYPE` = `Content-Type`, the `Content-*`/`Location` 
family, `Message-*` /
+`Multipart-*`, `tika:chunks`). They self-register nowhere, so the `Property` 
scan can't see them —
+yet Tika emits them constantly. Each record: `{ key, source }`.
+
+**Curated, but gated against the code:** `MetadataStringKeysTest` reflects 
each `source` constant
+(e.g. `HttpHeaders.CONTENT_TYPE`) and asserts its live value equals `key`, so 
a rename/retype/value
+change fails the build. The right long-term fix is to make these `Property` 
constants (then they'd
+move to `metadata-keys.json` automatically); that's a large, 
`Content-Type`-blast-radius change left
+for a future major release.
+
+## `MetadataKeyValidator` — the registry-driven lint
+Classifies any key by reading the three registries above (no parser classes 
needed):
+`CLOSED` (in `metadata-keys.json` or `metadata-string-keys.json`), `OPEN` 
(under a registered
+passthrough prefix), `TEMPLATE` (a `<closed-key>:<lang>` lang-alt instance), 
or `UNKNOWN` — a typo,
+an unregistered namespace, or a key nobody declared. This is the payoff the 
registries exist for: a
+data-driven legitimacy check instead of a hand-coded regex.
+
 Together the files describe the key space of the scanned bundle: closed keys 
are enumerated and
-gated; open namespaces are enumerated by prefix and gated; templates are 
described by rule; and
-`MetadataCoverageTest` guarantees no scanned-bundle module is silently missed. 
Keys from the
-out-of-scope families above are excluded by design.
+gated (Property-backed and legacy-String alike); open namespaces are 
enumerated by prefix and gated;
+templates are described by rule; and `MetadataCoverageTest` guarantees no 
scanned-bundle module is
+silently missed. Keys from the out-of-scope families above are excluded by 
design.
diff --git 
a/tika-metadata-schema/src/main/java/org/apache/tika/metadata/schema/MetadataKeyValidator.java
 
b/tika-metadata-schema/src/main/java/org/apache/tika/metadata/schema/MetadataKeyValidator.java
new file mode 100644
index 0000000000..4af312e9f6
--- /dev/null
+++ 
b/tika-metadata-schema/src/main/java/org/apache/tika/metadata/schema/MetadataKeyValidator.java
@@ -0,0 +1,146 @@
+/*
+ * 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.metadata.schema;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Registry-driven lint: classifies a metadata key against the committed 
registries rather than a
+ * hand-coded rule. A key is legitimate iff it is an enumerated closed key
+ * ({@code metadata-keys.json}), sits under a registered passthrough prefix
+ * ({@code metadata-open-namespaces.json}), or is a documented template 
instance. Anything else is
+ * {@link Classification#UNKNOWN} — a typo, an unregistered namespace, or a 
key someone forgot to
+ * declare.
+ *
+ * <p>Reads the JSON snapshots (which {@code MetadataSchemaTest} gates against 
the live declarations),
+ * so it needs no parser classes loaded and stays dependency-free.
+ */
+public final class MetadataKeyValidator {
+
+    public enum Classification { CLOSED, OPEN, TEMPLATE, UNKNOWN }
+
+    private static final String KEYS = 
"/org/apache/tika/metadata/metadata-keys.json";
+    private static final String OPEN = 
"/org/apache/tika/metadata/metadata-open-namespaces.json";
+    // Legacy closed keys declared as bare String constants (not Property), so 
absent from KEYS.
+    private static final String STRING_KEYS = 
"/org/apache/tika/metadata/metadata-string-keys.json";
+
+    // Conservative BCP-47 subset for the XMP lang-alt template suffix 
(<closed-key>:<lang>).
+    private static final Pattern LANG_TAG =
+            Pattern.compile("x-default|[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*");
+
+    private final Set<String> closedKeys;
+    private final List<String> openPrefixes;   // longest-first: most specific 
prefix wins
+
+    MetadataKeyValidator(Set<String> closedKeys, List<String> openPrefixes) {
+        this.closedKeys = Set.copyOf(closedKeys);
+        List<String> sorted = new ArrayList<>(openPrefixes);
+        sorted.sort(Comparator.comparingInt(String::length).reversed());
+        this.openPrefixes = List.copyOf(sorted);
+    }
+
+    /** Loads the validator from the committed registries on the classpath. */
+    public static MetadataKeyValidator fromClasspath() {
+        Set<String> closed = new HashSet<>(readValues(KEYS, "key"));
+        closed.addAll(readValues(STRING_KEYS, "key"));
+        return new MetadataKeyValidator(closed, readValues(OPEN, "prefix"));
+    }
+
+    public Classification classify(String key) {
+        if (key == null || key.isEmpty()) {
+            return Classification.UNKNOWN;
+        }
+        if (closedKeys.contains(key)) {
+            return Classification.CLOSED;
+        }
+        for (String prefix : openPrefixes) {
+            if (key.length() > prefix.length() && key.startsWith(prefix)) {
+                return Classification.OPEN;
+            }
+        }
+        if (isLangAltInstance(key)) {
+            return Classification.TEMPLATE;
+        }
+        return Classification.UNKNOWN;
+    }
+
+    public boolean isLegitimate(String key) {
+        return classify(key) != Classification.UNKNOWN;
+    }
+
+    /** The unregistered keys among {@code names}, in encounter order. */
+    public List<String> findUnknown(Iterable<String> names) {
+        List<String> unknown = new ArrayList<>();
+        for (String name : names) {
+            if (classify(name) == Classification.UNKNOWN) {
+                unknown.add(name);
+            }
+        }
+        return unknown;
+    }
+
+    /** {@code <closed-key>:<lang>} — the XMP rdf:Alt language variants 
(dc:title:fr, dc:title:x-default). */
+    private boolean isLangAltInstance(String key) {
+        int i = key.lastIndexOf(':');
+        if (i <= 0 || i == key.length() - 1) {
+            return false;
+        }
+        return closedKeys.contains(key.substring(0, i)) && 
LANG_TAG.matcher(key.substring(i + 1)).matches();
+    }
+
+    private static List<String> readValues(String resource, String field) {
+        String json = readResource(resource);
+        List<String> out = new ArrayList<>();
+        String marker = '"' + field + "\":\"";
+        int i = 0;
+        while ((i = json.indexOf(marker, i)) >= 0) {
+            i += marker.length();
+            StringBuilder sb = new StringBuilder();
+            while (i < json.length()) {
+                char c = json.charAt(i++);
+                if (c == '\\' && i < json.length()) {
+                    sb.append(json.charAt(i++));   // unescape \" and \\
+                } else if (c == '"') {
+                    break;
+                } else {
+                    sb.append(c);
+                }
+            }
+            out.add(sb.toString());
+        }
+        return out;
+    }
+
+    private static String readResource(String resource) {
+        try (InputStream in = 
MetadataKeyValidator.class.getResourceAsStream(resource)) {
+            if (in == null) {
+                throw new IllegalStateException("missing registry resource " + 
resource);
+            }
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+}
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-string-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-string-keys.json
new file mode 100644
index 0000000000..beaa0d0cff
--- /dev/null
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-string-keys.json
@@ -0,0 +1,17 @@
+[
+  {"key":"Content-Disposition","source":"HttpHeaders.CONTENT_DISPOSITION"},
+  {"key":"Content-Encoding","source":"HttpHeaders.CONTENT_ENCODING"},
+  {"key":"Content-Language","source":"HttpHeaders.CONTENT_LANGUAGE"},
+  {"key":"Content-Length","source":"HttpHeaders.CONTENT_LENGTH"},
+  {"key":"Content-Location","source":"HttpHeaders.CONTENT_LOCATION"},
+  {"key":"Content-Type","source":"HttpHeaders.CONTENT_TYPE"},
+  {"key":"Location","source":"HttpHeaders.LOCATION"},
+  {"key":"Message-Bcc","source":"Message.MESSAGE_BCC"},
+  {"key":"Message-Cc","source":"Message.MESSAGE_CC"},
+  {"key":"Message-From","source":"Message.MESSAGE_FROM"},
+  
{"key":"Message-Recipient-Address","source":"Message.MESSAGE_RECIPIENT_ADDRESS"},
+  {"key":"Message-To","source":"Message.MESSAGE_TO"},
+  {"key":"Multipart-Boundary","source":"Message.MULTIPART_BOUNDARY"},
+  {"key":"Multipart-Subtype","source":"Message.MULTIPART_SUBTYPE"},
+  {"key":"tika:chunks","source":"TikaCoreProperties.TIKA_CHUNKS"}
+]
diff --git 
a/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataKeyValidatorTest.java
 
b/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataKeyValidatorTest.java
new file mode 100644
index 0000000000..700b97be14
--- /dev/null
+++ 
b/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataKeyValidatorTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.metadata.schema;
+
+import static 
org.apache.tika.metadata.schema.MetadataKeyValidator.Classification.CLOSED;
+import static 
org.apache.tika.metadata.schema.MetadataKeyValidator.Classification.OPEN;
+import static 
org.apache.tika.metadata.schema.MetadataKeyValidator.Classification.TEMPLATE;
+import static 
org.apache.tika.metadata.schema.MetadataKeyValidator.Classification.UNKNOWN;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class MetadataKeyValidatorTest {
+
+    // Controlled fixture: two closed keys and two open prefixes, so 
classification is unambiguous.
+    private final MetadataKeyValidator v = new MetadataKeyValidator(
+            java.util.Set.of("dc:title", "X-TIKA:Parsed-By"),
+            List.of("html:", "pdf:docinfo:custom:"));
+
+    @Test
+    public void closedKeyIsClosed() {
+        assertEquals(CLOSED, v.classify("dc:title"));
+        assertEquals(CLOSED, v.classify("X-TIKA:Parsed-By"));
+    }
+
+    @Test
+    public void passthroughPrefixIsOpen() {
+        assertEquals(OPEN, v.classify("html:og:image"));
+        assertEquals(OPEN, v.classify("pdf:docinfo:custom:MyField"));
+    }
+
+    @Test
+    public void langAltVariantIsTemplate() {
+        assertEquals(TEMPLATE, v.classify("dc:title:fr"));
+        assertEquals(TEMPLATE, v.classify("dc:title:x-default"));
+        assertEquals(TEMPLATE, v.classify("dc:title:en-US"));
+    }
+
+    @Test
+    public void unregisteredKeyIsUnknown() {
+        assertEquals(UNKNOWN, v.classify("bogus:notakey"));
+        assertEquals(UNKNOWN, v.classify("Content-Type"));      // bare String 
constant, not a Property
+        assertEquals(UNKNOWN, v.classify("dc:title:notalangtag_but_long"));
+    }
+
+    @Test
+    public void edgeCases() {
+        assertEquals(UNKNOWN, v.classify(null));
+        assertEquals(UNKNOWN, v.classify(""));
+        assertEquals(UNKNOWN, v.classify("html:"));             // bare 
prefix, no suffix
+    }
+
+    @Test
+    public void findUnknownFiltersLegit() {
+        List<String> unknown = v.findUnknown(
+                List.of("dc:title", "html:x", "dc:title:fr", "Content-Type", 
"bogus:k"));
+        assertEquals(List.of("Content-Type", "bogus:k"), unknown);
+    }
+
+    @Test
+    public void loadsRealRegistriesAndAgreesWithThem() {
+        MetadataKeyValidator real = MetadataKeyValidator.fromClasspath();
+        // Every real closed key classifies CLOSED; a real passthrough suffix 
classifies OPEN.
+        assertEquals(CLOSED, real.classify("dc:title"));
+        assertEquals(CLOSED, real.classify("X-TIKA:digest:MD5"));   // 
synthesized digest key
+        assertEquals(CLOSED, real.classify("Content-Type"));        // 
bare-String key registry
+        assertEquals(CLOSED, real.classify("Message-From"));
+        assertEquals(OPEN, real.classify("html:twitter:card"));
+        assertTrue(real.isLegitimate("dc:title:fr"));
+        assertFalse(real.isLegitimate("totally:made:up:key"));
+    }
+}
diff --git 
a/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataStringKeysTest.java
 
b/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataStringKeysTest.java
new file mode 100644
index 0000000000..fc84fb33fd
--- /dev/null
+++ 
b/tika-metadata-schema/src/test/java/org/apache/tika/metadata/schema/MetadataStringKeysTest.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.metadata.schema;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Gate for the curated {@code metadata-string-keys.json}: each entry's key 
must equal the live value
+ * of the {@code source} constant it names (e.g. {@code 
HttpHeaders.CONTENT_TYPE}). If a constant is
+ * renamed, retyped, or its value changes, this fails — the hand-maintained 
list can't silently rot.
+ */
+public class MetadataStringKeysTest {
+
+    private static final String RESOURCE = 
"/org/apache/tika/metadata/metadata-string-keys.json";
+    private static final String PKG = "org.apache.tika.metadata.";
+    private static final Pattern ENTRY =
+            Pattern.compile("\\{\"key\":\"(.*?)\",\"source\":\"(.*?)\"}");
+
+    @Test
+    public void everyStringKeyMatchesItsSourceConstant() throws Exception {
+        String json;
+        try (InputStream in = 
MetadataStringKeysTest.class.getResourceAsStream(RESOURCE)) {
+            assertNotNull(in, "committed " + RESOURCE + " is missing");
+            json = new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        Matcher m = ENTRY.matcher(json);
+        int count = 0;
+        while (m.find()) {
+            String key = m.group(1);
+            String source = m.group(2);           // e.g. 
HttpHeaders.CONTENT_TYPE
+            int dot = source.lastIndexOf('.');
+            Class<?> cls = Class.forName(PKG + source.substring(0, dot));
+            Field field = cls.getField(source.substring(dot + 1));
+            assertEquals(String.class, field.getType(),
+                    source + " must be a String constant (a Property belongs 
in metadata-keys.json)");
+            assertEquals(key, field.get(null),
+                    source + " value drifted from metadata-string-keys.json");
+            count++;
+        }
+        assertEquals(15, count, "unexpected metadata-string-keys.json entry 
count");
+    }
+}

Reply via email to