diff --git a/README.md b/README.md index 6b7c61e1..58682e3e 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ single sealed interface is generated with a subclass for each type. |----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| | StructureDefinition JSON file (e.g. `StructureDefinition-Patient.json`) | Kotlin .kt file (e.g. `Patient.kt`) | | StructureDefinition (e.g. `Patient`) | Kotlin class (e.g. `class Patient`) | +| ValueSet JSON file (e.g. `ValueSet-resource-types.json`) | Kotlin .kt file (e.g. `ResourceType`) | +| ValueSet (e.g. `ResourceType`) | Kotlin class (e.g. `enum class ResourceType`) | | BackboneElement (e.g. `Patient.contact`) | Nested Kotlin class (e.g. `class Contact` nested under `Patient`) | | Choice of data types (e.g. `Patient.deceased[x]`) | Sealed interface (e.g. `sealed interface Deceased` nested under `Patient` with subtypes `Boolean` and `DateTime`) | @@ -195,6 +197,29 @@ when (val multipleBirth = patient.multipleBirth) { The generated classes reflect the inheritance hierarchy defined by FHIR. For example, `Patient` inherits from `DomainResource`, which inherits from `Resource`. +The constants in the generated Kotlin enum classes are derived from the code property of concepts found in FHIR `CodeSystem` +and `ValueSet` resources. To comply with Kotlin’s enum naming conventions—which require names to start with a letter +and avoid special characters—each code is transformed using a set of formatting rules. This includes handling numeric codes, +special characters, and FHIR URLs. After all transformations, the final name is converted to PascalCase to match Kotlin style guidelines. + +| Rule # | Description | Example Input | Example Output | +|--------|----------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|-------------------| +| 1 | Codes made up entirely of digits are prefixed with an underscore | `1111` | `_1111` | +| 2 | Dashes (`-`) and dots (`.`) are replaced with underscores (`_`) | `4.0.1` | `_4_0_1` | +| 3 | All other unsupported special characters are removed (if not explicitly handled below) | `some@code!name` | `Somecodename` | +| 4 | Specific special characters are replaced with readable keywords | `>=` | `GreaterOrEquals` | +| | | `<` | `LessThan` | +| | | `!=` or `<>` | `NotEquals` | +| | | `=` | `Equals` | +| | | `*` | `Multiply` | +| | | `+` | `Plus` | +| | | `-` | `Minus` | +| | | `/` | `Divide` | +| | | `%` | `Percent` | +| 5 | For codes that are full URLs, extract the last segment after the final slash or dot | `http://hl7.org/fhir/some-system/DateTime` or `http://hl7.org/fhir.system.DateTime` | `DateTime` | +| 6 | The final formatted string is converted to PascalCase | `some_codename` | `SomeCodename` | + + ### Mapping FHIR JSON representation to Kotlin The [Kotlin serialization](https://github.com/Kotlin/kotlinx.serialization) library is used for JSON diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/EnumTypeSpecGenerator.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/EnumTypeSpecGenerator.kt new file mode 100644 index 00000000..c2ad0918 --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/EnumTypeSpecGenerator.kt @@ -0,0 +1,270 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen + +import com.google.fhir.codegen.schema.CodeSystem +import com.google.fhir.codegen.schema.Concept +import com.google.fhir.codegen.schema.ValueSet +import com.google.fhir.codegen.schema.isValueSystemSupported +import com.google.fhir.codegen.schema.sanitizeKDoc +import com.google.fhir.codegen.schema.toPascalCase +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asTypeName + +/** + * Generates a [TypeSpec] for [Enum] class representation of FHIR data. The `Enum` class is + * generated using information derived from the [ValueSet] and [CodeSystem] terminology resources. + * Each generated enum class will contain the methods for retrieving the code, system, display and + * definition of each `Enum` class constant. + */ +object EnumTypeSpecGenerator { + + fun generate( + enumClassName: String, + valueSet: ValueSet, + codeSystemMap: Map, + ): TypeSpec? { + val fhirEnum = generateEnum(valueSet, codeSystemMap) + if (fhirEnum == null || fhirEnum.constants.isEmpty()) return null + val typeSpec = + TypeSpec.enumBuilder(enumClassName) + .apply { + fhirEnum.description?.sanitizeKDoc()?.let { addKdoc(it) } + primaryConstructor( + FunSpec.constructorBuilder().addParameter("code", String::class).build() + ) + + fhirEnum.constants.forEach { + addEnumConstant( + it.name, + TypeSpec.anonymousClassBuilder() + .apply { + if (!it.definition.isNullOrBlank()) addKdoc("%L", it.definition.sanitizeKDoc()) + addSuperclassConstructorParameter("%S", it.code) + } + .build(), + ) + .addProperty( + PropertySpec.builder("code", String::class, KModifier.PRIVATE) + .initializer("code") + .build() + ) + } + addFunction( + FunSpec.builder("toString") + .addModifiers(KModifier.OVERRIDE) + .addStatement("return code") + .returns(String::class) + .build() + ) + .addFunction( + FunSpec.builder("getCode") + .addModifiers(KModifier.PUBLIC) + .returns(String::class) + .addStatement("return code") + .build() + ) + + addFunction(createPropertyAccessorFunction("getSystem", fhirEnum.constants) { it.system }) + + addFunction( + createPropertyAccessorFunction("getDisplay", fhirEnum.constants) { it.display } + ) + addFunction( + createPropertyAccessorFunction("getDefinition", fhirEnum.constants) { it.definition } + ) + + addType( + TypeSpec.companionObjectBuilder() + .addFunction( + FunSpec.builder("fromCode") + .addParameter("code", String::class) + .returns(ClassName.bestGuess(enumClassName)) + .beginControlFlow("return when (code)") + .apply { + fhirEnum.constants.forEach { addStatement("%S -> %L", it.code, it.name) } + addStatement( + "else -> throw IllegalArgumentException(\"Unknown code \$code for enum %L\")", + enumClassName, + ) + } + .endControlFlow() + .build() + ) + .build() + ) + } + .build() + return typeSpec + } + + private fun createPropertyAccessorFunction( + functionName: String, + constants: List, + propertySelector: (FhirEnumConstant) -> String?, + ): FunSpec { + return FunSpec.builder(functionName) + .addModifiers(KModifier.PUBLIC) + .returns(String::class.asTypeName().copy(nullable = true)) + .apply { + val constantsWithValue = constants.filter { propertySelector(it) != null } + if (constantsWithValue.isEmpty()) { + addStatement("return null") + } else { + beginControlFlow("return when (this)") + constantsWithValue.forEach { constant -> + propertySelector(constant)?.let { addStatement("%L -> %S", constant.name, it) } + } + addStatement("else -> null") + endControlFlow() + } + } + .build() + } + + /** + * Instantiate a [FhirEnum] to facilitate the generation of Kotlin enum classes. The enum + * constants are derived from concepts defined in a `ValueSet`, a `CodeSystem`, or both. When both + * are provided, the constants are generated by intersecting the concepts from the `ValueSet` and + * the `CodeSystem`. If only one is provided, the constants are based solely on that source. Any + * nested concepts are flattened, transformed, and included in the resulting enum constants. + * + * NOTE: This excludes all systems that equals "http://unitsofmeasure.org" or start with "urn", + * e.g. urn:ietf:bcp:13, urn:ietf:bcp:47,urn:iso:std:iso:4217 typically used for MIMETypes, + * Currency Code etc. + */ + fun generateEnum(valueSet: ValueSet, codeSystemMap: Map): FhirEnum? { + return valueSet.compose + ?.include + ?.filter { it.isValueSystemSupported() } + ?.let { + val enumConstants = mutableListOf() + for (include in it) { + val system = include.system!! + val codeSystem = codeSystemMap[system] + + generateEnumConstants( + system = system, + codeSystemConcepts = codeSystem?.concept, + valueSetConcepts = include.concept, + enumConstants = enumConstants, + ) + } + return FhirEnum(valueSet.description, enumConstants) + } + } + + private fun generateEnumConstants( + system: String, + codeSystemConcepts: List?, + valueSetConcepts: List?, + enumConstants: MutableList, + ) { + if (!codeSystemConcepts.isNullOrEmpty() && !valueSetConcepts.isNullOrEmpty()) { + val expectedCodesSet = valueSetConcepts.mapTo(hashSetOf()) { it.code } + generateNestedEnumConstants(valueSetConcepts, system, enumConstants, expectedCodesSet) + } else if (!valueSetConcepts.isNullOrEmpty() && codeSystemConcepts.isNullOrEmpty()) { + generateNestedEnumConstants(valueSetConcepts, system, enumConstants, null) + } else if (!codeSystemConcepts.isNullOrEmpty() && valueSetConcepts.isNullOrEmpty()) { + generateNestedEnumConstants(codeSystemConcepts, system, enumConstants, null) + } + } + + private fun generateNestedEnumConstants( + concepts: List, + system: String, + enumConstants: MutableList, + expectedCodesSet: Set?, + ) { + val arrayDeque: ArrayDeque = ArrayDeque(concepts) + + while (arrayDeque.isNotEmpty()) { + val currenConcept = arrayDeque.removeFirst() + val name = currenConcept.code.formatEnumConstantName() + val include = expectedCodesSet?.contains(currenConcept.code) != false + if (name.isNotBlank() && include) { + val fhirEnumConstant = + FhirEnumConstant( + code = currenConcept.code, + system = system, + name = name, + display = currenConcept.display, + definition = currenConcept.definition, + ) + enumConstants.add(fhirEnumConstant) + } + if (!currenConcept.concept.isNullOrEmpty() && include) { + arrayDeque.addAll(currenConcept.concept) + } + } + } + + /** + * Transforms a FHIR code into a valid Kotlin enum constant name. + * + * This function applies a series of transformations to ensure the resulting name follows Kotlin + * naming conventions and is a valid identifier. The transformations are applied in the following + * order: + * 1. For URLs (strings starting with "http"), extract only the last segment after the final dot + * Example: "http://hl7.org/fhirpath/System.DateTime" → "DateTime" + * 2. For special characters (>, <, >=, etc.), map them to descriptive names Example: ">" → + * "GreaterThan", "<" → "LessThan" + * 3. Replace all remaining non-alphanumeric characters with underscores Example: "some-value.123" + * → "some_value_123" + * 4. If the string starts with a digit, prefix it with an underscore to make it a valid + * identifier Example: "123test" → "_123test" + * 5. Apply PascalCase to each segment between underscores while preserving the underscores + * Example: "test_value" → "Test_Value" + * + * @return A valid Kotlin enum constant name + */ + private fun String.formatEnumConstantName(): String { + + if (startsWith("http")) return substringAfterLast(".") + + when (this) { + ">" -> return "GreaterThan" + "<" -> return "LessThan" + ">=" -> return "GreaterOrEquals" + "<=" -> return "LessOrEquals" + "<>", + "!=" -> return "NotEquals" + "=" -> return "Equals" + "*" -> return "Multiply" + "+" -> return "Plus" + "-" -> return "Minus" + "/" -> return "Divide" + "%" -> return "Percent" + } + + val withUnderscores = this.replace(Regex("[^a-zA-Z0-9]"), "_") + + val prefixed = + if (withUnderscores.isNotEmpty() && withUnderscores[0].isDigit()) { + "_$withUnderscores" + } else { + withUnderscores + } + + val parts = prefixed.split("_") + return parts.joinToString("_") { part -> if (part.isEmpty()) "" else part.toPascalCase() } + } +} diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegen.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegen.kt index be77ce3d..11074581 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegen.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegen.kt @@ -16,7 +16,9 @@ package com.google.fhir.codegen +import com.google.fhir.codegen.schema.CodeSystem import com.google.fhir.codegen.schema.StructureDefinition +import com.google.fhir.codegen.schema.ValueSet import com.google.fhir.codegen.schema.rootElements import com.google.fhir.codegen.schema.serializableWithCustomSerializer import com.squareup.kotlinpoet.AnnotationSpec @@ -49,6 +51,9 @@ object FhirCodegen { packageName: String, structureDefinition: StructureDefinition, isBaseClass: Boolean, + valueSetMap: Map, + codeSystemMap: Map, + commonBindingValueSetUrls: MutableMap>, ): List { val modelClassName = ClassName(packageName, structureDefinition.name.capitalized()) val modelFileSpec = FileSpec.builder(modelClassName) @@ -65,6 +70,9 @@ object FhirCodegen { isBaseClass, surrogateFileSpec, serializerFileSpec, + valueSetMap = valueSetMap, + codeSystemMap = codeSystemMap, + commonBindingValueSetUrls = commonBindingValueSetUrls, ) ) .addSuppressAnnotation() @@ -81,6 +89,7 @@ object FhirCodegen { SurrogateTypeSpecGenerator.generate( ClassName(packageName, structureDefinition.name), structureDefinition.rootElements, + valueSetMap, ) ) .addAnnotation( diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegenTask.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegenTask.kt index 9925a414..8b783859 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegenTask.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirCodegenTask.kt @@ -17,11 +17,18 @@ package com.google.fhir.codegen import com.google.fhir.codegen.primitives.DoubleSerializerFileSpecGenerator +import com.google.fhir.codegen.primitives.EnumerationFileSpecGenerator import com.google.fhir.codegen.primitives.FhirDateFileSpecGenerator import com.google.fhir.codegen.primitives.FhirDateTimeFileSpecGenerator import com.google.fhir.codegen.primitives.LocalTimeSerializerFileSpecGenerator +import com.google.fhir.codegen.schema.CodeSystem import com.google.fhir.codegen.schema.StructureDefinition +import com.google.fhir.codegen.schema.ValueSet +import com.google.fhir.codegen.schema.toPascalCase +import com.google.fhir.codegen.schema.urlPart import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import java.io.File import kotlinx.serialization.json.Json import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -46,6 +53,13 @@ abstract class FhirCodegenTask : DefaultTask() { @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + } + + private val commonBindingValueSetUrls = mutableMapOf>() + @TaskAction fun generateCode() { // Prepare the output folder @@ -56,23 +70,37 @@ abstract class FhirCodegenTask : DefaultTask() { // Prepare the input files and log them in the output folder val inputFiles = definitionFiles.files.flatMap { file -> - // Only use structure definition files for resource types. + // Use structure definitions, value set and code system files // NB filtering by file name is only an approximation. file.walkTopDown().filter { - it.isFile && it.name.matches("StructureDefinition-[A-Za-z0-9]*\\.json".toRegex()) + it.isFile && + (it.name.matches("StructureDefinition-[A-Za-z0-9]*\\.json".toRegex()) || + it.name.matches("(?i)^(ValueSet|CodeSystem)((-v3.*)?|(?!-v\\d).*)\\.json$".toRegex())) } } outputDir.resolve("inputs.txt").writeText(inputFiles.joinToString("\n")) - // Parse input files - val json = Json { - ignoreUnknownKeys = true - prettyPrint = true - } + val valueSetMap = + inputFiles + .asSequence() + .filter { it.name.startsWith("ValueSet", ignoreCase = true) } + .map { json.decodeFromString(it.readText(Charsets.UTF_8)) } + .groupBy { it.urlPart } + .mapValues { it.value.first() } + val codeSystemMap = + inputFiles + .asSequence() + .filter { it.name.startsWith("CodeSystem", ignoreCase = true) } + .map { json.decodeFromString(it.readText(Charsets.UTF_8)) } + .groupBy { it.url } + .mapValues { it.value.first() } + + // Only use structure definition files for resource types. val structureDefinitions = inputFiles .asSequence() + .filter { it.name.startsWith("StructureDefinition") } .map { json.decodeFromString(it.readText(Charsets.UTF_8)) } .filterNot { // Do not generate classes for logical types e.g. `Definition`, `Request`, `Event`, etc. @@ -99,12 +127,16 @@ abstract class FhirCodegenTask : DefaultTask() { .map { it.baseDefinition?.substringAfterLast('/')?.capitalized() } .distinct() + val packageName = this.packageName.get() structureDefinitions - .flatMap { + .flatMap { structureDefinition -> FhirCodegen.generateFileSpecs( - this.packageName.get(), - it, - baseClasses.contains(it.name.capitalized()), + packageName = packageName, + structureDefinition = structureDefinition, + isBaseClass = baseClasses.contains(structureDefinition.name.capitalized()), + valueSetMap = valueSetMap, + codeSystemMap = codeSystemMap, + commonBindingValueSetUrls = commonBindingValueSetUrls, ) } .forEach { it.writeTo(outputDir) } @@ -122,12 +154,56 @@ abstract class FhirCodegenTask : DefaultTask() { ) .writeTo(outputDir) - FhirDateTimeFileSpecGenerator.generate(this.packageName.get()).writeTo(outputDir) - FhirDateFileSpecGenerator.generate(this.packageName.get()).writeTo(outputDir) + FhirDateTimeFileSpecGenerator.generate(packageName).writeTo(outputDir) + FhirDateFileSpecGenerator.generate(packageName).writeTo(outputDir) + + // Generates a wrapper for enum types + EnumerationFileSpecGenerator.generate(packageName).writeTo(outputDir) + + generateSharedEnums(valueSetMap, codeSystemMap, packageName, outputDir) // Generate custom serializers - val serializersPackageName = "${this.packageName.get()}.serializers" + val serializersPackageName = "$packageName.serializers" DoubleSerializerFileSpecGenerator.generate(serializersPackageName).writeTo(outputDir) LocalTimeSerializerFileSpecGenerator.generate(serializersPackageName).writeTo(outputDir) } + + /** + * Generate shared enums. These enum classes are created from [StructureDefinition.snapshot] + * Elements that have common binding extensions. + */ + private fun generateSharedEnums( + valueSetMap: Map, + codeSystemMap: Map, + packageName: String, + outputDir: File, + ) { + valueSetMap.values + .filter { commonBindingValueSetUrls.containsKey(it.urlPart) } + .forEach { valueSet -> + val valueSetName = valueSet.name.toPascalCase() + val commonBindingNames = commonBindingValueSetUrls[valueSet.urlPart] + + // Create enums for a ValueSet that's used by several common binding names + if (commonBindingNames != null) { + commonBindingNames.forEach { name -> + val enumTypeSpec = EnumTypeSpecGenerator.generate(name, valueSet, codeSystemMap) + if (enumTypeSpec != null) { + FileSpec.builder(packageName = packageName, fileName = name) + .addType(enumTypeSpec) + .build() + .writeTo(outputDir) + } + } + } else { + val enumTypeSpec = EnumTypeSpecGenerator.generate(valueSetName, valueSet, codeSystemMap) + if (enumTypeSpec != null) { + FileSpec.builder(packageName = packageName, fileName = valueSetName) + .addType(enumTypeSpec) + .build() + .writeTo(outputDir) + } + } + } + } } diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirEnum.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirEnum.kt new file mode 100644 index 00000000..2295268e --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/FhirEnum.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen + +import kotlinx.serialization.Serializable + +@Serializable +data class FhirEnum( + val description: String? = null, + val constants: List = emptyList(), +) {} + +@Serializable +data class FhirEnumConstant( + val code: String, + val system: String, + val name: String, + val display: String? = null, + val definition: String? = null, +) diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/ModelTypeSpecGenerator.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/ModelTypeSpecGenerator.kt index 84cf1cfb..cd3ffe44 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/ModelTypeSpecGenerator.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/ModelTypeSpecGenerator.kt @@ -16,15 +16,25 @@ package com.google.fhir.codegen +import com.google.fhir.codegen.schema.CodeSystem +import com.google.fhir.codegen.schema.ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL +import com.google.fhir.codegen.schema.ELEMENT_IS_COMMON_BINDING_EXTENSION_URL import com.google.fhir.codegen.schema.Element import com.google.fhir.codegen.schema.StructureDefinition +import com.google.fhir.codegen.schema.ValueSet import com.google.fhir.codegen.schema.backboneElements import com.google.fhir.codegen.schema.getElementName import com.google.fhir.codegen.schema.getElements +import com.google.fhir.codegen.schema.getExtension import com.google.fhir.codegen.schema.getTypeName +import com.google.fhir.codegen.schema.getValueSetUrl import com.google.fhir.codegen.schema.hasPrimaryConstructor +import com.google.fhir.codegen.schema.isCommonBinding import com.google.fhir.codegen.schema.rootElements +import com.google.fhir.codegen.schema.sanitizeKDoc import com.google.fhir.codegen.schema.serializableWithCustomSerializer +import com.google.fhir.codegen.schema.toPascalCase +import com.google.fhir.codegen.schema.typeIsEnumeratedCode import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -32,9 +42,11 @@ import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.asTypeName import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -50,7 +62,12 @@ object ModelTypeSpecGenerator { isBaseClass: Boolean, surrogateFileSpec: FileSpec.Builder, serializerFileSpec: FileSpec.Builder, + valueSetMap: Map, + codeSystemMap: Map, + commonBindingValueSetUrls: MutableMap>, ): TypeSpec { + // Nested enums are all created inside the enclosing parent class for reusability + val enumClassesMap = mutableMapOf() val typeSpec = TypeSpec.classBuilder(modelClassName) .apply { @@ -125,19 +142,32 @@ object ModelTypeSpecGenerator { structureDefinition.rootElements, structureDefinition, isBaseClass, + valueSetMap, ) addBackboneElement( - structureDefinitionName, - modelClassName, - structureDefinition.backboneElements, - structureDefinition, - surrogateFileSpec, - serializerFileSpec, + path = structureDefinitionName, + enclosingModelClassName = modelClassName, + backboneElements = structureDefinition.backboneElements, + structureDefinition = structureDefinition, + surrogateTypeSpec = surrogateFileSpec, + serializerTypeSpec = serializerFileSpec, + valueSetMap = valueSetMap, + codeSystemMap = codeSystemMap, + commonBindingValueSetUrls = commonBindingValueSetUrls, + enumClassesMap = enumClassesMap, ) addSealedInterfaces(modelClassName, structureDefinition.rootElements) + generateEnumClasses( + elements = structureDefinition.rootElements, + valueSetMap = valueSetMap, + codeSystemMap = codeSystemMap, + commonBindingValueSetUrls = commonBindingValueSetUrls, + enumClassesMap = enumClassesMap, + ) + if (structureDefinition.kind == StructureDefinition.Kind.PRIMITIVE_TYPE) { addToElementFunction( modelClassName.packageName, @@ -152,23 +182,70 @@ object ModelTypeSpecGenerator { ) addOfFunction(modelClassName, propertySpecs.single { it.name == "value" }.type) } + + enumClassesMap.forEach { + // In some cases the model may share name with enum class + val enumClassName = + if (modelClassName.simpleNames.any { name -> name == it.key }) "${it.key}Enum" + else it.key + modelClassName.nestedClass(enumClassName) + addType(it.value) + } } .build() return typeSpec } } +private fun TypeSpec.Builder.generateEnumClasses( + elements: List, + valueSetMap: Map, + codeSystemMap: Map, + commonBindingValueSetUrls: MutableMap>, + enumClassesMap: MutableMap, +): TypeSpec.Builder { + for (element in elements) { + val commonBindingExt = element.getExtension(ELEMENT_IS_COMMON_BINDING_EXTENSION_URL) + val bindingNameExt = element.getExtension(ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL) + val bindingName = bindingNameExt?.valueString?.toPascalCase() + val isCommonBinding = commonBindingExt?.isCommonBinding() + val valueSetUrl = element.getValueSetUrl() + if (valueSetUrl.isNullOrBlank()) continue + if (element.typeIsEnumeratedCode(valueSetMap) && isCommonBinding != true) { + val valueSet = valueSetMap[valueSetUrl] + if (valueSet != null) { + val typeSpec = EnumTypeSpecGenerator.generate(bindingName!!, valueSet, codeSystemMap) + if (typeSpec != null) { + enumClassesMap.putIfAbsent(bindingName, typeSpec) + } + } + } + // Track ValueSet urls and binding names + if (isCommonBinding == true) { + commonBindingValueSetUrls.getOrPut(valueSetUrl) { hashSetOf() }.apply { add(bindingName!!) } + } + } + return this +} + private fun TypeSpec.Builder.buildProperties( modelClassName: ClassName, elements: List, structureDefinition: StructureDefinition?, // null means backbone element isBaseClass: Boolean = false, + valueSetMap: Map, ): TypeSpec.Builder { val properties = elements.map { element -> val name = element.getElementName() + val type = + if (element.typeIsEnumeratedCode(valueSetMap)) { + element.getEnumerationTypeName(modelClassName) + } else { + element.getTypeName(modelClassName) + } - PropertySpec.builder(name, element.getTypeName(modelClassName)) + PropertySpec.builder(name, type) .mutable() .apply { if (structureDefinition == null || structureDefinition.hasPrimaryConstructor) { @@ -237,6 +314,43 @@ private fun TypeSpec.Builder.buildProperties( return this } +/** + * Substitute the primitive type of code with an `Enumeration` type if the values for the code are + * constrained to a set of values. + */ +private fun Element.getEnumerationTypeName(modelClassName: ClassName): TypeName { + // Ignore all base.path starting with "Resource." + val elementBasePath = base?.path + val bindingNameExt = getExtension(ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL) + + // Use bindingName for the enum class, subclasses re-use enums from the parent + val bindingNameString = bindingNameExt?.valueString?.toPascalCase() + + val enumClassName = + if (path != elementBasePath) "${elementBasePath?.substringBefore(".") ?: ""}.$bindingNameString" + else bindingNameString + + if (!enumClassName.isNullOrBlank()) { + val commonBindingExt = getExtension(ELEMENT_IS_COMMON_BINDING_EXTENSION_URL) + val enumClassPackageName = + if (commonBindingExt?.isCommonBinding() == true || enumClassName.contains(".")) + modelClassName.packageName + else "" + + val enumClass = ClassName(enumClassPackageName, enumClassName) + val enumerationClassName = + ClassName(modelClassName.packageName, "Enumeration").parameterizedBy(enumClass) + return if (this.max == "*") { + List::class.asClassName().parameterizedBy(enumerationClassName) + } else { + enumerationClassName + } + .copy(nullable = true) + } else { + return getTypeName(modelClassName) + } +} + /** Adds a nested class for each BackboneElement in the [StructureDefinition]. */ private fun TypeSpec.Builder.addBackboneElement( path: String, @@ -245,6 +359,10 @@ private fun TypeSpec.Builder.addBackboneElement( structureDefinition: StructureDefinition, surrogateTypeSpec: FileSpec.Builder, serializerTypeSpec: FileSpec.Builder, + valueSetMap: Map, + codeSystemMap: Map, + commonBindingValueSetUrls: MutableMap>, + enumClassesMap: MutableMap, ): TypeSpec.Builder { backboneElements .filter { (backboneElement, _) -> @@ -266,7 +384,7 @@ private fun TypeSpec.Builder.addBackboneElement( ClassName(enclosingModelClassName.packageName, backboneElement.type!!.single().code) ) } - .buildProperties(backboneElementClassName, elements, null) + .buildProperties(backboneElementClassName, elements, null, false, valueSetMap) .addBackboneElement( backboneElement.path, enclosingModelClassName.nestedClass(name), @@ -274,6 +392,17 @@ private fun TypeSpec.Builder.addBackboneElement( structureDefinition, surrogateTypeSpec, serializerTypeSpec, + valueSetMap, + codeSystemMap, + commonBindingValueSetUrls, + enumClassesMap, + ) + .generateEnumClasses( + elements = backboneElements.values.flatten(), + valueSetMap = valueSetMap, + codeSystemMap = codeSystemMap, + commonBindingValueSetUrls = commonBindingValueSetUrls, + enumClassesMap = enumClassesMap, ) .addSealedInterfaces( backboneElementClassName, @@ -288,6 +417,7 @@ private fun TypeSpec.Builder.addBackboneElement( SurrogateTypeSpecGenerator.generate( enclosingModelClassName.nestedClass(name.capitalized()), elements, + valueSetMap, ) ) serializerTypeSpec.addType( @@ -475,12 +605,3 @@ private fun TypeSpec.Builder.addOfFunction(className: ClassName, primitiveTypeNa .build() ) } - -/** - * Sanitizes the string for KDoc, replacing character sequences that could break the comment block. - * - * See also: https://github.com/square/kotlinpoet/issues/887. - */ -private fun String.sanitizeKDoc(): String { - return this.replace("/*", "/*") -} diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/SurrogateTypeSpecGenerator.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/SurrogateTypeSpecGenerator.kt index 8c1dbf21..89a9fea0 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/SurrogateTypeSpecGenerator.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/SurrogateTypeSpecGenerator.kt @@ -17,11 +17,18 @@ package com.google.fhir.codegen import com.google.fhir.codegen.primitives.FhirPathType +import com.google.fhir.codegen.schema.ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL +import com.google.fhir.codegen.schema.ELEMENT_IS_COMMON_BINDING_EXTENSION_URL import com.google.fhir.codegen.schema.Element import com.google.fhir.codegen.schema.Type +import com.google.fhir.codegen.schema.ValueSet import com.google.fhir.codegen.schema.getElementName +import com.google.fhir.codegen.schema.getExtension import com.google.fhir.codegen.schema.getSurrogatePropertyNamesAndTypes import com.google.fhir.codegen.schema.getTypeName +import com.google.fhir.codegen.schema.isCommonBinding +import com.google.fhir.codegen.schema.toPascalCase +import com.google.fhir.codegen.schema.typeIsEnumeratedCode import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FunSpec @@ -43,7 +50,12 @@ import org.gradle.configurationcache.extensions.capitalized * [surrogate](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#composite-serializer-via-surrogate). */ object SurrogateTypeSpecGenerator { - fun generate(modelClassName: ClassName, elements: List): TypeSpec { + + fun generate( + modelClassName: ClassName, + elements: List, + valueSetMap: Map, + ): TypeSpec { val typeSpec = TypeSpec.classBuilder(modelClassName.toSurrogateClassName()) .apply { @@ -89,8 +101,8 @@ object SurrogateTypeSpecGenerator { .build() ) - addConverterToDataClass(modelClassName, filteredElements) - addConverterFromDataClass(modelClassName, filteredElements) + addConverterToDataClass(modelClassName, filteredElements, valueSetMap) + addConverterFromDataClass(modelClassName, filteredElements, valueSetMap) } .build() return typeSpec @@ -104,6 +116,7 @@ object SurrogateTypeSpecGenerator { private fun TypeSpec.Builder.addConverterToDataClass( modelClassName: ClassName, elements: List, + valueSetMap: Map, ) { addFunction( FunSpec.builder("toModel") @@ -115,7 +128,12 @@ private fun TypeSpec.Builder.addConverterToDataClass( indent() elements.forEach { element -> add("%N = ", element.getElementName()) - addCodeToBuildProperty(modelClassName, modelClassName.toSurrogateClassName(), element) + addCodeToBuildProperty( + modelClassName, + modelClassName.toSurrogateClassName(), + element, + valueSetMap, + ) add("\n") } unindent() @@ -134,6 +152,7 @@ private fun TypeSpec.Builder.addConverterToDataClass( private fun TypeSpec.Builder.addConverterFromDataClass( modelClassName: ClassName, elements: List, + valueSetMap: Map, ) { addType( TypeSpec.companionObjectBuilder() @@ -149,7 +168,7 @@ private fun TypeSpec.Builder.addConverterFromDataClass( .apply { indent() elements.forEach { element -> - addCodeToBuiltPropertiesInSurrogate(modelClassName, element) + addCodeToBuildPropertiesInSurrogate(modelClassName, element, valueSetMap) } unindent() } @@ -198,13 +217,20 @@ private fun CodeBlock.Builder.addCodeToBuildProperty( modelClassName: ClassName, surrogateClassName: ClassName, element: Element, + valueSetMap: Map, ) { val propertyName = element.getElementName() if (element.type != null && element.type.size > 1) { // A single property (sealed interface) constructed from a choice of types add("%T.from(", element.getTypeName(modelClassName)) for (type in element.type) { - addCodeToBuildChoiceTypeProperty(modelClassName, surrogateClassName, element, type) + addCodeToBuildChoiceTypeProperty( + modelClassName, + surrogateClassName, + element, + type, + valueSetMap, + ) add(", ") } add(")") @@ -213,7 +239,27 @@ private fun CodeBlock.Builder.addCodeToBuildProperty( // A list of primitive type val fhirPathType = FhirPathType.getFromFhirTypeCode(element.type?.singleOrNull()?.code!!)!! val typeInDataClass = fhirPathType.getTypeInDataClass(modelClassName.packageName) - if (typeInDataClass == fhirPathType.typeInSurrogateClass) { + + if (element.typeIsEnumeratedCode(valueSetMap)) { + val enumClass = element.getEnumClass(modelClassName) + add( + "if(this@%T.%N == null && this@%T.%N == null) { null } else { (this@%T.%N ?: List(this@%T.%N!!.size) { null }).zip(this@%T.%N ?: List(this@%T.%N!!.size) { null }).mapNotNull{ (value, element) -> %T.of(value?.let { %L.fromCode(it) }, element) } }", + surrogateClassName, + propertyName, + surrogateClassName, + "_$propertyName", + surrogateClassName, + propertyName, + surrogateClassName, + "_$propertyName", + surrogateClassName, + "_$propertyName", + surrogateClassName, + propertyName, + ClassName(modelClassName.packageName, "Enumeration"), + enumClass, + ) + } else if (typeInDataClass == fhirPathType.typeInSurrogateClass) { add( "if(this@%T.%N == null && this@%T.%N == null) { null } else { (this@%T.%N ?: List(this@%T.%N!!.size) { null }).zip(this@%T.%N ?: List(this@%T.%N!!.size) { null }).mapNotNull{ (value, element) -> %T.of(value, element) } }", surrogateClassName, @@ -261,6 +307,8 @@ private fun CodeBlock.Builder.addCodeToBuildProperty( surrogateClassName, propertyName, element.type?.singleOrNull(), + element, + valueSetMap, ) } } @@ -295,8 +343,22 @@ private fun CodeBlock.Builder.addCodeToBuildProperty( surrogateClassName: ClassName, propertyName: String, type: Type?, + element: Element, + valueSetMap: Map, ) { - if (type != null && FhirPathType.containsFhirTypeCode(type.code)) { + + if (element.typeIsEnumeratedCode(valueSetMap)) { + val enumClass = element.getEnumClass(modelClassName) + add( + "%T.of(this@%T.%N?.let { %L.fromCode(it) }, this@%T.%N)", + ClassName(modelClassName.packageName, "Enumeration"), + surrogateClassName, + propertyName, + enumClass, + surrogateClassName, + "_$propertyName", + ) + } else if (type != null && FhirPathType.containsFhirTypeCode(type.code)) { val fhirPathType = FhirPathType.getFromFhirTypeCode(type.code)!! val typeInDataClass = fhirPathType.getTypeInDataClass(modelClassName.packageName) if (typeInDataClass == fhirPathType.typeInSurrogateClass) { @@ -325,6 +387,26 @@ private fun CodeBlock.Builder.addCodeToBuildProperty( } } +private fun Element.getEnumClass(modelClassName: ClassName): ClassName { + val elementBasePath: String? = base?.path + val bindingNameExt = getExtension(ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL) + val commonBindingExt = getExtension(ELEMENT_IS_COMMON_BINDING_EXTENSION_URL) + val bindingNameString = bindingNameExt?.valueString?.toPascalCase() + val enumClassName = + if (path != elementBasePath && !elementBasePath.isNullOrBlank()) + "${elementBasePath.substringBefore(".")}.$bindingNameString" + else bindingNameString + val enumClassPackageName = + if (commonBindingExt?.isCommonBinding() == true || enumClassName?.contains(".") == true) { + modelClassName.packageName + } else { + // Use qualified import + modelClassName.packageName + "." + modelClassName.simpleNames.first() + } + val enumClass = ClassName(enumClassPackageName, enumClassName!!) + return enumClass +} + /** * Adds code to build a property in the data class of [type] from a choice of data types in the * surrogate class. @@ -350,11 +432,19 @@ private fun CodeBlock.Builder.addCodeToBuildChoiceTypeProperty( surrogateClassName: ClassName, element: Element, type: Type, + valueSetMap: Map, ) { // The property name in the surrogate class is the element name concatenated with the type code // e.g. `Patient.deceasedBoolean` val propertyName = "${element.getElementName()}${type.code.capitalized()}" - addCodeToBuildProperty(modelClassName, surrogateClassName, propertyName, type) + addCodeToBuildProperty( + modelClassName, + surrogateClassName, + propertyName, + type, + element, + valueSetMap, + ) } /** @@ -391,9 +481,10 @@ private fun CodeBlock.Builder.addCodeToBuildChoiceTypeProperty( * - **Complex type:** Complex types can be directly assigned since the data types in the data class * and the surrogate class are the same. The generated code simply includes the property name. */ -private fun CodeBlock.Builder.addCodeToBuiltPropertiesInSurrogate( +private fun CodeBlock.Builder.addCodeToBuildPropertiesInSurrogate( modelClassName: ClassName, element: Element, + valueSetMap: Map, ) { val propertyName = element.getElementName() if (element.type != null && element.type.size > 1) { @@ -410,7 +501,13 @@ private fun CodeBlock.Builder.addCodeToBuiltPropertiesInSurrogate( // A list of primitive type is deconstructed into two lists val fhirPathType = FhirPathType.getFromFhirTypeCode(element.type?.single()?.code!!)!! val typeInDataClass = fhirPathType.getTypeInDataClass(modelClassName.packageName) - if (typeInDataClass == fhirPathType.typeInSurrogateClass) { + if (element.typeIsEnumeratedCode(valueSetMap)) { + add( + "%N = this@with.%N?.map{ it?.value?.getCode() }?.takeUnless { it.all { it == null } }\n", + propertyName, + propertyName, + ) + } else if (typeInDataClass == fhirPathType.typeInSurrogateClass) { add( "%N = this@with.%N?.map{ it?.value }?.takeUnless { it.all { it == null } }\n", propertyName, @@ -438,7 +535,11 @@ private fun CodeBlock.Builder.addCodeToBuiltPropertiesInSurrogate( // A single primitive value is deconstructed into two properties val fhirPathType = FhirPathType.getFromFhirTypeCode(element.type?.single()?.code!!)!! val typeInDataClass = fhirPathType.getTypeInDataClass(modelClassName.packageName) - if (typeInDataClass == fhirPathType.typeInSurrogateClass) { + + if (element.typeIsEnumeratedCode(valueSetMap)) { + // Call getCode() if the type of the property is an enum type + add("%N = this@with.%N?.value?.getCode()\n", propertyName, propertyName) + } else if (typeInDataClass == fhirPathType.typeInSurrogateClass) { add("%N = this@with.%N?.value\n", propertyName, propertyName) } else { // Call FhirDateTime.toString diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/primitives/EnumerationFileSpecGenerator.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/primitives/EnumerationFileSpecGenerator.kt new file mode 100644 index 00000000..9a69ae2d --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/primitives/EnumerationFileSpecGenerator.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen.primitives + +import com.google.fhir.codegen.schema.sanitizeKDoc +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.asTypeName +import kotlin.text.substringAfterLast + +/** + * Generates [FileSpec] for a FHIR `Enumeration` type bound to a specific set of codes. It is an + * extension to primitive type `code` and represents a constrained code value from an enumerated + * list + */ +object EnumerationFileSpecGenerator { + + fun generate(packageName: String): FileSpec { + + val version = packageName.substringAfterLast('.') + val isR5 = version.equals("r5", ignoreCase = true) + + val superclassName = ClassName(packageName, if (isR5) "PrimitiveType" else "Element") + val extensionClassName = ClassName(packageName, "Extension") + + // Define T with upper bound of Enum<*> + val enumType = ClassName("kotlin", "Enum").parameterizedBy(STAR) + val typeVariable = TypeVariableName("T", enumType) + + val elementClassName = ClassName(packageName, "Element") + + val toElementFunction = createToElementFunction(elementClassName) + val ofFunction = createOfFunction(elementClassName, typeVariable) + val companionObject = createCompanionObject(ofFunction) + + val enumClass = + TypeSpec.classBuilder("Enumeration") + .addModifiers(KModifier.PUBLIC, KModifier.DATA) + .addTypeVariable(typeVariable) + .superclass(superclassName) + .apply { + if (!isR5) { + addSuperclassConstructorParameter("id") + addSuperclassConstructorParameter("extension") + } + } + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter( + ParameterSpec.builder("id", STRING.copy(nullable = true)) + .defaultValue("null") + .addKdoc("unique id for the element within a resource (for internal references)") + .build() + ) + .addParameter( + ParameterSpec.builder( + name = "extension", + type = + List::class.asTypeName() + .parameterizedBy(extensionClassName.copy(nullable = true)) + .copy(nullable = true), + ) + .addKdoc( + """ + May be used to represent additional information that is not part of the basic definition of the + resource. To make the use of extensions safe and manageable, there is a strict set of governance + applied to the definition and use of extensions. + """ + .trimIndent() + .sanitizeKDoc() + ) + .defaultValue("null") + .build() + ) + .addParameter( + ParameterSpec.builder("value", typeVariable.copy(nullable = true)) + .defaultValue("null") + .addKdoc("The actual value") + .build() + ) + .build() + ) + .addProperty( + PropertySpec.builder("id", STRING.copy(nullable = true)) + .initializer("id") + .mutable(true) + .addModifiers(KModifier.OVERRIDE) + .build() + ) + .addProperty( + PropertySpec.builder( + name = "extension", + type = + List::class.asTypeName() + .parameterizedBy(extensionClassName.copy(nullable = true)) + .copy(nullable = true), + ) + .initializer("extension") + .mutable(true) + .addModifiers(KModifier.OVERRIDE) + .build() + ) + .addProperty( + PropertySpec.builder("value", typeVariable.copy(nullable = true)) + .initializer("value") + .mutable(true) + .build() + ) + .addFunction(toElementFunction) + .addType(companionObject) + .addKdoc( + """ + A FHIR Enumeration type bound to a specific set of codes. It represents a constrained code + value from an enumerated list. + """ + .trimIndent() + .sanitizeKDoc() + ) + .build() + + return FileSpec.builder(packageName, "Enumeration").addType(enumClass).build() + } + + private fun createToElementFunction(elementClassName: ClassName): FunSpec { + return FunSpec.builder("toElement") + .addModifiers(KModifier.PUBLIC) + .returns(elementClassName.copy(nullable = true)) + .addStatement("if (id != null || extension != null) { return Element(id, extension) }") + .addStatement("return null") + .build() + } + + private fun createOfFunction( + elementClassName: ClassName, + typeVariable: TypeVariableName, + ): FunSpec { + return FunSpec.builder("of") + .addModifiers(KModifier.PUBLIC) + .addTypeVariable(typeVariable) + .addParameter(ParameterSpec.builder("value", typeVariable.copy(nullable = true)).build()) + .addParameter( + ParameterSpec.builder("element", elementClassName.copy(nullable = true)).build() + ) + .returns(ClassName("", "Enumeration").parameterizedBy(typeVariable).copy(nullable = true)) + .addStatement( + "return if (value == null && element == null) { null } else { Enumeration(element?.id, element?.extension, value = value) }" + ) + .build() + } + + private fun createCompanionObject(ofFunction: FunSpec): TypeSpec { + return TypeSpec.companionObjectBuilder() + .addModifiers(KModifier.PUBLIC) + .addFunction(ofFunction) + .build() + } +} diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/CodeSystem.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/CodeSystem.kt new file mode 100644 index 00000000..59a2b1c4 --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/CodeSystem.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen.schema + +import kotlinx.serialization.Serializable + +@Serializable +data class CodeSystem( + val id: String, + val url: String, + val name: String, + val description: String? = null, + val concept: List? = null, +) diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Concept.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Concept.kt new file mode 100644 index 00000000..2b998f23 --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Concept.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen.schema + +import kotlinx.serialization.Serializable + +@Serializable +data class Concept( + val code: String, + val display: String? = null, + val definition: String? = null, + val concept: List? = null, +) diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Extension.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Extension.kt new file mode 100644 index 00000000..d1f1aa0f --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/Extension.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen.schema + +import kotlinx.serialization.Serializable + +@Serializable +data class Extension( + val url: String, + val valueCode: String? = null, + val valueString: String? = null, + val valueBoolean: Boolean? = null, +) diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/MoreStructureDefinitions.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/MoreStructureDefinitions.kt index 38e3bcfc..12db0f12 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/MoreStructureDefinitions.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/MoreStructureDefinitions.kt @@ -23,11 +23,28 @@ import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.asTypeName import org.gradle.configurationcache.extensions.capitalized +const val ELEMENT_IS_COMMON_BINDING_EXTENSION_URL = + "http://hl7.org/fhir/StructureDefinition/elementdefinition-isCommonBinding" + +const val ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL = + "http://hl7.org/fhir/StructureDefinition/elementdefinition-bindingName" + val StructureDefinition.rootElements get() = snapshot?.element?.filter { it.id.matches("$name\\.[A-Za-z0-9]+(\\[x])?".toRegex()) } ?: emptyList() +val ValueSet.urlPart + get() = url.substringBeforeLast("|") + +// TODO Fix conflicting commonBinding name "PublicationStatus" used in +// Declared in StructureDefinition-specimen +// and present in http://hl7.org/fhir/ValueSet/publication-status in r5 +fun Include.isValueSystemSupported(): Boolean = + !system.isNullOrBlank() && + !system.startsWith("urn", ignoreCase = true) && + system !in setOf("http://hl7.org/fhir/specimen-combined", "http://unitsofmeasure.org") + /** * Returns [Element]s from the [StructureDefinition] representing data members of the specified * class. @@ -99,6 +116,71 @@ fun Element.isBackboneElement(): Boolean { return typeCode == "BackboneElement" || typeCode == "Element" } +/** Retrieve an [Extension] with the provided url otherwise return `null` */ +fun Element.getExtension(withUrl: String): Extension? { + if (binding == null || binding.extension.isNullOrEmpty()) return null + return binding.extension.find { it.url == withUrl } +} + +fun Extension.isCommonBinding(): Boolean = + url == ELEMENT_IS_COMMON_BINDING_EXTENSION_URL && valueBoolean == true + +/** + * Retrieve the [ValueSet.url] from the [Element]. Extract the URI part from the set url excluding + * the FHIR versions E.g. http://hl7.org/fhir/ValueSet/task-status|4.3.0, will return + * "http://hl7.org/fhir/ValueSet/task-status" + */ +fun Element.getValueSetUrl() = this.binding?.valueSet?.substringBeforeLast("|") + +/** + * Check if [Element]'s type is `code` and it has an extension with url + * `http://hl7.org/fhir/StructureDefinition/elementdefinition-bindingName`, and the [Element.base]' + * path does not start with a "Resource." and name (in PascalCase) is not blank. Some names in FHIR + * are have symbols e.g. Element id "Requirements.statement.conformance" of + * StructureDefinition-Requirements, the binding name is "??". It returns true if the enumeration + * was/is to be generated from a ValueSet with systems that are supported. ValueSets with systems + * beginning with "urn" are currently excluded. + */ +fun Element.typeIsEnumeratedCode(valueSetMap: Map): Boolean { + val valueSet = valueSetMap[getValueSetUrl()] + if (valueSet == null) return false + val isValidValueSet = valueSet.compose?.include?.all { it.isValueSystemSupported() } + return isValidValueSet == true && + base?.path?.startsWith("Resource.") != true && + base?.path?.startsWith("CanonicalResource.") != true && + this.type?.count { it.code.equals("code", ignoreCase = true) } == 1 && + !this.getExtension(ELEMENT_DEFINITION_BINDING_NAME_EXTENSION_URL) + ?.valueString + ?.toPascalCase() + .isNullOrBlank() +} + +/** + * Format the string by replacing all non-alphanumeric-characters with an empty string and + * capitalize the first character. Example: v3.ObservationInterpretation -> + * V3ObservationInterpretation + */ +fun String.toPascalCase(): String { + val camelCaseString = + split("-").joinToString("") { it -> + it.replaceFirstChar { char -> + if (it.indexOf(char) == 0) { + char.uppercaseChar() + } else { + char + } + } + } + + // For consistency convert all uppercase word to lowercase then capitalize + val formattedString = + if (camelCaseString.all { it.isUpperCase() }) camelCaseString.lowercase().capitalized() + else camelCaseString + + val alphanumericString = formattedString.replace(Regex("[^a-zA-Z0-9]+"), "") + return alphanumericString +} + fun Element.getElementName() = path.substringAfterLast('.').removeSuffix("[x]") /** @@ -315,3 +397,12 @@ private fun Element.getContentReferenceType(packageName: String): TypeName? { } return null } + +/** + * Sanitizes the string for KDoc, replacing character sequences that could break the comment block. + * + * See also: https://github.com/square/kotlinpoet/issues/887. + */ +fun String.sanitizeKDoc(): String { + return this.replace("/*", "/*") +} diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/StructureDefinition.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/StructureDefinition.kt index a70db61f..53a43abd 100644 --- a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/StructureDefinition.kt +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/StructureDefinition.kt @@ -65,6 +65,15 @@ data class Element( val isModifierReason: String? = null, val representation: List? = null, val condition: List? = null, + val binding: Binding? = null, +) + +@Serializable +data class Binding( + val extension: List? = null, + val strength: String, + val description: String? = null, + val valueSet: String? = null, ) @Serializable data class Base(val path: String, val min: Int, val max: String) diff --git a/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/ValueSet.kt b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/ValueSet.kt new file mode 100644 index 00000000..96f7945f --- /dev/null +++ b/fhir-codegen/gradle-plugin/src/main/kotlin/com/google/fhir/codegen/schema/ValueSet.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.fhir.codegen.schema + +import kotlinx.serialization.Serializable + +@Serializable +data class ValueSet( + val id: String, + val url: String, + val name: String, + val description: String? = null, + val compose: Compose? = null, +) + +@Serializable data class Compose(val include: List) + +@Serializable data class Include(val system: String? = null, val concept: List? = null)