Skip to content

Commit

Permalink
feat: Support JSON-LD for standoff endpoints (DEV-4345) (#3516)
Browse files Browse the repository at this point in the history
  • Loading branch information
seakayone authored Feb 25, 2025
1 parent 98664c7 commit 03d76c6
Show file tree
Hide file tree
Showing 8 changed files with 65 additions and 158 deletions.
111 changes: 1 addition & 110 deletions docs/05-internals/design/api-v2/json-ld.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,116 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
-->

# JSON-LD Parsing and Formatting

## JsonLDUtil

Knora provides a utility object called `JsonLDUtil`, which wraps the
[titanium-json-ld Java library](https://github.com/filip26/titanium-json-ld), and parses JSON-LD text to a
Knora data structure called `JsonLDDocument`. These classes provide commonly needed
functionality for extracting and validating data from JSON-LD documents, as well
as for constructing new documents.

## Parsing JSON-LD

A route that expects a JSON-LD request must first parse the JSON-LD using
`JsonLDUtil` . For example, this is how `ValuesRouteV2` parses a JSON-LD request to create a value:

````scala
post {
entity(as[String]) { jsonRequest =>
requestContext => {
val requestDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(jsonRequest)
````

The result is a `JsonLDDocument` in which all prefixes have been expanded
to full IRIs, with an empty JSON-LD context.

The next step is to convert the `JsonLDDocument` to a request message that can be
sent to the Knora responder that will handle the request.

```scala
val requestMessageFuture: Future[CreateValueRequestV2] = for {
requestingUser <- getUserADM(requestContext)
requestMessage: CreateValueRequestV2 <- CreateValueRequestV2.fromJsonLD(
requestDoc,
apiRequestID = UUID.randomUUID,
requestingUser = requestingUser,
responderManager = responderManager,
storeManager = storeManager,
settings = settings,
log = log
)
} yield requestMessage
```

This is done in a `Future`, because the processing of JSON-LD input
could in itself involve sending messages to responders.

Each request message case class (in this case `CreateValueRequestV2`) has a companion object
that implements the `KnoraJsonLDRequestReaderV2` trait:

```scala
/**
* A trait for objects that can generate case class instances based on JSON-LD input.
*
* @tparam C the type of the case class that can be generated.
*/
trait KnoraJsonLDRequestReaderV2[C] {
/**
* Converts JSON-LD input into a case class instance.
*
* @param jsonLDDocument the JSON-LD input.
* @param apiRequestID the UUID of the API request.
* @param requestingUser the user making the request.
* @param responderManager a reference to the responder manager.
* @param storeManager a reference to the store manager.
* @param settings the application settings.
* @param log a logging adapter.
* @param timeout a timeout for `ask` messages.
* @param executionContext an execution context for futures.
* @return a case class instance representing the input.
*/
def fromJsonLD(jsonLDDocument: JsonLDDocument,
apiRequestID: UUID,
requestingUser: UserADM,
responderManager: ActorRef,
storeManager: ActorRef,
settings: KnoraSettingsImpl,
log: LoggingAdapter)(implicit timeout: Timeout, executionContext: ExecutionContext): Future[C]
}
```

This means that the companion object has a method `fromJsonLD` that takes a
`JsonLDDocument` and returns an instance of the case class. The `fromJsonLD` method
can use the functionality of the `JsonLDDocument` data structure for extracting
and validating the content of the request. For example, `JsonLDObject.requireStringWithValidation`
gets a required member of a JSON-LD object, and validates it using a function
that is passed as an argument. Here is an example of getting and validating
a `SmartIri`:

```scala
for {
valueType: SmartIri <- Future(jsonLDObject.requireStringWithValidation(JsonLDConstants.TYPE, stringFormatter.toSmartIriWithErr))
```

The validation function (in this case `stringFormatter.toSmartIriWithErr`) has to take
two arguments: a string to be validated, and a function that that throws an exception
if the string is invalid. The return value of `requireStringWithValidation` is the
return value of the validation function, which in this case is a `SmartIri`. If
the string is invalid, `requireStringWithValidation` throws `BadRequestException`.

It is also possible to get and validate an optional JSON-LD object member:

```scala
val maybeDateValueHasStartEra: Option[DateEraV2] = jsonLDObject.maybeStringWithValidation(
OntologyConstants.KnoraApiV2Complex.DateValueHasStartEra, DateEraV2.parse
)
```

Here `JsonLDObject.maybeStringWithValidation` returns an `Option` that contains
the return value of the validation function (`DateEraV2.parse`) if it was given,
otherwise `None`.
# JSON-LD

## Returning a JSON-LD Response

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,19 @@ import java.util.UUID

import org.knora.webapi.messages.IriConversions.*
import org.knora.webapi.messages.OntologyConstants
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.util.rdf.JsonLDKeywords
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestMetadataV2
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestV2
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestXMLV2
import org.knora.webapi.sharedtestdata.SharedTestDataADM2.anythingProjectIri
import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri
import org.knora.webapi.slice.admin.domain.model.User

sealed abstract case class DefineStandoffMapping private (
mappingName: String,
projectIRI: String,
label: String,
) {
private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance

/**
* Create a JSON-LD serialization of the request. This can be used for e2e tests.
Expand Down Expand Up @@ -59,7 +58,7 @@ sealed abstract case class DefineStandoffMapping private (
): CreateMappingRequestV2 = {
val mappingMetadata = CreateMappingRequestMetadataV2(
label = label,
projectIri = projectIRI.toSmartIri,
projectIri = ProjectIri.unsafeFrom(projectIRI),
mappingName = mappingName,
)
CreateMappingRequestV2(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import java.util.UUID
import scala.collection.immutable.SortedSet

import dsp.errors.AssertionException
import dsp.valueobjects.Iri
import dsp.valueobjects.UuidUtil
import org.knora.webapi.*
import org.knora.webapi.config.AppConfig
Expand All @@ -24,6 +23,7 @@ import org.knora.webapi.messages.util.rdf.*
import org.knora.webapi.messages.v2.responder.KnoraContentV2
import org.knora.webapi.messages.v2.responder.KnoraJsonLDResponseV2
import org.knora.webapi.messages.v2.responder.ontologymessages.StandoffEntityInfoGetResponseV2
import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri
import org.knora.webapi.slice.admin.domain.model.User

/**
Expand Down Expand Up @@ -54,36 +54,9 @@ case class CreateMappingRequestV2(
* @param projectIri the IRI of the project the mapping belongs to.
* @param mappingName the name of the mapping to be created.
*/
case class CreateMappingRequestMetadataV2(label: String, projectIri: SmartIri, mappingName: String)
case class CreateMappingRequestMetadataV2(label: String, projectIri: ProjectIri, mappingName: String)
extends StandoffResponderRequestV2

object CreateMappingRequestMetadataV2 {

def fromJsonLDSync(jsonLDDocument: JsonLDDocument): CreateMappingRequestMetadataV2 = {

implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance
val validationFun: (String, => Nothing) => String = (s, errorFun) =>
Iri.toSparqlEncodedString(s).getOrElse(errorFun)

val label: String =
jsonLDDocument.body.requireStringWithValidation(OntologyConstants.Rdfs.Label, validationFun)

val projectIri: SmartIri = jsonLDDocument.body.requireIriInObject(
OntologyConstants.KnoraApiV2Complex.AttachedToProject,
stringFormatter.toSmartIriWithErr,
)

val mappingName: String =
jsonLDDocument.body.requireStringWithValidation(OntologyConstants.KnoraApiV2Complex.MappingHasName, validationFun)

CreateMappingRequestMetadataV2(
label = label,
projectIri = projectIri,
mappingName = mappingName,
)
}
}

/**
* Represents the mapping as an XML document.
*
Expand All @@ -98,7 +71,8 @@ case class CreateMappingRequestXMLV2(xml: String) extends StandoffResponderReque
* @param label the label describing the mapping.
* @param projectIri the project the mapping belongs to.
*/
case class CreateMappingResponseV2(mappingIri: IRI, label: String, projectIri: SmartIri) extends KnoraJsonLDResponseV2 {
case class CreateMappingResponseV2(mappingIri: IRI, label: String, projectIri: ProjectIri)
extends KnoraJsonLDResponseV2 {

def toJsonLDDocument(
targetSchema: ApiV2Schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import org.knora.webapi.messages.v2.responder.valuemessages.*
import org.knora.webapi.responders.IriLocker
import org.knora.webapi.responders.Responder
import org.knora.webapi.slice.admin.domain.model.KnoraProject
import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri
import org.knora.webapi.slice.admin.domain.model.User
import org.knora.webapi.slice.admin.domain.service.ProjectService
import org.knora.webapi.slice.infrastructure.CacheManager
Expand Down Expand Up @@ -207,7 +208,7 @@ final case class StandoffResponderV2(
private def createMappingV2(
xml: String,
label: String,
projectIri: SmartIri,
projectIri: ProjectIri,
mappingName: String,
requestingUser: User,
apiRequestID: UUID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ import dsp.errors.BadRequestException
import org.knora.webapi.config.AppConfig
import org.knora.webapi.core.MessageRelay
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.util.rdf.JsonLDUtil
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestMetadataV2
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestV2
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestXMLV2
import org.knora.webapi.routing.RouteUtilV2
import org.knora.webapi.routing.RouteUtilZ
import org.knora.webapi.slice.common.ApiComplexV2JsonLdRequestParser
import org.knora.webapi.slice.resourceinfo.domain.IriConverter
import org.knora.webapi.slice.security.Authenticator

Expand All @@ -33,12 +32,9 @@ import pekko.http.scaladsl.model.Multipart.BodyPart
import pekko.http.scaladsl.server.Directives.*
import pekko.http.scaladsl.server.Route

/**
* Provides a function for API routes that deal with search.
*/
final case class StandoffRouteV2()(
private implicit val runtime: Runtime[
AppConfig & Authenticator & IriConverter & StringFormatter & MessageRelay,
ApiComplexV2JsonLdRequestParser & AppConfig & Authenticator & IriConverter & StringFormatter & MessageRelay,
],
private implicit val system: ActorSystem,
) extends LazyLogging {
Expand Down Expand Up @@ -75,23 +71,25 @@ final case class StandoffRouteV2()(
val requestMessageTask = for {
requestingUser <- ZIO.serviceWithZIO[Authenticator](_.getUserADM(requestContext))
allParts <- ZIO.fromFuture(_ => allPartsFuture)
jsonldDoc <-
metadata <-
ZIO
.fromOption(allParts.get(jsonPartKey))
.orElseFail(
BadRequestException(s"MultiPart POST request was sent without required '$jsonPartKey' part!"),
)
.mapAttempt(JsonLDUtil.parseJsonLD(_))
apiRequestID <- RouteUtilZ.randomUuid()
metadata <-
ZIO.attempt(CreateMappingRequestMetadataV2.fromJsonLDSync(jsonldDoc))
.flatMap(jsonLd =>
ZIO
.serviceWithZIO[ApiComplexV2JsonLdRequestParser](_.createMappingRequestMetadataV2(jsonLd))
.mapError(BadRequestException.apply),
)
xml <-
ZIO
.fromOption(allParts.get(xmlPartKey))
.mapBoth(
_ => BadRequestException(s"MultiPart POST request was sent without required '$xmlPartKey' part!"),
CreateMappingRequestXMLV2.apply,
)
apiRequestID <- RouteUtilZ.randomUuid()
} yield CreateMappingRequestV2(metadata, xml, requestingUser, apiRequestID)
RouteUtilV2.runRdfRouteZ(requestMessageTask, requestContext)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.knora.webapi.slice.common
import org.apache.jena.rdf.model.*
import org.apache.jena.vocabulary.RDF
import org.apache.jena.vocabulary.RDFS
import zio.*
import zio.ZIO
import zio.ZLayer
Expand All @@ -18,6 +19,7 @@ import scala.language.implicitConversions

import org.knora.webapi.IRI
import org.knora.webapi.core.MessageRelay
import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex as KA
import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.*
import org.knora.webapi.messages.OntologyConstants.Rdfs
import org.knora.webapi.messages.SmartIri
Expand All @@ -26,6 +28,7 @@ import org.knora.webapi.messages.v2.responder.resourcemessages.CreateResourceV2
import org.knora.webapi.messages.v2.responder.resourcemessages.CreateValueInNewResourceV2
import org.knora.webapi.messages.v2.responder.resourcemessages.DeleteOrEraseResourceRequestV2
import org.knora.webapi.messages.v2.responder.resourcemessages.UpdateResourceMetadataRequestV2
import org.knora.webapi.messages.v2.responder.standoffmessages.CreateMappingRequestMetadataV2
import org.knora.webapi.messages.v2.responder.valuemessages.*
import org.knora.webapi.messages.v2.responder.valuemessages.ValueContentV2.FileInfo
import org.knora.webapi.slice.admin.api.model.Project
Expand Down Expand Up @@ -435,6 +438,23 @@ final case class ApiComplexV2JsonLdRequestParser(
case UriValue => ZIO.fromEither(UriValueContentV2.from(valueResource))
case unsupported => ZIO.fail(s"Unsupported value type: $unsupported")
} yield content

def createMappingRequestMetadataV2(jsonlLd: String): IO[String, CreateMappingRequestMetadataV2] =
def findSingle[A](m: Model, p: Property, mapper: Resource => Property => Either[String, A]): IO[String, A] = {
m.listSubjectsWithProperty(p).asScala.toList match {
case Nil => ZIO.fail(s"No $p found")
case r :: Nil => ZIO.succeed(r)
case _ => ZIO.fail(s"Multiple $p found")
}
}.map(r => mapper.apply(r)(p)).flatMap(ZIO.fromEither)
ZIO.scoped {
for {
m <- ModelOps.fromJsonLd(jsonlLd).logError
label <- findSingle(m, RDFS.label, _.objectString)
projectIri <- findSingle(m, KA.AttachedToProject, r => r.objectUri(_, ProjectIri.from))
mappingName <- findSingle(m, KA.MappingHasName, _.objectString)
} yield CreateMappingRequestMetadataV2(label, projectIri, mappingName)
}
}

object ApiComplexV2JsonLdRequestParser {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ object ModelOps { self =>
statementOption(s, p).toRight(s"Statement not found '${s.getURI} ${p.getURI} ?o .'")

def singleRootResource: Either[String, Resource] =
val subs = model.listSubjects().asScala.toSet
val objs = model.listObjects().asScala.collect { case r: Resource => r }.toSet
(subs -- objs) match {
val subs = model.listSubjects().asScala.toSet
val objs = model.listObjects().asScala.collect { case r: Resource => r }.toSet
val candidates = subs -- objs
candidates match {
case iris if iris.size == 1 => Right(iris.head)
case iris if iris.isEmpty => Left("No root resource found in model")
case iris => Left(s"Multiple root resources found in model: ${iris.mkString(", ")}")
Expand Down
Loading

0 comments on commit 03d76c6

Please sign in to comment.