Skip to content

Commit

Permalink
✨ feat: Now we can integrate multiple remote FHIR terminology service…
Browse files Browse the repository at this point in the history
…s for validating agains code-binding restrictions
  • Loading branch information
Tuncay Namli committed Aug 13, 2024
1 parent b0c9322 commit 91a7231
Show file tree
Hide file tree
Showing 14 changed files with 272 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package io.onfhir.client
import io.onfhir.api.Resource
import io.onfhir.api.client.{FhirClientException, IOnFhirClient}
import io.onfhir.api.service.IFhirTerminologyService
import io.onfhir.client.TerminologyServiceClient.{EXPAND_OPERATION_NAME, EXPAND_OPERATION_REQUEST_PARAMS, LOOKUP_OPERATION_NAME, LOOKUP_OPERATION_REQUEST_PARAMS, TRANSLATE_OPERATION_NAME, TRANSLATE_OPERATION_REQUEST_PARAMS}
import io.onfhir.client.TerminologyServiceClient.{EXPAND_OPERATION_NAME, EXPAND_OPERATION_REQUEST_PARAMS, LOOKUP_OPERATION_NAME, LOOKUP_OPERATION_REQUEST_PARAMS, TRANSLATE_OPERATION_NAME, TRANSLATE_OPERATION_REQUEST_PARAMS, VALIDATE_CODE_OPERATION_NAME, VALIDATE_CODE_REQUEST_PARAMS}
import org.json4s.JObject
import org.slf4j.LoggerFactory

Expand Down Expand Up @@ -346,12 +346,41 @@ class TerminologyServiceClient(onFhirClient: IOnFhirClient)(implicit ec: Executi
request
.executeAndReturnResource()
}

/**
* Validate that a coded value is in the set of codes allowed by a value set.
*
* @param url Value set Canonical URL.
* @param valueSetVersion The identifier that is used to identify a specific version of the value set to be used when validating the code
* @param code The code that is to be validated.
* @param system The system for the code that is to be validated
* @param systemVersion The version of the system, if one was provided in the source data
* @param display The display associated with the code to validate.
* @return
*/
override def validateCode(url: String, valueSetVersion: Option[String], code: String, system: Option[String], systemVersion: Option[String], display: Option[String]): Future[JObject] = {
var request =
onFhirClient
.operation(VALIDATE_CODE_OPERATION_NAME)
.on("ValueSet")
.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.URL, url)
.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.CODE, code)
//Optional params
valueSetVersion.foreach(v => request = request.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.VALUE_SET_VERSION, v))
system.foreach(s => request = request.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.SYSTEM, s))
systemVersion.foreach(v => request = request.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.SYSTEM_VERSION, v))
display.foreach(d => request = request.addSimpleParam(VALIDATE_CODE_REQUEST_PARAMS.DISPLAY, d))

request
.executeAndReturnResource()
}
}

object TerminologyServiceClient {
final val TRANSLATE_OPERATION_NAME = "translate"
final val LOOKUP_OPERATION_NAME = "lookup"
final val EXPAND_OPERATION_NAME = "expand"
final val VALIDATE_CODE_OPERATION_NAME = "validate-code"

final object TRANSLATE_OPERATION_REQUEST_PARAMS {
val CONCEPT_MAP_URL = "url"
Expand Down Expand Up @@ -383,4 +412,13 @@ object TerminologyServiceClient {
val OFFSET = "offset"
val COUNT = "count"
}

final object VALIDATE_CODE_REQUEST_PARAMS {
final val URL = "url"
final val VALUE_SET_VERSION = "valueSetVersion"
final val CODE = "code"
final val SYSTEM = "system"
final val SYSTEM_VERSION = "systemVersion"
final val DISPLAY = "display"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class TerminologyServiceClientTest extends Specification {
val result = Await.result(terminologyServiceClient.translate(coding, "https://www.ncbi.nlm.nih.gov/clinvar"), Duration.Inf)
FHIRUtil.getParameterValueByName(result, "result") shouldEqual Some(JBool(true))
}

"handle validate-code operation with given code and system" in {
val result = Await.result(terminologyServiceClient.validateCode("http://loinc.org/vs", code= "2339-0", system=Some("http://loinc.org")), Duration.Inf)
FHIRUtil.getParameterValueByName(result, "result") shouldEqual Some(JBool(true))

val result2 = Await.result(terminologyServiceClient.validateCode("http://loinc.org/vs", code = "23", system = Some("http://loinc.org")), Duration.Inf)
FHIRUtil.getParameterValueByName(result2, "result") shouldEqual Some(JBool(false))
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import scala.concurrent.duration.Duration
/**
* Interface for FHIR terminology service
*/
trait IFhirTerminologyService extends IFhirTerminologyTranslationService with IFhirTerminologyLookupService with IFhirTerminologyExpandService with Serializable{
trait IFhirTerminologyService extends IFhirTerminologyTranslationService with IFhirTerminologyLookupService with IFhirTerminologyExpandService with IFhirTerminologyValidateService with Serializable{
/**
* Return timeout specified for the terminology service calls
* @return
Expand Down Expand Up @@ -189,3 +189,17 @@ trait IFhirTerminologyExpandService {
*/
def expandWithValueSet(valueSet:Resource, offset: Option[Long] = None, count: Option[Long] = None):Future[JObject]
}

trait IFhirTerminologyValidateService {
/**
* Validate that a coded value is in the set of codes allowed by a value set.
* @param url Value set Canonical URL.
* @param valueSetVersion The identifier that is used to identify a specific version of the value set to be used when validating the code
* @param code The code that is to be validated.
* @param system The system for the code that is to be validated
* @param systemVersion The version of the system, if one was provided in the source data
* @param display The display associated with the code to validate.
* @return
*/
def validateCode(url:String, valueSetVersion:Option[String] = None, code:String, system:Option[String], systemVersion:Option[String] = None, display:Option[String] = None):Future[JObject]
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import scala.concurrent.Future
abstract class AbstractFhirContentValidator(
val fhirConfig:BaseFhirConfig,
val profileUrl:String,
val referenceResolver:Option[IReferenceResolver] = None) {
val referenceResolver:Option[IReferenceResolver] = None,
val terminologyValidator: IFhirTerminologyValidator
) {
//Chain of profiles for this profile, where parents are on the right in hierarchy order e.g. MyObservation2 -> MyObservation -> Observation -> DomainResource -> Resource
val rootProfileChain: Seq[ProfileRestrictions] = fhirConfig.findProfileChain(profileUrl)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ trait IFhirResourceValidator {
*/
def validateResourceAgainstProfile(resource: Resource, rtype:String, profile:Option[String], parentPath:Option[String] = None, bundle:Option[(Option[String],Resource)] = None, silent:Boolean = false): Future[Seq[OutcomeIssue]]

/**
* Return terminology validator
* @return
*/
def getTerminologyValidator():Option[IFhirTerminologyValidator]
}
42 changes: 41 additions & 1 deletion onfhir-common/src/main/scala/io/onfhir/config/OnfhirConfig.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package io.onfhir.config

import java.time.Duration
import com.typesafe.config.ConfigFactory
import com.typesafe.config.{Config, ConfigFactory}
import io.onfhir.api.util.FHIRUtil
import io.onfhir.api.{DEFAULT_FHIR_VERSION, FHIR_HTTP_OPTIONS, FHIR_VALIDATION_ALTERNATIVES}

import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration
import scala.jdk.DurationConverters._
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}

Expand Down Expand Up @@ -194,4 +199,39 @@ object OnfhirConfig {
//If upsert is true, we use Mongo upsert (replace the current version of resource or create) for FHIR bulk import operations. IMPORTANT This is not a version aware interaction.
//Otherwise normal FHIR batch operation is used for grouped resources
lazy val bulkUpsertMode:Boolean = Try(config.getBoolean("fhir.bulk.upsert")).toOption.getOrElse(false)

/**
* Configurations for integrated terminology services
*/
lazy val integratedTerminologyServices:Option[Seq[(TerminologyServiceConf, Config)]] =
Try(config.getObject("fhir.integrated-terminology-services").asScala)
.toOption
.map(cnf =>
cnf
.map(entry => {
val sname = entry._1

val timeout =
Try(config.getDuration(s"fhir.integrated-terminology-services.$sname.timeout"))
.toOption.map(_.toScala)
.getOrElse(FiniteDuration.apply(1, TimeUnit.SECONDS))
val supportedValueSets =
config.getStringList(s"fhir.integrated-terminology-services.$sname.value-sets")
.asScala
.map(vs => FHIRUtil.parseCanonicalValue(vs))
.groupBy(_._1)
.map(g => g._1 -> (g._2.flatMap(_._2).toSeq match {
case Nil => None
case oth => Some(oth.toSet)
}))

TerminologyServiceConf(
sname,
timeout,
supportedValueSets
) ->
config.getConfig(s"fhir.integrated-terminology-services.$sname")
})
.toSeq
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.onfhir.config

import scala.concurrent.duration.Duration

/**
* Metadata for a terminology service
* TODO Extend the definition
*
* @param name Name of the service
* @param supportedValueSets Supported ValueSet urls and versions if supplied
*/
case class TerminologyServiceConf(
name:String,
timeout: Duration,
supportedValueSets:Map[String, Option[Set[String]]]
)
5 changes: 5 additions & 0 deletions onfhir-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@
<groupId>io.onfhir</groupId>
<artifactId>onfhir-path_${scala.binary.version}</artifactId>
</dependency>
<dependency>
<groupId>io.onfhir</groupId>
<artifactId>onfhir-client_${scala.binary.version}</artifactId>
</dependency>

<!--Configurations-->
<dependency>
<groupId>com.typesafe</groupId>
Expand Down
32 changes: 32 additions & 0 deletions onfhir-core/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,39 @@ fhir {
# Otherwise normal FHIR batch operation is used for grouped resources
upsert = true
}
# Integrated FHIR Terminology Services to use for code-binding validations
integrated-terminology-services {
# A name assigned to this terminology service (only used in logs)
# <terminology-service-name> {
# ValueSets supported by this terminology service (ValueSet.url and optional ValueSet.version in format <ValueSet.url>|<ValueSet.version>)
# You can also use ValueSet url prefixes to indicate that any url starting with given prefix is supported by using *
# e.g. http://loinc.org/vs, http://loinc.org/vs|2.78, http://example.com/fhir/ValueSet/*
#value-sets = ["http://loinc.org/vs"]
# Base URL for terminology service
#serverBaseUrl = "https://fhir.loinc.org"
# Authentication details for the terminology service
#authz {
#Method for authorization (basic | oauth2) if you are constructing client from config
# - basic: HTTP basic authentication where username, password should be supplied
# - ouath2: Bearer token based authentication where token is obtained from a OAuth2.0 token endpoint
#method = ""

# If you will use BearerTokenInterceptorFromTokenEndoint, uncomment the following properties;
# Assigned client identifier by the authorization server for your client
#client_id = ""
# Client secret given by the authorization server for your client
#client_secret=""
# URL of the OAuth2.0 Token endpoint of the authorization server
#token_endpoint=""
# Client authentication method; use either 'client_secret_basic', 'client_secret_post' or 'client_secret_jwt'
#token_endpoint_auth_method =""

#If you will use BasicAuthentication, uncomment the following
#username = ""
#password = ""
#}
#}
}
}

# Configurations related with Akka.io platform (Do not change this unless you are a experienced user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,12 @@ class FHIRResourceValidator(fhirConfigurationManager: IFhirConfigurationManager)
})
}
}

/**
* Return terminology validator
*
* @return
*/
override def getTerminologyValidator(): Option[IFhirTerminologyValidator] = Option(fhirConfigurationManager.fhirTerminologyValidator)
}

Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package io.onfhir.config

import io.onfhir.Onfhir
import io.onfhir.api.DEFAULT_IMPLEMENTED_FHIR_OPERATIONS
import io.onfhir.api.parsers.{FHIRResultParameterResolver, FHIRSearchParameterValueParser}
import io.onfhir.api.util.FHIRServerUtil
import io.onfhir.api.validation.{FHIRResourceValidator, IFhirResourceValidator, IFhirTerminologyValidator}
import io.onfhir.audit.IFhirAuditCreator
import io.onfhir.authz.AuthzManager
import io.onfhir.client.{OnFhirNetworkClient, TerminologyServiceClient}
import io.onfhir.db.{MongoDBInitializer, ResourceManager}
import io.onfhir.event.{FhirEventBus, IFhirEventBus}
import io.onfhir.validation.FhirTerminologyValidator
Expand Down Expand Up @@ -65,7 +67,15 @@ object FhirConfigurationManager extends IFhirConfigurationManager {
}
//Initialize FHIR Resource and terminology Validator
fhirValidator = new FHIRResourceValidator(this)
fhirTerminologyValidator = new FhirTerminologyValidator(fhirConfig)

//Integrated terminology services
val integratedTerminologyServices =
OnfhirConfig
.integratedTerminologyServices
.map(_.map(tsConf => tsConf._1 -> new TerminologyServiceClient(OnFhirNetworkClient.apply(tsConf._2)(Onfhir.actorSystem))(Onfhir.actorSystem.dispatcher)))
.getOrElse(Nil)

fhirTerminologyValidator = new FhirTerminologyValidator(fhirConfig, integratedTerminologyServices)
//Initialize FHIR Audit creator if necessary
if (OnfhirConfig.fhirAuditingRepository.equalsIgnoreCase("local") || OnfhirConfig.fhirAuditingRepository.equalsIgnoreCase("remote"))
fhirAuditCreator = fhirConfigurator.getAuditCreator()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.json4s.JsonAST.{JArray, JObject, JString, JValue}
* Value Set binding if strength is given as 'required', 'extensible', 'preferred'
* @param valueSetUrl URL of ValueSet where codes are expected
* @param version Business Version of ValueSet where codes are expected
* @param isRequired If binding strength is required
* @param strength Binding strength (required | preferred | ..)
*/
case class CodeBindingRestriction(valueSetUrl:String, version:Option[String], strength:String) extends FhirRestriction {

Expand All @@ -21,12 +21,12 @@ case class CodeBindingRestriction(valueSetUrl:String, version:Option[String], st
* @return
*/
override def evaluate(value: JValue, fhirContentValidator: AbstractFhirContentValidator): Seq[ConstraintFailure] = {
val terminologyValidator = FhirTerminologyValidator(fhirContentValidator.fhirConfig)
if(terminologyValidator.isValueSetSupported(valueSetUrl, version)) {
val terminologyValidator = fhirContentValidator.terminologyValidator
if (terminologyValidator.isValueSetSupported(valueSetUrl, version)) {
value match {
//FHIR code
case JString(c) =>
if(!terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version, None, c))
if (!terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version, None, c))
Seq(ConstraintFailure(s"Code binding failure, code '$c' is not defined in the ValueSet '$valueSetUrl' or is nor active and selectable!", !isRequired))
else
Nil
Expand All @@ -35,22 +35,22 @@ case class CodeBindingRestriction(valueSetUrl:String, version:Option[String], st
case obj: JObject if (obj.obj.exists(_._1 == FHIR_COMMON_FIELDS.CODING)) =>
val systemAndCodes =
FHIRUtil.extractValueOption[Seq[JObject]](obj, FHIR_COMMON_FIELDS.CODING)
.map(_.map(coding =>
.map(_.map(coding =>
FHIRUtil.extractValueOption[String](coding, FHIR_COMMON_FIELDS.SYSTEM) ->
FHIRUtil.extractValueOption[String](coding, FHIR_COMMON_FIELDS.CODE))).getOrElse(Nil)
//One of the codes should be bounded to given
if(systemAndCodes.nonEmpty && !systemAndCodes.exists(sc => sc._1.isDefined && sc._2.isDefined && terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version, sc._1, sc._2.get)))
if (systemAndCodes.nonEmpty && !systemAndCodes.exists(sc => sc._1.isDefined && sc._2.isDefined && terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version, sc._1, sc._2.get)))
Seq(ConstraintFailure(s"Code binding failure, none of the system-code pairing '${printSystemCodes(systemAndCodes)}' is defined in the ValueSet '$valueSetUrl' or is active and selectable!", !isRequired))
else
Nil

//FHIR Quantity, Coding
case obj: JObject =>
//Extract the system and code if exists
val (system, code) = FHIRUtil.extractValueOption[String](obj, FHIR_COMMON_FIELDS.SYSTEM) ->
val (system, code) = FHIRUtil.extractValueOption[String](obj, FHIR_COMMON_FIELDS.SYSTEM) ->
FHIRUtil.extractValueOption[String](obj, FHIR_COMMON_FIELDS.CODE)
//If there is binding, they should exist and defined in the value set
if(system.isEmpty || code.isEmpty || ! terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version,system, code.get))
if (system.isEmpty || code.isEmpty || !terminologyValidator.validateCodeAgainstValueSet(valueSetUrl, version, system, code.get))
Seq(ConstraintFailure(s"Code binding failure, system-code pairing '${printSystemCodes(Seq(system -> code))}' is not defined in the ValueSet '$valueSetUrl' or is not active and selectable!", !isRequired))
else
Nil
Expand All @@ -59,6 +59,7 @@ case class CodeBindingRestriction(valueSetUrl:String, version:Option[String], st
} else {
Seq(ConstraintFailure(s"Unknown or not processable ValueSet '$valueSetUrl' for validation, skipping code binding validation...", isWarning = true))
}

}

private def printSystemCodes(systemAndCodes:Seq[(Option[String], Option[String])]) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ class FhirContentValidator(
profileUrl:String,
referenceResolver:Option[IReferenceResolver] = None,
resourceValidator:Option[IFhirResourceValidator] = None
)(implicit val ec:ExecutionContext) extends AbstractFhirContentValidator(fhirConfig, profileUrl, referenceResolver) {
)(implicit val ec:ExecutionContext)
extends AbstractFhirContentValidator(
fhirConfig,
profileUrl,
referenceResolver,
resourceValidator.flatMap(_.getTerminologyValidator()).getOrElse(FhirTerminologyValidator.apply(fhirConfig, Nil))) {
protected val logger: Logger = LoggerFactory.getLogger(this.getClass)
//Temporary store of bundle content for bundle validations
private var bundle:Option[Resource] = None
Expand Down
Loading

0 comments on commit 91a7231

Please sign in to comment.