diff --git a/src/main/scala/org/renci/cam/QueryService.scala b/src/main/scala/org/renci/cam/QueryService.scala index 8230ea64..2df6e398 100644 --- a/src/main/scala/org/renci/cam/QueryService.scala +++ b/src/main/scala/org/renci/cam/QueryService.scala @@ -259,40 +259,90 @@ object QueryService extends LazyLogging { * @param submittedQueryGraph * The query graph to search the triplestore with. * @return - * A TRAPIMessage displaying the results. + * A TRAPIResponse to return to the client. */ def run(limit: Int, submittedQueryGraph: TRAPIQueryGraph) - : RIO[ZConfig[AppConfig] with HttpClient with Has[BiolinkData] with Has[SPARQLCache], TRAPIMessage] = - for { - // Get the Biolink data. - biolinkData <- biolinkData - _ = logger.debug("limit: {}", limit) - - // Prepare the query graph for processing. - queryGraph = enforceQueryEdgeTypes(submittedQueryGraph, biolinkData.predicates) - - // Generate the relationsToLabelAndBiolinkPredicate. - allPredicatesInQuery = queryGraph.edges.values.flatMap(_.predicates.getOrElse(Nil)).to(Set) - predicatesToRelations <- mapQueryBiolinkPredicatesToRelations(allPredicatesInQuery) - allRelationsInQuery = predicatesToRelations.values.flatten.to(Set) - relationsToLabelAndBiolinkPredicate <- mapRelationsToLabelAndBiolink(allRelationsInQuery) - - // Generate query solutions. - _ = logger.debug(s"findInitialQuerySolutions($queryGraph, $predicatesToRelations, $limit)") - initialQuerySolutions <- findInitialQuerySolutions(queryGraph, predicatesToRelations, limit) - results = initialQuerySolutions.zipWithIndex.map { case (qs, index) => - Result.fromQuerySolution(qs, index, queryGraph) - } - _ = logger.debug(s"Results: $results") - - // From the results, generate the TRAPI nodes, edges and results. - nodes <- generateTRAPINodes(results) - _ = logger.debug(s"Nodes: $nodes") - edges <- generateTRAPIEdges(results, relationsToLabelAndBiolinkPredicate) - _ = logger.debug(s"Edges: $edges") - trapiResults = generateTRAPIResults(results) - _ = logger.debug(s"Results: $trapiResults") - } yield TRAPIMessage(Some(queryGraph), Some(TRAPIKnowledgeGraph(nodes, edges)), Some(trapiResults.distinct)) + : RIO[ZConfig[AppConfig] with HttpClient with Has[BiolinkData] with Has[SPARQLCache], TRAPIResponse] = { + val emptyTRAPIMessage = TRAPIMessage(Some(submittedQueryGraph), None, Some(List())) + + val allAttributeConstraints = submittedQueryGraph.nodes.values.flatMap( + _.constraints.getOrElse(List())) ++ submittedQueryGraph.edges.values.flatMap(_.attribute_constraints.getOrElse(List())) + val allQualifierConstraints = submittedQueryGraph.edges.values.flatMap(_.qualifier_constraints.getOrElse(List())) + + if (allAttributeConstraints.nonEmpty) { + ZIO.succeed( + TRAPIResponse( + emptyTRAPIMessage, + Some("UnsupportedAttributeConstraint"), + None, + Some( + List( + LogEntry( + Some(java.time.Instant.now().toString), + Some("ERROR"), + Some("UnsupportedAttributeConstraint"), + Some(s"The following attributes are not supported: ${allAttributeConstraints}") + ) + ) + ) + ) + ) + } else if (allQualifierConstraints.nonEmpty) { + // Are there any qualifier constraints? If so, we can't match them, so we should return an empty list of results. + ZIO.succeed( + TRAPIResponse( + emptyTRAPIMessage, + Some("Success"), + None, + Some( + List( + LogEntry( + Some(java.time.Instant.now().toString), + Some("WARNING"), + Some("UnsupportedQualifierConstraint"), + Some(s"The following qualifier constraints are not supported: ${allQualifierConstraints}") + ) + ) + ) + ) + ) + } else + for { + // Get the Biolink data. + biolinkData <- biolinkData + _ = logger.debug("limit: {}", limit) + + // Prepare the query graph for processing. + queryGraph = enforceQueryEdgeTypes(submittedQueryGraph, biolinkData.predicates) + + // Generate the relationsToLabelAndBiolinkPredicate. + allPredicatesInQuery = queryGraph.edges.values.flatMap(_.predicates.getOrElse(Nil)).to(Set) + predicatesToRelations <- mapQueryBiolinkPredicatesToRelations(allPredicatesInQuery) + allRelationsInQuery = predicatesToRelations.values.flatten.to(Set) + relationsToLabelAndBiolinkPredicate <- mapRelationsToLabelAndBiolink(allRelationsInQuery) + + // Generate query solutions. + _ = logger.debug(s"findInitialQuerySolutions($queryGraph, $predicatesToRelations, $limit)") + initialQuerySolutions <- findInitialQuerySolutions(queryGraph, predicatesToRelations, limit) + results = initialQuerySolutions.zipWithIndex.map { case (qs, index) => + Result.fromQuerySolution(qs, index, queryGraph) + } + _ = logger.debug(s"Results: $results") + + // From the results, generate the TRAPI nodes, edges and results. + nodes <- generateTRAPINodes(results) + _ = logger.debug(s"Nodes: $nodes") + edges <- generateTRAPIEdges(results, relationsToLabelAndBiolinkPredicate) + _ = logger.debug(s"Edges: $edges") + trapiResults = generateTRAPIResults(results) + _ = logger.debug(s"Results: $trapiResults") + } yield TRAPIResponse( + TRAPIMessage(Some(queryGraph), Some(TRAPIKnowledgeGraph(nodes, edges)), Some(trapiResults.distinct)), + Some("Success"), + None, + None + ) + } def oldRun(limit: Int, includeExtraEdges: Boolean, submittedQueryGraph: TRAPIQueryGraph) : RIO[ZConfig[AppConfig] with HttpClient with Has[BiolinkData] with Has[SPARQLCache], TRAPIMessage] = diff --git a/src/main/scala/org/renci/cam/Server.scala b/src/main/scala/org/renci/cam/Server.scala index b54c2803..742d4524 100644 --- a/src/main/scala/org/renci/cam/Server.scala +++ b/src/main/scala/org/renci/cam/Server.scala @@ -134,8 +134,8 @@ object Server extends App with LazyLogging { .fromOption(body.message.query_graph) .orElseFail(new InvalidBodyException("A query graph is required, but hasn't been provided.")) limitValue <- ZIO.fromOption(limit).orElse(ZIO.effect(1000)) - message <- QueryService.run(limitValue, queryGraph) - } yield TRAPIResponse(message, Some("Success"), None, None) + response <- QueryService.run(limitValue, queryGraph) + } yield response program.mapError(error => error.getMessage) } .toRoutes diff --git a/src/test/scala/org/renci/cam/test/LimitTest.scala b/src/test/scala/org/renci/cam/test/LimitTest.scala index c5bd6ef6..a24ed763 100644 --- a/src/test/scala/org/renci/cam/test/LimitTest.scala +++ b/src/test/scala/org/renci/cam/test/LimitTest.scala @@ -44,7 +44,8 @@ object LimitTest extends DefaultRunnableSpec with LazyLogging { .map(limit => testM(s"Test query with limit of $limit expecting $queryGraphExpectedResults results") { for { - message <- QueryService.run(limit, testQueryGraph) + response <- QueryService.run(limit, testQueryGraph) + message = response.message _ = logger.info(s"Retrieved ${message.results.get.size} results when limit=$limit") results = message.results.get } yield { diff --git a/src/test/scala/org/renci/cam/test/TRAPITest.scala b/src/test/scala/org/renci/cam/test/TRAPITest.scala new file mode 100644 index 00000000..60812077 --- /dev/null +++ b/src/test/scala/org/renci/cam/test/TRAPITest.scala @@ -0,0 +1,197 @@ +package org.renci.cam.test + +import com.typesafe.scalalogging.LazyLogging +import io.circe._ +import io.circe.generic.auto._ +import io.circe.generic.semiauto._ +import org.apache.jena.query.{Query, QuerySolution} +import org.http4s.headers.{`Content-Type`, Accept} +import org.http4s.{EntityDecoder, MediaType, Method, Request, Uri} +import org.renci.cam.Biolink.biolinkData +import org.renci.cam.HttpClient.HttpClient +import org.renci.cam.Server.EndpointEnv +import org.renci.cam._ +import org.renci.cam.domain.{BiolinkClass, BiolinkPredicate, IRI, LogEntry, TRAPIAttribute, TRAPIResponse} +import zio.cache.Cache +import zio.config.ZConfig +import zio.config.typesafe.TypesafeConfig +import zio.interop.catz.concurrentInstance +import zio.test._ +import zio.{Layer, ZIO, ZLayer} + +object TRAPITest extends DefaultRunnableSpec with LazyLogging { + + /** If CAM-KP-API receives a attribute constraint that it does not support, it MUST respond with an error Code + * "UnsupportedAttributeConstraint". + */ + val testUnsupportedAttributeConstraints: Spec[EndpointEnv, TestFailure[Throwable], TestSuccess] = + suite("testUnsupportedAttributeConstraints") { + // Examples taken from https://github.com/NCATSTranslator/ReasonerAPI/blob/1e0795a1c4ff5bcac3ccd5f188fdc09ec6bd27c3/ImplementationRules.md#specifying-permitted-and-excluded-kps-to-an-ara + val query = """{ + "message": { + "query_graph": { + "nodes": { + "n0": { + "categories": ["biolink:GeneOrGeneProduct"] + }, + "n1": { "categories": ["biolink:AnatomicalEntity"], "ids": ["GO:0005634"] } + }, + "edges": { + "e0": { + "subject": "n0", + "object": "n1", + "predicates": ["biolink:part_of"], + "attribute_constraints": [{ + "id": "biolink:knowledge_source", + "name": "knowledge source", + "value": "infores:semmeddb", + "not": true, + "operator": "==" + }] + } + } + } + } + }""" + + testM("test unsupported attribute constraint") { + for { + biolinkData <- biolinkData + + server <- Server.httpApp + response <- server( + Request( + method = Method.POST, + uri = Uri.unsafeFromString("/query") + ) + .withHeaders(Accept(MediaType.application.json), `Content-Type`(MediaType.application.json)) + .withEntity(query) + ) + content <- EntityDecoder.decodeText(response) + trapiResponseJson <- ZIO.fromEither(io.circe.parser.parse(content)) + + trapiResponse <- ZIO.fromEither( + { + implicit val decoderIRI: Decoder[IRI] = Implicits.iriDecoder(biolinkData.prefixes) + implicit val keyDecoderIRI: KeyDecoder[IRI] = Implicits.iriKeyDecoder(biolinkData.prefixes) + implicit val decoderBiolinkClass: Decoder[BiolinkClass] = Implicits.biolinkClassDecoder(biolinkData.classes) + implicit val decoderBiolinkPredicate: Decoder[BiolinkPredicate] = + Implicits.biolinkPredicateDecoder(biolinkData.predicates) + implicit lazy val decoderTRAPIAttribute: Decoder[TRAPIAttribute] = deriveDecoder[TRAPIAttribute] + + trapiResponseJson.as[TRAPIResponse] + } + ) + + logs = trapiResponse.logs + logsWithUnsupportedAttributeConstraint = logs.getOrElse(List()).filter { + case LogEntry(_, Some("ERROR"), Some("UnsupportedAttributeConstraint"), _) => true + case _ => false + } + } yield assert(response.status)(Assertion.hasField("isSuccess", _.isSuccess, Assertion.isTrue)) && + assert(content)(Assertion.isNonEmptyString) && + // Should return an UnsupportedAttributeConstraint as the status ... + assert(trapiResponse.status)(Assertion.isSome(Assertion.equalTo("UnsupportedAttributeConstraint"))) && + // ... and in the logs. + assert(logs)(Assertion.isSome(Assertion.isNonEmpty)) && + assert(logsWithUnsupportedAttributeConstraint)(Assertion.isNonEmpty) + } + } + + /** If CAM-KP-API receives a qualifier constraint that it does not support, it MUST return an empty response (since no edges meet the + * constraint). + * + * (This is still in development at https://github.com/NCATSTranslator/ReasonerAPI/pull/364) + */ + val testUnsupportedQualifierConstraints = suite("testUnsupportedConstraints") { + // Examples taken from https://github.com/NCATSTranslator/ReasonerAPI/blob/7520ac564e63289dffe092d4c7affd6db4ba22f1/examples/Message/subject_and_object_qualifiers.json + val query = """{ + "message": { + "query_graph": { + "nodes": { + "n0": { + "categories": ["biolink:GeneOrGeneProduct"] + }, + "n1": { "categories": ["biolink:AnatomicalEntity"], "ids": ["GO:0005634"] } + }, + "edges": { + "e0": { + "subject": "n0", + "object": "n1", + "predicates": ["biolink:part_of"], + "qualifier_constraints": [{ + "qualifier_set": [{ + "qualifier_type_id": "biolink:subject_aspect_qualifier", + "qualifier_value": "abundance" + }, { + "qualifier_type_id": "biolink:subject_direction_qualifier", + "qualifier_value": "decreased" + }] + }] + } + } + } + } + }""" + + testM("test unsupported qualifier constraint on QEdge") { + for { + biolinkData <- biolinkData + + server <- Server.httpApp + response <- server( + Request( + method = Method.POST, + uri = Uri.unsafeFromString("/query") + ) + .withHeaders(Accept(MediaType.application.json), `Content-Type`(MediaType.application.json)) + .withEntity(query) + ) + content <- EntityDecoder.decodeText(response) + trapiResponseJson <- ZIO.fromEither(io.circe.parser.parse(content)) + + trapiResponse <- ZIO.fromEither( + { + implicit val decoderIRI: Decoder[IRI] = Implicits.iriDecoder(biolinkData.prefixes) + implicit val keyDecoderIRI: KeyDecoder[IRI] = Implicits.iriKeyDecoder(biolinkData.prefixes) + implicit val decoderBiolinkClass: Decoder[BiolinkClass] = Implicits.biolinkClassDecoder(biolinkData.classes) + implicit val decoderBiolinkPredicate: Decoder[BiolinkPredicate] = + Implicits.biolinkPredicateDecoder(biolinkData.predicates) + implicit lazy val decoderTRAPIAttribute: Decoder[TRAPIAttribute] = deriveDecoder[TRAPIAttribute] + + trapiResponseJson.as[TRAPIResponse] + } + ) + + logs = trapiResponse.logs + logWarningOfQualifierConstraints = logs.getOrElse(List()).filter { + // We've made this up ourselves. + case LogEntry(_, Some("WARNING"), Some("UnsupportedQualifierConstraint"), _) => true + case _ => false + } + } yield assert(response.status)(Assertion.hasField("isSuccess", _.isSuccess, Assertion.isTrue)) && + assert(content)(Assertion.isNonEmptyString) && + // Should return an overall status of Success + assert(trapiResponse.status)(Assertion.isSome(Assertion.equalTo("Success"))) && + // ... and in the logs + assert(logs)(Assertion.isSome(Assertion.isNonEmpty)) && + assert(logWarningOfQualifierConstraints)(Assertion.isNonEmpty) && + // ... and with no results. + assert(trapiResponse.message.results)(Assertion.isSome(Assertion.isEmpty)) + } + } + + val configLayer: Layer[Throwable, ZConfig[AppConfig]] = TypesafeConfig.fromDefaultLoader(AppConfig.config) + + val testLayer: ZLayer[ + Any, + Throwable, + HttpClient with ZConfig[Biolink.BiolinkData] with ZConfig[AppConfig] with ZConfig[Cache[Query, Throwable, List[QuerySolution]]]] = + HttpClient.makeHttpClientLayer ++ Biolink.makeUtilitiesLayer ++ configLayer >+> SPARQLQueryExecutor.makeCache.toLayer + + def spec: Spec[environment.TestEnvironment, TestFailure[Throwable], TestSuccess] = suite("TRAPI tests")( + testUnsupportedAttributeConstraints, + testUnsupportedQualifierConstraints + ).provideCustomLayer(testLayer.mapError(TestFailure.die)) + +}