Skip to content

Commit

Permalink
Merge pull request #74 from srdc/supporting-profiles-with-versions
Browse files Browse the repository at this point in the history
Supporting profiles with versions
  • Loading branch information
tnamli authored Sep 24, 2024
2 parents 79c5049 + 836b003 commit 66e14a5
Show file tree
Hide file tree
Showing 22 changed files with 309 additions and 139 deletions.
77 changes: 77 additions & 0 deletions onfhir-common/src/main/scala/io/onfhir/api/util/FHIRUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.onfhir.api.util
import akka.http.scaladsl.model._
import io.onfhir.api._
import io.onfhir.api.model._
import io.onfhir.api.validation.ProfileRestrictions
import io.onfhir.config.OnfhirConfig
import io.onfhir.util.JsonFormatter.formats

Expand Down Expand Up @@ -964,4 +965,80 @@ object FHIRUtil {
Math.pow(10, i) * 0.5
}
}

/**
* Find the latest FHIR version among the given versions. If versions are not given in the correct format this returns None indicating it is impossible
* e.g. 2.1, 2.2, 2.3.1, 2.4 --> 2.4
* @param versions Given version strings
* @return
*/
def findLatestFhirVersion(versions:Seq[String]):Option[String] = {
Try(
versions
.map(v => v.split('.').map(_.toInt)) //Try to parse the versions e.g. 1.2, 2.2.2, if not return None indicating we cannot deduce the latest version
)
.toOption
.getOrElse(Nil)
.sortWith((v1, v2) => isNewer(v1, v2))
.headOption
.map(v => v.mkString("."))
}

/**
* Check if the first FHIR version is greater than the second
* @param v1 First FHIR version
* @param v2 Second FHIR version
* @return
*/
private def isNewer(v1:Array[Int], v2:Array[Int]):Boolean = {
val result =
v1
.zip(v2) //Zip the versions, if lengths are not equal remaining elements are dropped
.foldLeft[Option[Boolean]](None) {
case (None,(i1, i2)) if i1 == i2 => None //If both versions are equal, return None indicating not decided yet
case (None, (i1, i2)) => Some(i1 > i2) //If version1 is greater, return true
case (Some(true), _) => Some(true) //If version1 is greater in earlier phase, continue with that
case (Some(false), _) => Some(false) //If version1 is smaller in earlier phase, continue with that
}

(v1.length, v2.length) match {
//If version 1's length equal or more and the comparison is equal e.g. 2.1.1 vs 2.1 --> assume 2.1.1 is newer
case (l1, l2) if l1 >= l2 => result.getOrElse(true)
//Otherwise e.g. 2.1 vs 2.1.1 --> return false as the second is newer
case _ => result.getOrElse(false)
}
}

/**
* Find the definition of a mentioned profile
* @param mentionedProfile Mentioned profile URL and optional version
* @param profiles All existing profiles
* @return
*/
def getMentionedProfile(mentionedProfile:(String, Option[String]), profiles: Map[String, Map[String, ProfileRestrictions]]):Option[ProfileRestrictions] = {
profiles
.get(mentionedProfile._1)
.flatMap(foundVersions=>
mentionedProfile._2
//If a version is mentioned, try to get that
.flatMap(v =>
foundVersions.get(v)
)
//If a version is not mentioned
.orElse(
//If there is only one just return it
if (foundVersions.size == 1)
foundVersions.headOption.map(_._2)
else {
foundVersions
.get("latest")
.orElse(
FHIRUtil
.findLatestFhirVersion(foundVersions.keys.toSeq)
.map(v => foundVersions(v))
)
}
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ abstract class AbstractFhirContentValidator(
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)
val rootProfileChain: Seq[ProfileRestrictions] = fhirConfig.findProfileChainByCanonical(profileUrl)

//FHIR reference and expected target profiles to check for existence
val referencesToCheck = new mutable.ListBuffer[(FhirReference, Set[String])]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ case class ConstraintFailure(errorOrWarningMessage: String, isWarning: Boolean =
* A FHIR StructureDefinition (Profile or base definition for resource types or data types)
*
* @param url URL of the profile
* @param version Version of the profile
* @param id Resource id of the profile
* @param baseUrl Base profile that this extends if exist
* @param baseUrl Base profile that this extends if exist (with optional version)
* @param resourceType Resource type for the StructureDefinition
* @param resourceName Given name of the StructureDefinition resource
* @param resourceDescription Description of the StructureDefinition resource
Expand All @@ -40,8 +41,9 @@ case class ConstraintFailure(errorOrWarningMessage: String, isWarning: Boolean =
* @param isAbstract If this is a abstract definition
*/
case class ProfileRestrictions(url: String,
version:Option[String],
id: Option[String],
baseUrl: Option[String],
baseUrl: Option[(String, Option[String])],
resourceType: String,
resourceName: Option[String],
resourceDescription: Option[String],
Expand Down
41 changes: 27 additions & 14 deletions onfhir-common/src/main/scala/io/onfhir/config/BaseFhirConfig.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.onfhir.config

import io.onfhir.api.FHIR_ROOT_URL_FOR_DEFINITIONS
import io.onfhir.api.util.FHIRUtil
import io.onfhir.api.validation.{ProfileRestrictions, ValueSetRestrictions}

/** *
Expand All @@ -14,8 +15,8 @@ class BaseFhirConfig(version:String) {
*/
var fhirVersion: String = _

/** FHIR Profile definitions including the base profiles (For validation) Profile Url -> Definitions * */
var profileRestrictions: Map[String, ProfileRestrictions] = _
/** FHIR Profile definitions including the base profiles (For validation) Profile Url -> Map(version -> Definition) * */
var profileRestrictions: Map[String, Map[String, ProfileRestrictions]] = _
/** Supported FHIR value set urls with this server (For validation) ValueSet Url -> Map(Version ->Definitions) */
var valueSetRestrictions: Map[String, Map[String, ValueSetRestrictions]] = _

Expand Down Expand Up @@ -54,23 +55,36 @@ class BaseFhirConfig(version:String) {
* @param profileUrl Profile URL (StructureDefinition.url)
* @return
*/
def findProfile(profileUrl: String): Option[ProfileRestrictions] = {
profileRestrictions.get(profileUrl)
def findProfile(profileUrl: String, version:Option[String] = None): Option[ProfileRestrictions] = {
FHIRUtil.getMentionedProfile(profileUrl -> version, profileRestrictions)
}

/**
* Find a chain of parent profiles until the base FHIR specification profile
* Find a chain of parent profiles until the base FHIR specification profile from given url and optional version
*
* @param profileUrl Profile URL (StructureDefinition.url)
* @param version Version of definition (StructureDefinition.version)
* @return Profiles in order of evaluation (inner profile,..., base profile)
*/
def findProfileChain(profileUrl: String): Seq[ProfileRestrictions] = {
findProfile(profileUrl) match {
def findProfileChain(profileUrl: String, version:Option[String] = None): Seq[ProfileRestrictions] = {
findProfile(profileUrl, version) match {
case None => Nil
case Some(profile) => findChain(profileRestrictions)(profile)
case Some(profile) => findChain(profile)
}
}

/**
* Find a chain of parent profiles until the base FHIR specification profile from given Canonical reference to profile
* @param profileCanonicalRef Canonical reference
* e.g. http://onfhir.io/StructureDefinition/MyProfile
* e.g. http://onfhir.io/StructureDefinition/MyProfile|2.0
* @return
*/
def findProfileChainByCanonical(profileCanonicalRef:String):Seq[ProfileRestrictions] = {
val (profileUrl, version) = FHIRUtil.parseCanonicalValue(profileCanonicalRef)
findProfileChain(profileUrl, version)
}

/**
* Find target resource/data type of a profile
* @param profileUrl Profile URL (StructureDefinition.url)
Expand All @@ -83,17 +97,16 @@ class BaseFhirConfig(version:String) {
/**
* Supplementary method for profile chain finding
*
* @param restrictions Profile restrictions for each profile
* @param profile Profile URL (StructureDefinition.url)
* @param profile Profile itself
* @return
*/
private def findChain(restrictions: Map[String, ProfileRestrictions])(profile: ProfileRestrictions): Seq[ProfileRestrictions] = {
private def findChain(profile: ProfileRestrictions): Seq[ProfileRestrictions] = {
profile
.baseUrl
.map(burl =>
restrictions
.get(burl)
.fold[Seq[ProfileRestrictions]](Seq(profile))(parent => profile +: findChain(restrictions)(parent))
findProfile(burl._1, burl._2)
.map(parent => profile +: findChain(parent))
.getOrElse(Nil)
)
.getOrElse(Seq(profile))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ class FhirServerConfig(version:String) extends BaseFhirConfig(version) {
/***
* Dynamic configurations for this instance of FHIR repository
*/
/** List of supported resource types and profiles for each resource; resource-type -> Set(profile-url) */
var supportedProfiles:Map[String, Set[String]] = HashMap()
/** List of supported resource types and profiles for each resource; resource-type -> Map(profile-url -> Set(versions)) */
var supportedProfiles:Map[String, Map[String, Set[String]]] = HashMap()
/** Rest configuration for each Resource*/
var resourceConfigurations:Map[String, ResourceConf] = HashMap()

Expand Down Expand Up @@ -85,8 +85,10 @@ class FhirServerConfig(version:String) extends BaseFhirConfig(version) {
* @param profileUrl Profile URL (StructureDefinition.url)
* @return
*/
def isProfileSupported(profileUrl:String):Boolean = {
supportedProfiles.flatMap(_._2).exists(_.equals(profileUrl))
def isProfileSupported(profileUrl:String, version:Option[String] = None):Boolean = {
supportedProfiles.flatMap(_._2).exists(profiles =>
profiles._1 == profileUrl && version.forall(v => profiles._2.contains(v))
)
}

/**
Expand Down Expand Up @@ -149,8 +151,7 @@ class FhirServerConfig(version:String) extends BaseFhirConfig(version) {
*/
def getSummaryElements(rtype:String):Set[String] = {
val cProfile = resourceConfigurations(rtype).profile.getOrElse(s"$FHIR_ROOT_URL_FOR_DEFINITIONS/StructureDefinition/$rtype")

profileRestrictions(cProfile).summaryElements
findProfile(cProfile).map(_.summaryElements).getOrElse(Set.empty)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,9 @@ class FHIRUtilTest extends Specification {
FHIRUtil.getParameterValueByPath(parameters, "match.concept").map(c => (c \ "code").extract[String]).toSet shouldEqual Set("309068002", "309068001")
FHIRUtil.getParameterValueByPath(parameters, "match.other.x") shouldEqual Seq(JString("y"))
}

"find latest FHIR version" in {
FHIRUtil.findLatestFhirVersion(Seq("2.1", "2.2", "1.9", "2.2.1")) shouldEqual Some("2.2.1")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.onfhir.config

import io.onfhir.api.FHIR_FOUNDATION_RESOURCES.{FHIR_CODE_SYSTEM, FHIR_STRUCTURE_DEFINITION, FHIR_VALUE_SET}
import io.onfhir.api.model.OutcomeIssue
import io.onfhir.api.parsers.IFhirFoundationResourceParser
import io.onfhir.api.util.FHIRUtil
import io.onfhir.api.validation.{ConstraintKeys, IReferenceResolver, ProfileRestrictions, SimpleReferenceResolver}
import io.onfhir.api.{FHIR_ROOT_URL_FOR_DEFINITIONS, Resource}
Expand Down Expand Up @@ -64,19 +65,15 @@ abstract class BaseFhirConfigurator extends IFhirVersionConfigurator {

logger.info("Parsing base FHIR foundation resources (base standard) ...")
//Parsing base definitions
val baseResourceProfiles =
baseResourceProfileResources
.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = true))
.map(p => p.url -> p).toMap
val baseDataTypeProfiles =
baseDataTypeProfileResources
.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = true))
.map(p => p.url -> p).toMap
val baseResourceProfiles = parseStructureDefinitionsConvertToMap(foundationResourceParser, baseResourceProfileResources)

val baseDataTypeProfiles = parseStructureDefinitionsConvertToMap(foundationResourceParser, baseDataTypeProfileResources)

val baseProfiles =
baseResourceProfiles ++
baseDataTypeProfiles.filter(_._1.split('/').last.head.isUpper) ++
baseOtherProfileResources.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = true)).map(p => p.url -> p).toMap ++
baseExtensionProfileResources.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = true)).map(p => p.url -> p).toMap
baseDataTypeProfiles.filter(_._1.split('/').last.head.isUpper) ++ //Get only complex types
parseStructureDefinitionsConvertToMap(foundationResourceParser, baseOtherProfileResources) ++
parseStructureDefinitionsConvertToMap(foundationResourceParser, baseExtensionProfileResources)

//Initialize fhir config with base profiles and value sets to prepare for validation
fhirConfig.profileRestrictions = baseProfiles
Expand All @@ -87,7 +84,8 @@ abstract class BaseFhirConfigurator extends IFhirVersionConfigurator {
validateGivenInfrastructureResources(fhirConfig, FHIR_CODE_SYSTEM, codeSystemResources)
logger.info("Parsing given FHIR foundation resources ...")
//Parsing the profiles and value sets into our compact form
val profiles = profileResources.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = true)).map(p => p.url -> p).toMap
val profiles = parseStructureDefinitionsConvertToMap(foundationResourceParser, profileResources)

//Parse all as bundle
val valueSets = foundationResourceParser.parseValueSetAndCodeSystems(valueSetResources ++ codeSystemResources ++ baseValueSetsAndCodeSystems)

Expand All @@ -101,6 +99,29 @@ abstract class BaseFhirConfigurator extends IFhirVersionConfigurator {
fhirConfig
}

/**
* Parse StructureDefinition resources and make them Map of Map (url and version)
* @param foundationResourceParser Resource parser
* @param resources StructureDefinition resources
* @return
*/
protected def parseStructureDefinitionsConvertToMap(foundationResourceParser:IFhirFoundationResourceParser, resources:Seq[Resource], includeElementMetadata:Boolean = true):Map[String, Map[String, ProfileRestrictions]] = {
resources
.map(foundationResourceParser.parseStructureDefinition(_, includeElementMetadata = includeElementMetadata))
.map(s => (s.url, s.version, s))
.groupBy(_._1) // group by the URL
.map { case (url, defs) =>
val withoutVersion = defs.filter(_._2.isEmpty) // Find all StructureDefinitions without a version

// If there are multiple StructureDefinitions without a version, log a warning
if (withoutVersion.size > 1) {
logger.warn(s"Multiple StructureDefinitions without a version for URL: $url. Only the last one will be used.")
}

// Convert to the desired map structure: url -> (version -> StructureDefinition)
url -> defs.map(s => s._2.getOrElse("latest") -> s._3).toMap
}
}

/**
* Validate and handle profile configurations
Expand All @@ -111,11 +132,15 @@ abstract class BaseFhirConfigurator extends IFhirVersionConfigurator {
* @return
*/
private def validateAndConfigureProfiles(fhirConfig: BaseFhirConfig,
profiles: Map[String, ProfileRestrictions],
baseProfiles: Map[String, ProfileRestrictions]): BaseFhirConfig = {
profiles: Map[String, Map[String, ProfileRestrictions]],
baseProfiles: Map[String, Map[String, ProfileRestrictions]]): BaseFhirConfig = {
//Check if all mentioned profiles within the given profiles also exist in profile set (Profile set is closed)
val allProfilesAndExtensionsMentionedInSomewhere = findMentionedProfiles(fhirConfig, profiles.values.toSeq)
val profileDefinitionsNotGiven = allProfilesAndExtensionsMentionedInSomewhere.filter(p => !profiles.contains(p) && !baseProfiles.contains(p)).toSeq
val allProfilesAndExtensionsMentionedInSomewhere = findMentionedProfiles(fhirConfig, profiles.values.flatMap(_.values).toSeq)
//Find those ones that the definitions are not given
val profileDefinitionsNotGiven =
allProfilesAndExtensionsMentionedInSomewhere
.filter(p => FHIRUtil.getMentionedProfile(p, profiles ++ baseProfiles).isEmpty)
.toSeq
if (profileDefinitionsNotGiven.nonEmpty)
throw new InitializationException(s"Missing StructureDefinition in profile configurations for the referred profiles (${profileDefinitionsNotGiven.mkString(",")}) within the given profiles (e.g. as base profile 'StructureDefinition.baseDefinition', target profile for an element StructureDefinition.differential.element.type.profile or reference StructureDefinition.differential.element.type.targetProfile) ! All mentioned profiles should be given for configuration of the application!")

Expand All @@ -131,27 +156,30 @@ abstract class BaseFhirConfigurator extends IFhirVersionConfigurator {
* @param profiles All profile restrictions
* @return
*/
protected def findMentionedProfiles(fhirConfig: BaseFhirConfig, profiles: Seq[ProfileRestrictions]): Set[String] = {
profiles.flatMap(p => {
val isBaseProfile = p.url.startsWith(FHIR_ROOT_URL_FOR_DEFINITIONS)
p.elementRestrictions.map(_._2)
.flatMap(e =>
e.restrictions.get(ConstraintKeys.DATATYPE).toSeq.map(_.asInstanceOf[TypeRestriction])
.flatMap(_.dataTypesAndProfiles.flatMap(dtp => dtp._2 match {
case Nil =>
if (isBaseProfile || fhirConfig.FHIR_COMPLEX_TYPES.contains(dtp._1))
Seq(s"$FHIR_ROOT_URL_FOR_DEFINITIONS/StructureDefinition/${dtp._1}")
else
Nil
case oth => oth
}).toSet) ++
e.restrictions.get(ConstraintKeys.REFERENCE_TARGET).toSeq.map(_.asInstanceOf[ReferenceRestrictions]).flatMap(_.targetProfiles).toSet) ++
p.baseUrl.toSeq
}).filterNot(p => {
val ns = s"$FHIR_ROOT_URL_FOR_DEFINITIONS/StructureDefinition/"
val arr = p.split(ns)
arr.length == 2 && Character.isLowerCase(arr(1).head)
}).toSet
protected def findMentionedProfiles(fhirConfig: BaseFhirConfig, profiles: Seq[ProfileRestrictions]): Set[(String, Option[String])] = {
profiles
.flatMap(p => {
val isBaseProfile = p.url.startsWith(FHIR_ROOT_URL_FOR_DEFINITIONS)
p.elementRestrictions.map(_._2)
.flatMap(e =>
e.restrictions.get(ConstraintKeys.DATATYPE).toSeq.map(_.asInstanceOf[TypeRestriction])
.flatMap(_.dataTypesAndProfiles.flatMap(dtp => dtp._2 match {
case Nil =>
if (isBaseProfile || fhirConfig.FHIR_COMPLEX_TYPES.contains(dtp._1))
Seq(s"$FHIR_ROOT_URL_FOR_DEFINITIONS/StructureDefinition/${dtp._1}")
else
Nil
case oth => oth
}).toSet) ++
e.restrictions.get(ConstraintKeys.REFERENCE_TARGET).toSeq.map(_.asInstanceOf[ReferenceRestrictions]).flatMap(_.targetProfiles).toSet
).map(FHIRUtil.parseCanonicalValue) ++
p.baseUrl.toSeq
})
.filterNot(p => {
val ns = s"$FHIR_ROOT_URL_FOR_DEFINITIONS/StructureDefinition/"
p._1.startsWith(ns) && Character.isLowerCase(p._1.drop(ns.length).head)
})
.toSet
}

/**
Expand Down
Loading

0 comments on commit 66e14a5

Please sign in to comment.