HIERARCHICAL_VIEW = new BooleanRioSetting(
"org.eclipse.rdf4j.rio.jsonld.hierarchical_view", "Hierarchical representation of the JSON", Boolean.FALSE);
+ /**
+ * Whitelist of remote/local resources that the JSON-LD parser can retrieve. Set of URIs as strings.
+ *
+ * Default:
+ * {@code Set.of("http://www.w3.org/ns/anno.jsonld", "http://www.w3.org/ns/activitystreams.jsonld", "http://www.w3.org/ns/ldp.jsonld", "http://www.w3.org/ns/oa.jsonld", "http://www.w3.org/ns/hydra/context.jsonld", "http://schema.org/", "https://w3id.org/security/v1", "https://w3c.github.io/json-ld-rc/context.jsonld", "https://www.w3.org/2018/credentials/v1", "https://health-lifesci.schema.org/", "https://auto.schema.org/", "https://bib.schema.org/", "http://xmlns.com/foaf/spec/index.jsonld", "https://pending.schema.org/", "https://schema.org/", "https://schema.org/docs/jsonldcontext.jsonld", "https://schema.org/version/latest/schemaorg-current-https.jsonld", "https://schema.org/version/latest/schemaorg-all-http.jsonld", "https://schema.org/version/latest/schemaorg-all-https.jsonld", "https://schema.org/version/latest/schemaorg-current-http.jsonld", "https://schema.org/version/latest/schemaorg-all.jsonld", "https://schema.org/version/latest/schemaorg-current.jsonld", "https://project-open-data.cio.gov/v1.1/schema/catalog.jsonld", "https://geojson.org/geojson-ld/geojson-context.jsonld", "https://www.w3.org/2019/wot/td/v1");
+ *
+ */
+ public static final RioSetting> WHITELIST = new RioSettingImpl<>(
+ "org.eclipse.rdf4j.rio.jsonld_whitelist",
+ "Whitelist of remote/local resources that the JSON-LD parser can retrieve. Set of URIs as strings.",
+ Set.of(
+ "http://www.w3.org/ns/anno.jsonld",
+ "http://www.w3.org/ns/activitystreams.jsonld",
+ "http://www.w3.org/ns/ldp.jsonld",
+ "http://www.w3.org/ns/oa.jsonld",
+ "http://www.w3.org/ns/hydra/context.jsonld",
+ "http://schema.org/",
+ "https://w3id.org/security/v1",
+ "https://w3c.github.io/json-ld-rc/context.jsonld",
+ "https://www.w3.org/2018/credentials/v1",
+ "https://health-lifesci.schema.org/",
+ "https://auto.schema.org/",
+ "https://bib.schema.org/",
+ "http://xmlns.com/foaf/spec/index.jsonld",
+ "https://pending.schema.org/",
+ "https://schema.org/",
+ "https://schema.org/docs/jsonldcontext.jsonld",
+ "https://schema.org/version/latest/schemaorg-current-https.jsonld",
+ "https://schema.org/version/latest/schemaorg-all-http.jsonld",
+ "https://schema.org/version/latest/schemaorg-all-https.jsonld",
+ "https://schema.org/version/latest/schemaorg-current-http.jsonld",
+ "https://schema.org/version/latest/schemaorg-all.jsonld",
+ "https://schema.org/version/latest/schemaorg-current.jsonld",
+ "https://project-open-data.cio.gov/v1.1/schema/catalog.jsonld",
+ "https://geojson.org/geojson-ld/geojson-context.jsonld",
+ "https://www.w3.org/2019/wot/td/v1"
+ ));
+
+ /**
+ * Secure mode only allows loading remote/local resources (ex. context from url) that are whitelisted.
+ *
+ * Default: true
+ */
+ public static final RioSetting SECURE_MODE = new RioSettingImpl<>(
+ "org.eclipse.rdf4j.rio.jsonld_secure_mode",
+ "Secure mode only allows loading remote/local resources (ex. context from url) that are whitelisted.",
+ Boolean.TRUE);
+
+ /**
+ * The document loader cache is enabled by default. All loaded documents, such as remote contexts, are cached for 1
+ * hour, or until the cache is full. The cache holds up to 1000 documents. The cache is shared between all
+ * JSONLDParsers. The cache can be disabled by setting this value to false.
+ *
+ * Default: true
+ */
+ public static final RioSetting DOCUMENT_LOADER_CACHE = new RioSettingImpl<>(
+ "org.eclipse.rdf4j.rio.jsonld_document_loader_cache",
+ "The document loader cache is enabled by default. All loaded documents, such as remote contexts, are cached for 1 hour, or until the cache is full. The cache holds up to 1000 documents. The cache is shared between all JSONLDParsers. The cache can be disabled by setting this value to false.",
+ Boolean.TRUE);
+
/**
* Private default constructor.
*/
diff --git a/core/rio/jsonld/pom.xml b/core/rio/jsonld/pom.xml
index c1af2971328..307928c78a8 100644
--- a/core/rio/jsonld/pom.xml
+++ b/core/rio/jsonld/pom.xml
@@ -74,6 +74,10 @@
commons-io
commons-io
+
+ com.google.guava
+ guava
+
${project.groupId}
rdf4j-rio-api
diff --git a/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/CachingDocumentLoader.java b/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/CachingDocumentLoader.java
new file mode 100644
index 00000000000..fc7074d12ce
--- /dev/null
+++ b/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/CachingDocumentLoader.java
@@ -0,0 +1,88 @@
+/*******************************************************************************
+ * Copyright (c) 2024 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.rio.jsonld;
+
+import java.net.URI;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.rdf4j.rio.RDFParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+
+import no.hasmac.jsonld.JsonLdError;
+import no.hasmac.jsonld.document.Document;
+import no.hasmac.jsonld.loader.DocumentLoader;
+import no.hasmac.jsonld.loader.DocumentLoaderOptions;
+import no.hasmac.jsonld.loader.SchemeRouter;
+
+public class CachingDocumentLoader implements DocumentLoader {
+ private static final DocumentLoader defaultLoader = SchemeRouter.defaultInstance();
+ private static final Logger logger = LoggerFactory.getLogger(CachingDocumentLoader.class);
+
+ private static final LoadingCache cache = CacheBuilder.newBuilder()
+ .maximumSize(1000) // Maximum 1000 documents in cache
+ .expireAfterWrite(1, TimeUnit.HOURS) // Expire after 1 hour
+ .concurrencyLevel(8) // Optimize for 8 concurrent threads
+ .build(new CacheLoader<>() {
+ @Override
+ public Document load(URI url) throws Exception {
+ return defaultLoader.loadDocument(url, new DocumentLoaderOptions());
+ }
+ });
+
+ private final boolean secureMode;
+ private final Set whitelist;
+ private final boolean documentLoaderCache;
+
+ public CachingDocumentLoader(boolean secureMode, Set whitelist, boolean documentLoaderCache) {
+ this.secureMode = secureMode;
+ this.whitelist = whitelist;
+ this.documentLoaderCache = documentLoaderCache;
+ }
+
+ @Override
+ public Document loadDocument(URI uri, DocumentLoaderOptions options) {
+
+ try {
+ if (!secureMode || whitelist.contains(uri.toString())) {
+ if (documentLoaderCache) {
+ try {
+ return cache.get(uri);
+ } catch (ExecutionException e) {
+ if (e.getCause() != null) {
+ throw new RDFParseException("Could not load document from " + uri, e.getCause());
+ }
+ throw new RDFParseException("Could not load document from " + uri, e);
+ }
+ } else {
+ try {
+ return defaultLoader.loadDocument(uri, options);
+ } catch (JsonLdError e) {
+ throw new RDFParseException("Could not load document from " + uri, e);
+ }
+ }
+ } else {
+ throw new RDFParseException("Could not load document from " + uri
+ + " because it is not whitelisted. See: JSONLDSettings.WHITELIST and JSONLDSettings.SECURE_MODE");
+ }
+ } catch (RDFParseException e) {
+ logger.error(e.getMessage(), e);
+ throw e;
+ }
+ }
+}
diff --git a/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParser.java b/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParser.java
index 4fe15f728a2..0c3561620c4 100644
--- a/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParser.java
+++ b/core/rio/jsonld/src/main/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParser.java
@@ -10,6 +10,10 @@
*******************************************************************************/
package org.eclipse.rdf4j.rio.jsonld;
+import static org.eclipse.rdf4j.rio.helpers.JSONLDSettings.DOCUMENT_LOADER_CACHE;
+import static org.eclipse.rdf4j.rio.helpers.JSONLDSettings.SECURE_MODE;
+import static org.eclipse.rdf4j.rio.helpers.JSONLDSettings.WHITELIST;
+
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
@@ -17,6 +21,7 @@
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.Optional;
+import java.util.Set;
import java.util.function.BiConsumer;
import org.eclipse.rdf4j.model.IRI;
@@ -48,8 +53,6 @@
import no.hasmac.jsonld.document.JsonDocument;
import no.hasmac.jsonld.lang.Keywords;
import no.hasmac.jsonld.loader.DocumentLoader;
-import no.hasmac.jsonld.loader.DocumentLoaderOptions;
-import no.hasmac.jsonld.loader.SchemeRouter;
import no.hasmac.rdf.RdfConsumer;
import no.hasmac.rdf.RdfValueFactory;
@@ -126,12 +129,21 @@ private void parse(InputStream in, Reader reader, String baseURI)
BasicParserSettings.FAIL_ON_UNKNOWN_LANGUAGES);
}
+ boolean secureMode = getParserConfig().get(SECURE_MODE);
+ boolean documentLoaderCache = getParserConfig().get(DOCUMENT_LOADER_CACHE);
+
+ Set whitelist = getParserConfig().get(WHITELIST);
+
JsonLdOptions opts = new JsonLdOptions();
opts.setUriValidation(false);
opts.setExceptionOnWarning(getParserConfig().get(JSONLDSettings.EXCEPTION_ON_WARNING));
Document context = getParserConfig().get(JSONLDSettings.EXPAND_CONTEXT);
+ DocumentLoader defaultDocumentLoader = opts.getDocumentLoader();
+ CachingDocumentLoader cachingDocumentLoader = new CachingDocumentLoader(secureMode, whitelist,
+ documentLoaderCache);
+
if (context != null) {
opts.setExpandContext(context);
@@ -142,22 +154,21 @@ private void parse(InputStream in, Reader reader, String baseURI)
throw new RDFParseException("Expand context is not a valid JSON document");
}
opts.getContextCache().put(context.getDocumentUrl().toString(), jsonContent.get());
- opts.setDocumentLoader(new DocumentLoader() {
-
- private final DocumentLoader defaultDocumentLoader = SchemeRouter.defaultInstance();
-
- @Override
- public Document loadDocument(URI url, DocumentLoaderOptions options) throws JsonLdError {
- if (url.equals(context.getDocumentUrl())) {
- return context;
- }
- return defaultDocumentLoader.loadDocument(url, options);
+ opts.setDocumentLoader((uri, options) -> {
+ if (uri.equals(context.getDocumentUrl())) {
+ return context;
}
+
+ return cachingDocumentLoader.loadDocument(uri, options);
});
}
}
+ if (secureMode && opts.getDocumentLoader() == defaultDocumentLoader) {
+ opts.setDocumentLoader(cachingDocumentLoader);
+ }
+
if (baseURI != null && !baseURI.isEmpty()) {
URI uri = new URI(baseURI);
opts.setBase(uri);
diff --git a/core/rio/jsonld/src/test/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParserCustomTest.java b/core/rio/jsonld/src/test/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParserCustomTest.java
index e8e8e26bd5e..1e61a4423fe 100644
--- a/core/rio/jsonld/src/test/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParserCustomTest.java
+++ b/core/rio/jsonld/src/test/java/org/eclipse/rdf4j/rio/jsonld/JSONLDParserCustomTest.java
@@ -11,12 +11,18 @@
package org.eclipse.rdf4j.rio.jsonld;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+import static org.eclipse.rdf4j.rio.helpers.JSONLDSettings.SECURE_MODE;
+import static org.eclipse.rdf4j.rio.helpers.JSONLDSettings.WHITELIST;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.File;
import java.io.StringReader;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
@@ -24,6 +30,7 @@
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.model.vocabulary.FOAF;
import org.eclipse.rdf4j.model.vocabulary.XSD;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.rio.RDFParseException;
@@ -32,7 +39,9 @@
import org.eclipse.rdf4j.rio.helpers.ContextStatementCollector;
import org.eclipse.rdf4j.rio.helpers.JSONLDSettings;
import org.eclipse.rdf4j.rio.helpers.ParseErrorCollector;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import no.hasmac.jsonld.document.Document;
@@ -228,4 +237,85 @@ public void testContext() throws Exception {
parser.parse(new StringReader(LOADER_JSONLD), "");
assertTrue(model.predicates().contains(testPredicate));
}
+
+ @Test
+ public void testLocalFileSecurity() throws Exception {
+
+ String contextUri = JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/context.jsonld")
+ .toString();
+
+ String jsonld = FileUtils
+ .readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/data.jsonld")
+ .getFile()), StandardCharsets.UTF_8)
+ .replace("file:./context.jsonld", contextUri);
+
+ // expect exception
+ RDFParseException rdfParseException = Assertions.assertThrowsExactly(RDFParseException.class, () -> {
+ parser.parse(new StringReader(jsonld), "");
+ });
+
+ Assertions.assertEquals("Could not load document from " + contextUri
+ + " because it is not whitelisted. See: JSONLDSettings.WHITELIST and JSONLDSettings.SECURE_MODE",
+ rdfParseException.getMessage());
+ }
+
+ @Test
+ public void testLocalFileSecurityWhiteList() throws Exception {
+ String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/data.jsonld")
+ .getFile()), StandardCharsets.UTF_8);
+ String contextUri = JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/context.jsonld")
+ .toString();
+ jsonld = jsonld.replace("file:./context.jsonld", contextUri);
+
+ parser.getParserConfig().set(WHITELIST, Set.of(contextUri));
+
+ parser.parse(new StringReader(jsonld), "");
+ assertTrue(model.objects().contains(FOAF.PERSON));
+ }
+
+ @Test
+ public void testLocalFileSecurityDisableSecurity() throws Exception {
+ String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/data.jsonld")
+ .getFile()), StandardCharsets.UTF_8);
+ jsonld = jsonld.replace("file:./context.jsonld",
+ JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/localFileContext/context.jsonld")
+ .toString());
+
+ parser.getParserConfig().set(SECURE_MODE, false);
+
+ parser.parse(new StringReader(jsonld), "");
+ assertTrue(model.objects().contains(FOAF.PERSON));
+ }
+
+ @RepeatedTest(10)
+ public void testRemoteContext() throws Exception {
+ String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/remoteContext/data.jsonld")
+ .getFile()), StandardCharsets.UTF_8);
+
+ parser.getParserConfig().set(WHITELIST, Set.of("https://schema.org"));
+ parser.parse(new StringReader(jsonld), "");
+ assertEquals(59, model.size());
+ }
+
+ @Test
+ public void testRemoteContextException() throws Exception {
+ String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
+ .getResource("testcases/jsonld/remoteContextException/data.jsonld")
+ .getFile()), StandardCharsets.UTF_8);
+
+ parser.getParserConfig().set(WHITELIST, Set.of("https://example.org/context.jsonld"));
+ RDFParseException rdfParseException = Assertions.assertThrowsExactly(RDFParseException.class, () -> {
+ parser.parse(new StringReader(jsonld), "");
+ });
+
+ assertEquals("Could not load document from https://example.org/context.jsonld", rdfParseException.getMessage());
+ }
+
}
diff --git a/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/context.jsonld b/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/context.jsonld
new file mode 100644
index 00000000000..c2cb49f9339
--- /dev/null
+++ b/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/context.jsonld
@@ -0,0 +1,6 @@
+{
+ "@context":
+ {
+ "Person": "http://xmlns.com/foaf/0.1/Person"
+ }
+}
diff --git a/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/data.jsonld b/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/data.jsonld
new file mode 100644
index 00000000000..daa68e7fc50
--- /dev/null
+++ b/core/rio/jsonld/src/test/resources/testcases/jsonld/localFileContext/data.jsonld
@@ -0,0 +1,9 @@
+{
+ "@context": "file:./context.jsonld",
+
+ "@id":"http://example/peter",
+ "@type":"Person"
+
+
+
+}
diff --git a/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContext/data.jsonld b/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContext/data.jsonld
new file mode 100644
index 00000000000..6b5b124a2ba
--- /dev/null
+++ b/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContext/data.jsonld
@@ -0,0 +1,83 @@
+{
+"@context": "https://schema.org",
+ "@type": ["ItemList", "CreativeWork"],
+ "name": "Top 5 covers of Bob Dylan Songs",
+ "author": "John Doe",
+ "about": {
+ "@type": "MusicRecording",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Bob Dylan"
+ }
+ },
+ "itemListOrder": "https://schema.org/ItemListOrderAscending",
+ "numberOfItems": 5,
+ "itemListElement": [
+ {
+ "@type": "ListItem",
+ "position": 5,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "If Not For You",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "George Harrison"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 4,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "The Times They Are A-Changin'",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Tracy Chapman"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 3,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "It Ain't Me Babe",
+ "byArtist": [
+ {
+ "@type": "MusicGroup",
+ "name": "Johnny Cash"
+ },
+ {
+ "@type": "MusicGroup",
+ "name": "June Carter Cash"
+ }
+ ]
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 2,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "Don't Think Twice It's Alright",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Waylon Jennings"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 1,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "All Along the Watchtower",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Jimi Hendrix"
+ }
+ }
+ }
+ ]
+}
diff --git a/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContextException/data.jsonld b/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContextException/data.jsonld
new file mode 100644
index 00000000000..69ceb5882c6
--- /dev/null
+++ b/core/rio/jsonld/src/test/resources/testcases/jsonld/remoteContextException/data.jsonld
@@ -0,0 +1,83 @@
+{
+"@context": "https://example.org/context.jsonld",
+ "@type": ["ItemList", "CreativeWork"],
+ "name": "Top 5 covers of Bob Dylan Songs",
+ "author": "John Doe",
+ "about": {
+ "@type": "MusicRecording",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Bob Dylan"
+ }
+ },
+ "itemListOrder": "https://schema.org/ItemListOrderAscending",
+ "numberOfItems": 5,
+ "itemListElement": [
+ {
+ "@type": "ListItem",
+ "position": 5,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "If Not For You",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "George Harrison"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 4,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "The Times They Are A-Changin'",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Tracy Chapman"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 3,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "It Ain't Me Babe",
+ "byArtist": [
+ {
+ "@type": "MusicGroup",
+ "name": "Johnny Cash"
+ },
+ {
+ "@type": "MusicGroup",
+ "name": "June Carter Cash"
+ }
+ ]
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 2,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "Don't Think Twice It's Alright",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Waylon Jennings"
+ }
+ }
+ },
+ {
+ "@type": "ListItem",
+ "position": 1,
+ "item": {
+ "@type": "MusicRecording",
+ "name": "All Along the Watchtower",
+ "byArtist": {
+ "@type": "MusicGroup",
+ "name": "Jimi Hendrix"
+ }
+ }
+ }
+ ]
+}