diff --git a/build.sbt b/build.sbt index 10f08861..fd0ff044 100644 --- a/build.sbt +++ b/build.sbt @@ -104,6 +104,8 @@ lazy val jena = (project in file("jena")) libraryDependencies ++= Seq( "org.apache.jena" % "jena-core" % jenaV, "org.apache.jena" % "jena-arq" % jenaV, + // Integration with Fuseki is optional, so include this dep as "provided" + "org.apache.jena" % "jena-fuseki-main" % jenaV % "provided,test", ), commonSettings, ) @@ -120,6 +122,7 @@ lazy val jenaPlugin = (project in file("jena-plugin")) // Use the "provided" scope to not include the Jena dependencies in the plugin JAR "org.apache.jena" % "jena-core" % jenaV % "provided,test", "org.apache.jena" % "jena-arq" % jenaV % "provided,test", + "org.apache.jena" % "jena-fuseki-main" % jenaV % "provided,test", ), // Do not publish this to Maven – we will separately do sbt assembly and publish to GitHub publishArtifact := false, diff --git a/docs/docs/getting-started-plugins.md b/docs/docs/getting-started-plugins.md index fb0fb777..fb683b2f 100644 --- a/docs/docs/getting-started-plugins.md +++ b/docs/docs/getting-started-plugins.md @@ -22,9 +22,11 @@ You can simply add Jelly format support to [Apache Jena](https://jena.apache.org !!! bug "Content negotiation in Fuseki" - Currently Apache Jena Fuseki will not properly handle content negotiation for the Jelly format, due to the supported content types being hardcoded in Fuseki (see [upstream issue](https://github.com/apache/jena/issues/2700)). + Content negotiation using the `application/x-jelly-rdf` media type in the `Accept` header works in Fuseki "Main" distribution since version 5.2.0. To get it working, you need to run Fuseki with the `--modules=true` command-line option. Content negotiation does not work in the "webapp" distribution with UI due to an [upstream bug](https://github.com/apache/jena/issues/2774). + + In Fuseki 5.1.0 and older, content negotiation with Jelly does not work at all. - Until that is fixed, you can use Jelly with Fuseki endpoints by specifying the `output=application/x-jelly-rdf` parameter (either in the URL or in the URL-encoded form body) when querying the endpoint. + To work around these issues, you can specify the `output=application/x-jelly-rdf` parameter (either in the URL or in the URL-encoded form body) when querying the endpoint. ### Eclipse RDF4J diff --git a/jena/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule b/jena/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule new file mode 100644 index 00000000..af06007d --- /dev/null +++ b/jena/src/main/resources/META-INF/services/org.apache.jena.fuseki.main.sys.FusekiAutoModule @@ -0,0 +1 @@ +eu.ostrzyciel.jelly.convert.jena.fuseki.JellyFusekiModule \ No newline at end of file diff --git a/jena/src/main/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModule.scala b/jena/src/main/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModule.scala new file mode 100644 index 00000000..94c320b9 --- /dev/null +++ b/jena/src/main/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModule.scala @@ -0,0 +1,56 @@ +package eu.ostrzyciel.jelly.convert.jena.fuseki + +import eu.ostrzyciel.jelly.core.Constants +import org.apache.jena.atlas.web.{AcceptList, MediaRange} +import org.apache.jena.fuseki.{DEF, Fuseki} +import org.apache.jena.fuseki.main.FusekiServer +import org.apache.jena.fuseki.main.sys.FusekiAutoModule +import org.apache.jena.rdf.model.Model +import org.apache.jena.riot.WebContent + +import java.util + +object JellyFusekiModule: + val mediaRangeJelly: MediaRange = new MediaRange(Constants.jellyContentType) + +/** + * A Fuseki module that adds Jelly content type to the list of accepted content types. + * + * This allows users to use the Accept header set to application/x-jelly-rdf to request Jelly RDF responses. + * It works for SPARQL CONSTRUCT queries and for the Graph Store Protocol. + * + * More info on Fuseki modules: https://jena.apache.org/documentation/fuseki2/fuseki-modules.html + */ +final class JellyFusekiModule extends FusekiAutoModule: + import JellyFusekiModule.* + + override def name(): String = "Jelly" + + override def start(): Unit = + try { + maybeAddJellyToList(DEF.constructOffer).foreach(offer => DEF.constructOffer = offer) + maybeAddJellyToList(DEF.rdfOffer).foreach(offer => DEF.rdfOffer = offer) + maybeAddJellyToList(DEF.quadsOffer).foreach(offer => { + DEF.quadsOffer = offer + Fuseki.serverLog.info(s"Added ${Constants.jellyContentType} to the list of accepted content types") + }) + } catch { + case e: IllegalAccessError => Fuseki.serverLog.warn( + s"Cannot register the ${Constants.jellyContentType} content type, because you are running an Apache Jena " + + s"Fuseki version that doesn't support content type registration. " + + s"Update to Fuseki 5.2.0 or newer for this to work." + ) + } + + /** + * Adds the Jelly content type to the list of accepted content types if it is not already present. + * @param list current list of accepted content types + * @return none or a new list with Jelly content type + */ + private def maybeAddJellyToList(list: AcceptList): Option[AcceptList] = + if list.entries().contains(mediaRangeJelly) then + None + else + val newList = new util.Vector[MediaRange](list.entries()) + newList.add(mediaRangeJelly) + Some(new AcceptList(newList)) diff --git a/jena/src/test/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModuleSpec.scala b/jena/src/test/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModuleSpec.scala new file mode 100644 index 00000000..3f734109 --- /dev/null +++ b/jena/src/test/scala/eu/ostrzyciel/jelly/convert/jena/fuseki/JellyFusekiModuleSpec.scala @@ -0,0 +1,47 @@ +package eu.ostrzyciel.jelly.convert.jena.fuseki + +import org.apache.jena.fuseki.DEF +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import scala.jdk.CollectionConverters.* + +class JellyFusekiModuleSpec extends AnyWordSpec, Matchers: + "JellyFusekiModule" should { + "have a name" in { + JellyFusekiModule().name() should be ("Jelly") + } + + "use the correct content type for Jelly" in { + JellyFusekiModule.mediaRangeJelly.getContentTypeStr should be ("application/x-jelly-rdf") + } + + "register the Jelly content type in the lists of accepted content types" in { + val oldLists = List(DEF.constructOffer, DEF.rdfOffer, DEF.quadsOffer) + for list <- oldLists do + list.entries().asScala should not contain JellyFusekiModule.mediaRangeJelly + DEF.constructOffer.entries().asScala should not contain JellyFusekiModule.mediaRangeJelly + DEF.rdfOffer.entries().asScala should not contain JellyFusekiModule.mediaRangeJelly + DEF.quadsOffer.entries().asScala should not contain JellyFusekiModule.mediaRangeJelly + + val module = JellyFusekiModule() + module.start() + + val lists = List(DEF.constructOffer, DEF.rdfOffer, DEF.quadsOffer) + for (list, oldList) <- lists.zip(oldLists) do + list.entries().asScala should contain (JellyFusekiModule.mediaRangeJelly) + list.entries().size() should be (oldList.entries().size() + 1) + } + + "not register the Jelly content type if it's already registered" in { + val module = JellyFusekiModule() + module.start() + DEF.rdfOffer.entries().asScala should contain (JellyFusekiModule.mediaRangeJelly) + val size1 = DEF.rdfOffer.entries().size() + + module.start() + DEF.rdfOffer.entries().asScala should contain (JellyFusekiModule.mediaRangeJelly) + val size2 = DEF.rdfOffer.entries().size() + size2 should be (size1) + } + }