diff --git a/compliance/rio/src/test/java/org/eclipse/rdf4j/rio/n3/N3ParserTest.java b/compliance/rio/src/test/java/org/eclipse/rdf4j/rio/n3/N3ParserTest.java deleted file mode 100644 index 42be7fd908a..00000000000 --- a/compliance/rio/src/test/java/org/eclipse/rdf4j/rio/n3/N3ParserTest.java +++ /dev/null @@ -1,35 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 Eclipse RDF4J contributors, Aduna, and others. - * - * 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.n3; - -import org.eclipse.rdf4j.rio.RDFParser; -import org.eclipse.rdf4j.rio.turtle.TurtleParser; -import org.eclipse.rdf4j.testsuite.rio.n3.N3ParserTestCase; -import org.junit.jupiter.api.Disabled; - -import junit.framework.Test; - -/** - * JUnit test for the N3 parser that uses the tests that are available - * online. - */ -@Disabled("FIXME: This test is badly broken") -public class N3ParserTest extends N3ParserTestCase { - - public static Test suite() throws Exception { - return new N3ParserTest().createTestSuite(); - } - - @Override - protected RDFParser createRDFParser() { - return new TurtleParser(); - } -} diff --git a/core/rio/api/src/main/java/org/eclipse/rdf4j/rio/helpers/JSONLDSettings.java b/core/rio/api/src/main/java/org/eclipse/rdf4j/rio/helpers/JSONLDSettings.java index 1b1dde8d6db..fc4d5592ba3 100644 --- a/core/rio/api/src/main/java/org/eclipse/rdf4j/rio/helpers/JSONLDSettings.java +++ b/core/rio/api/src/main/java/org/eclipse/rdf4j/rio/helpers/JSONLDSettings.java @@ -10,6 +10,9 @@ *******************************************************************************/ package org.eclipse.rdf4j.rio.helpers; +import java.util.List; +import java.util.Set; + import org.eclipse.rdf4j.rio.RioSetting; import com.github.jsonldjava.core.DocumentLoader; @@ -153,6 +156,66 @@ public class JSONLDSettings { public static final RioSetting 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" + } + } + } + ] +}