Repository: apache/jena (java)

Issue:
Wrong prefix parsing when JSON-LD root is an array
### Version

5.5.0

### What happened?

The following JSON-LD will cause Jena to parse `description` as a prefix to the `A value that ends with a colon:` namespace:

```
[
            {
                "@context": "http://schema.org",
                "@id": "https://data.example.org/dataset/entity",
                "@type": "Thing",
                "description": "A value that ends with a colon:"
            }
]
```

The JSON-LD is valid and correctly expanded by the JSON-LD Plaground, https://json-ld.org/playground/

<img width="600" height="378" alt="Image" src="https://github.com/user-attachments/assets/9fd86cec-709e-4360-aed3-48e02d929e20" />

This is the test to reproduce the issue:

https://github.com/ziodave/jena/blob/main/jena-arq/src/test/java/org/apache/jena/riot/lang/TestPrefixes.java

```
    @Test
    public void prefixes_01() {

        String json = """
                [
                             {
                                 "@context": "http://schema.org",
                                 "@id": "https://data.example.org/dataset/entity",
                                 "@type": "Thing",
                                 "description": "A value that ends with a colon:"
                             }
                 ]
                """;

        Model model = ModelFactory.createDefaultModel();
        StringReader sr = new StringReader(json);
        RDFDataMgr.read(model, sr, null, Lang.JSONLD11);
        Assertions.assertFalse(model.isEmpty());
        Assertions.assertTrue(model.getNsPrefixMap().isEmpty(), () -> {
            StringBuilder sb = new StringBuilder("Found the following namespaces, expecting none:\n");
            model.getNsPrefixMap().forEach((s, n) -> {
                sb.append(s)
                        .append(": ")
                        .append(n)
                        .append("\n");
            });

            return sb.toString();
        });
    }
```

It fails with:

```
Failures: 
TestPrefixes.prefixes_01:50 Found the following namespaces:
description: A value that ends with a colon:
 ==> expected: <true> but was: <false>
```
The expectation is to have an empty prefix map because there are none in the JSON-LD.

This happens because of a bug in the way prefixes are calculated when the JSON-LD starts with an array:

```
    private static void extractPrefixes(JsonValue jsonValue, BiConsumer<String, String> action) {
        if (jsonValue == null)
            return;
        // JSON-LD 1.1 section 9.4
        switch (jsonValue.getValueType()) {
        case ARRAY:
            jsonValue.asJsonArray().forEach(jv -> extractPrefixes(jv, action));
            break;
        case OBJECT:
            extractPrefixesCxtDefn(jsonValue.asJsonObject(), action);
            break;
```

Here it calls `extractPrefixes` directly and the JSON object is passed to the parser as is. However, if the JSON-LD wasn't started with an array, it would have been preprocessed first by the calling function:

```
    private static void extractPrefixes(Document document, BiConsumer<String, String> action) {
        try {
            JsonStructure js = document.getJsonContent().orElseThrow();
            switch (js.getValueType()) {
            case ARRAY:
                extractPrefixes(js, action);
                break;
            case OBJECT:
                JsonValue jv = js.asJsonObject().get(Keywords.CONTEXT);
                extractPrefixes(jv, action);
                break;
            default:
                break;
```

Which extracts the `@context`.

So that a suggested fix is to move `JsonValue jv = js.asJsonObject().get(Keywords.CONTEXT);` from `extractPrefixes(Document document, BiConsumer<String, String> action)` to `extractPrefixes(JsonValue jsonValue, BiConsumer<String, String> action)`.


This only happens with JSON-LD parsing when the value of a string ands with a colon because of the following processing of values in org/apache/jena/riot/lang/LangJSONLD11.java:

```
            if (uri.endsWith("#") || uri.endsWith("/") || uri.endsWith(":")) {
                action.accept(prefix, uri);
                return;
            }
```


### Are you interested in making a pull request?

Maybe

Pull request description:
GH-3335: Prefix determination from @context
GitHub issue resolved #3335


----

 - [x] Tests are included.
 - [x] Commits have been squashed to remove intermediate development commit messages.
 - [x] Key commit messages start with the issue number (GH-xxxx)

By submitting this pull request, I acknowledge that I am making a contribution to the Apache Software Foundation under the terms and conditions of the [Contributor's Agreement](https://www.apache.org/licenses/contributor-agreements.html).


Patch:
diff --git a/jena-arq/src/main/java/org/apache/jena/riot/lang/LangJSONLD11.java b/jena-arq/src/main/java/org/apache/jena/riot/lang/LangJSONLD11.java
index 4a7a1d8227..d960bdb2e7 100644
--- a/jena-arq/src/main/java/org/apache/jena/riot/lang/LangJSONLD11.java
+++ b/jena-arq/src/main/java/org/apache/jena/riot/lang/LangJSONLD11.java
@@ -33,10 +33,7 @@ import com.apicatalog.rdf.RdfDataset;
 import com.apicatalog.rdf.api.RdfConsumerException;
 import com.apicatalog.rdf.api.RdfQuadConsumer;
 
-import jakarta.json.JsonObject;
-import jakarta.json.JsonString;
-import jakarta.json.JsonStructure;
-import jakarta.json.JsonValue;
+import jakarta.json.*;
 import jakarta.json.stream.JsonLocation;
 import org.apache.jena.atlas.logging.Log;
 import org.apache.jena.atlas.web.ContentType;
@@ -44,6 +41,7 @@ import org.apache.jena.datatypes.RDFDatatype;
 import org.apache.jena.datatypes.TypeMapper;
 import org.apache.jena.graph.Node;
 import org.apache.jena.graph.Triple;
+import org.apache.jena.irix.IRIs;
 import org.apache.jena.riot.Lang;
 import org.apache.jena.riot.ReaderRIOT;
 import org.apache.jena.riot.RiotException;
@@ -106,7 +104,6 @@ public class LangJSONLD11 implements ReaderRIOT {
             Document document = JsonDocument.of(in);
             read(document, baseURI, output, context);
         } catch (JsonLdError ex) {
-            ex.printStackTrace();
             handleJsonLdError(ex);
         } catch (Exception ex) {
             errorHandler.error(ex.getMessage(), -1, -1);
@@ -131,7 +128,7 @@ public class LangJSONLD11 implements ReaderRIOT {
      * JSON-LD does not define prefixes.
      * <p>
      * The use of "prefix:localname" happens for any definition of "prefix" in the
-     * {@literal @context} even if intended for a URI e.g a property.
+     * {@literal @context} even if intended for a URI e.g. a property.
      * </p>
      * <p>
      * We could extract any {"key" : "value"} from the context but we add a
@@ -141,54 +138,80 @@ public class LangJSONLD11 implements ReaderRIOT {
      * <p>
      * In addition, {@literal @vocab} becomes prefix "".
      * </p>
+     * <p>
+     * The code assumes the structure is well-formed - it passed JSON-LD parsing by Titanium.
+     * </p>
      */
     private static void extractPrefixes(Document document, BiConsumer<String, String> action) {
         try {
-            JsonStructure js = document.getJsonContent().orElseThrow();
-            switch (js.getValueType()) {
-            case ARRAY:
-                extractPrefixes(js, action);
-                break;
-            case OBJECT:
-                JsonValue jv = js.asJsonObject().get(Keywords.CONTEXT);
-                extractPrefixes(jv, action);
-                break;
-            default:
-                break;
-            }
+
+            JsonStructure jsonStructure = document.getJsonContent().orElse(null);
+            if ( jsonStructure == null )
+                return;
+            extractPrefixesValue(jsonStructure, action);
         } catch (Throwable ex) {
             Log.warn(LangJSONLD11.class, "Unexpected problem while extracting prefixes: " + ex.getMessage(), ex);
         }
     }
 
-    private static void extractPrefixes(JsonValue jsonValue, BiConsumer<String, String> action) {
-        if (jsonValue == null)
-            return;
-        // JSON-LD 1.1 section 9.4
+    /**
+     * <a href="https://www.w3.org/TR/json-ld11/#graph-objects">JSON-LD 1.1 section 9.4 Graph Objects</a>.
+     * Assume the structure is well-fromed - it passed JSON-LD parsing by Titanium.
+     */
+    private static void extractPrefixesValue(JsonValue jsonValue, BiConsumer<String, String> action) {
         switch (jsonValue.getValueType()) {
-        case ARRAY:
-            jsonValue.asJsonArray().forEach(jv -> extractPrefixes(jv, action));
-            break;
-        case OBJECT:
-            extractPrefixesCxtDefn(jsonValue.asJsonObject(), action);
-            break;
-        case NULL:
-            break;      // We are only interested in prefixes
-        case STRING:
-            break;      // We are only interested in prefixes
-        default:
-            break;
+            case ARRAY -> extractPrefixesArray(jsonValue.asJsonArray(), action);
+            case OBJECT -> extractPrefixesObject(jsonValue.asJsonObject(), action);
+            default->{}
+        }
+    }
+
+    private static void extractPrefixesArray(JsonArray jsonArray, BiConsumer<String, String> action) {
+        jsonArray.forEach(jv -> extractPrefixesValue(jv, action));
+    }
+
+    /**
+    * <a href="https://www.w3.org/TR/json-ld11/#graph-objects">JSON-LD 1.1 section 9.4 Graph Objects</a>.
+    */
+    private static void extractPrefixesObject(JsonObject jsonObject, BiConsumer<String, String> action) {
+        JsonValue contextValue = jsonObject.get(Keywords.CONTEXT);
+        if ( contextValue == null )
+            return;
+        // If the graph object contains the @context key, its value MUST be null, an IRI reference, a context definition, or an array composed of any of these.
+        switch (contextValue.getValueType()) {
+            // Assuming the contextValue is valid (Titanium parsed it).
+            case ARRAY -> extractPrefixesContextArray(contextValue.asJsonArray(), action);
+            case OBJECT -> extractPrefixesContextDefinition(contextValue.asJsonObject(), action);
+            // URI or null.
+            default -> {}
         }
     }
 
-    private static void extractPrefixesCxtDefn(JsonObject jCxt, BiConsumer<String, String> action) {
+    // @context [ ]
+    private static void extractPrefixesContextArray(JsonArray jsonArray, BiConsumer<String, String> action) {
+        jsonArray.forEach(cxtArrayEntry -> {
+            switch (cxtArrayEntry.getValueType()) {
+                case OBJECT -> extractPrefixesContextDefinition(cxtArrayEntry.asJsonObject(), action);
+                // URI or null.
+                default -> {}
+            }
+        });
+    }
+
+    /**
+     * Extract prefixes from a context definition.
+     * <p>
+     * "A context definition defines a local context in a node object."
+     */
+    private static void extractPrefixesContextDefinition(JsonObject jCxt, BiConsumer<String, String> action) {
+        // Assume the local context is valid.
         Set<String> keys = jCxt.keySet();
         keys.stream().forEach(k -> {
             // "@vocab" : "uri"
             // "shortName" : "uri"
             // "shortName" : { "@type":"@id" , "@id": "uri" } -- presumed to be a single property aliases, not a prefix.
-            JsonValue jvx = jCxt.get(k);
-            if (JsonValue.ValueType.STRING != jvx.getValueType())
+            JsonValue jValue = jCxt.get(k);
+            if (JsonValue.ValueType.STRING != jValue.getValueType())
                 return;
             String prefix = k;
             if (Keywords.VOCAB.equals(k))
@@ -197,9 +220,10 @@ public class LangJSONLD11 implements ReaderRIOT {
                 // Keyword, not @vocab.
                 return;
             // Pragmatic filter: URI ends in "#" or "/" or ":"
-            String uri = JsonString.class.cast(jvx).getString();
+            String uri = JsonString.class.cast(jValue).getString();
             if (uri.endsWith("#") || uri.endsWith("/") || uri.endsWith(":")) {
-                action.accept(prefix, uri);
+                if ( IRIs.check(uri) )
+                    action.accept(prefix, uri);
                 return;
             }
         });
diff --git a/jena-core/src/main/java/org/apache/jena/irix/IRIs.java b/jena-core/src/main/java/org/apache/jena/irix/IRIs.java
index a603f38cd4..4c4a5e057f 100644
--- a/jena-core/src/main/java/org/apache/jena/irix/IRIs.java
+++ b/jena-core/src/main/java/org/apache/jena/irix/IRIs.java
@@ -44,7 +44,7 @@ public class IRIs {
         return iri;
     }
 
-    /** Check a string is valid syntax for an IRI (absolute or relative) */
+    /** Check a string is valid syntax for an IRI and is valid for use in RDF. */
     static public boolean check(String iriStr) {
         Objects.requireNonNull(iriStr);
         try {
diff --git a/jena-core/src/main/java/org/apache/jena/irix/IRIx.java b/jena-core/src/main/java/org/apache/jena/irix/IRIx.java
index 36ae005e38..effbcb2010 100644
--- a/jena-core/src/main/java/org/apache/jena/irix/IRIx.java
+++ b/jena-core/src/main/java/org/apache/jena/irix/IRIx.java
@@ -128,7 +128,7 @@ public abstract class IRIx {
      * <a href="https://tools.ietf.org/html/rfc2396">RFC 2396</a>
      * where an absolute URI means "has scheme".
      * <p>
-     * In RDF data it is a
+     * In RDF data, it is a
      * <a href="https://www.w3.org/TR/rdf11-concepts/#section-IRIs">useful concept</a>.
      * It is either an absolute URI, but if it is hierarchical, it must have a host.
      * <p>
diff --git a/jena-fuseki2/jena-fuseki-ui/package.json b/jena-fuseki2/jena-fuseki-ui/package.json
index 6424c81ef5..82b29d5d8e 100644
--- a/jena-fuseki2/jena-fuseki-ui/package.json
+++ b/jena-fuseki2/jena-fuseki-ui/package.json
@@ -22,7 +22,7 @@
   },
   "dependencies": {
     "@fortawesome/fontawesome-svg-core": "^6.4.2",
-    "@fortawesome/free-solid-svg-icons": "^6.4.2",
+    "@fortawesome/free-solid-svg-icons": "^7.0.0",
     "@fortawesome/vue-fontawesome": "^3.0.3",
     "@popperjs/core": "^2.11.8",
     "@vue/compat": "^3.5.16",
diff --git a/jena-fuseki2/jena-fuseki-ui/yarn.lock b/jena-fuseki2/jena-fuseki-ui/yarn.lock
index aca47f8704..1eb058588b 100644
--- a/jena-fuseki2/jena-fuseki-ui/yarn.lock
+++ b/jena-fuseki2/jena-fuseki-ui/yarn.lock
@@ -1134,6 +1134,11 @@
   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz#7123d74b0c1e726794aed1184795dbce12186470"
   integrity sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==
 
+"@fortawesome/fontawesome-common-types@7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.0.0.tgz#1a03eb151263bffcc9c4d0b0b0ffef04496e4382"
+  integrity sha512-PGMrIYXLGA5K8RWy8zwBkd4vFi4z7ubxtet6Yn13Plf6krRTwPbdlCwlcfmoX0R7B4Z643QvrtHmdQ5fNtfFCg==
+
 "@fortawesome/fontawesome-common-types@^0.2.36":
   version "0.2.36"
   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903"
@@ -1153,17 +1158,17 @@
   dependencies:
     "@fortawesome/fontawesome-common-types" "^0.2.36"
 
-"@fortawesome/free-solid-svg-icons@^6.4.2":
-  version "6.7.2"
-  resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz#fe25883b5eb8464a82918599950d283c465b57f6"
-  integrity sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==
+"@fortawesome/free-solid-svg-icons@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.0.0.tgz#26362f9f97e33fc3739752af584a015d8a365850"
+  integrity sha512-njSLAllkOddYDCXgTFboXn54Oe5FcvpkWq+FoetOHR64PbN0608kM02Lze0xtISGpXgP+i26VyXRQA0Irh3Obw==
   dependencies:
-    "@fortawesome/fontawesome-common-types" "6.7.2"
+    "@fortawesome/fontawesome-common-types" "7.0.0"
 
 "@fortawesome/vue-fontawesome@^3.0.3":
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz#1e8032df151173d8174ac9f5a28da3c0f5a495e4"
-  integrity sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.0.tgz#e20f931c83c9d27d84adf23dcb3ffd7a62e6599b"
+  integrity sha512-0YW5ZJ68p90R2T5UHaoojBbh7S33SXUb584yKcBgdq+zs2or3OtLQzCEhozDFhYHcg2UF+ah3GZa5Xp4xaU7/A==
 
 "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0":
   version "9.3.0"


Test patch:
diff --git a/jena-arq/src/test/java/org/apache/jena/riot/lang/TS_Lang.java b/jena-arq/src/test/java/org/apache/jena/riot/lang/TS_Lang.java
index e54203178a..17f22e283b 100644
--- a/jena-arq/src/test/java/org/apache/jena/riot/lang/TS_Lang.java
+++ b/jena-arq/src/test/java/org/apache/jena/riot/lang/TS_Lang.java
@@ -43,6 +43,7 @@ import org.apache.jena.riot.lang.rdfxml.TC_RIOT_RDFXML;
 
     , TestLangJsonLD.class
     , TestLangJsonLD_DocLoader.class
+    , TestLangJsonLD_prefixes.class
 
     , TestRDFXML_ReaderProperties.class
     , TestTriXReader.class
@@ -51,6 +52,7 @@ import org.apache.jena.riot.lang.rdfxml.TC_RIOT_RDFXML;
     // Thrift is done in the "thrift" package
     , TestParserFactory.class
     , TestCollectorStream.class
+
     , TC_RIOT_RDFXML.class
 })
 
diff --git a/jena-arq/src/test/java/org/apache/jena/riot/lang/TestLangJsonLD_prefixes.java b/jena-arq/src/test/java/org/apache/jena/riot/lang/TestLangJsonLD_prefixes.java
new file mode 100644
index 0000000000..666aea89cb
--- /dev/null
+++ b/jena-arq/src/test/java/org/apache/jena/riot/lang/TestLangJsonLD_prefixes.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.jena.riot.lang;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.StringReader;
+
+import com.apicatalog.jsonld.JsonLdError;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFDataMgr;
+import org.apache.jena.riot.RiotException;
+
+public class TestLangJsonLD_prefixes {
+    // GH-3335
+    // https://github.com/apache/jena/issues/3335
+    @Test
+    public void prefixes_array_object() {
+        String jsonStr = """
+                [ { "@context": "http://schema.org",
+                    "@id": "https://data.example.org/dataset/entity",
+                    "@type": "Thing",
+                    "description": "A value that ends with a colon:"
+                   } ]
+                """;
+        parseTestPrefixes(jsonStr);
+    }
+
+    @Test
+    public void prefixes_bad_uri() {
+        // Bad prefix in the context - Titanium catches it.
+        String jsonStr = """
+                  { "@context": { "ns2": "A string that ends with a colon:" } ,
+                    "@id": "https://data.example.org/dataset/entity",
+                    "ns2:abc": "ABC"
+                 }
+                """;
+        RiotException ex = assertThrows(RiotException.class, ()->parseTestPrefixes(jsonStr));
+        assertTrue(ex.getCause() instanceof JsonLdError);
+    }
+
+    @Test
+    public void prefixes_array_context() {
+        String jsonStr = """
+                  { "@context": [ { "ns2": "urn:jena:" } , null ] ,
+                    "ns2:abc": "NS"
+                  }
+                """;
+        parseTestPrefixes(jsonStr, "ns2");
+    }
+
+    @Test
+    public void prefixes_array_context_2() {
+        String jsonStr = """
+          {
+            "@context": [ { "ns1": "urn:jena:ns1#" } , { "ns2": "urn:jena:ns2#" } ] ,
+            "ns1:abc": "abc1",
+            "ns2:abc": "abc2"
+          }
+          """;
+        parseTestPrefixes(jsonStr, "ns1", "ns2");
+    }
+
+    private void parseTestPrefixes(String json, String... namespaces) {
+        Model model = ModelFactory.createDefaultModel();
+        StringReader sr = new StringReader(json);
+        RDFDataMgr.read(model, sr, null, Lang.JSONLD11);
+        assertEquals(namespaces.length, model.getNsPrefixMap().size());
+        for (String ns : namespaces ) {
+            assertNotNull(model.getNsPrefixURI(ns), ()->"Failed to find prefix '"+ns+"'");
+        }
+    }
+}