Skip to content

Generate enum classes #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
acb26cd
Implement models for ValueSet and CodeSystem
ellykits Apr 30, 2025
c3d4f4f
Update StructureDefinition model to inlcude Binding property
ellykits Apr 30, 2025
3e93a3a
Implement TypeSpec for generating enum classes
ellykits Apr 30, 2025
32e5c7f
Implement logic for generating enum classes
ellykits Apr 30, 2025
e5ef44c
Merge branch 'google:main' into implement-enums
ellykits Apr 30, 2025
78eec34
Delete commented code
ellykits May 1, 2025
22f9d1f
Fix imports
ellykits May 1, 2025
94a0337
Implement EnumerationTypeGenerator
ellykits May 2, 2025
4738d83
Wrap enumarated codes type with Enumeration class
ellykits May 5, 2025
8df843a
Fix using enums types for r4 and r4b
ellykits May 7, 2025
3d52009
Implement Enumeration of and toElement functions
ellykits May 8, 2025
e803d2e
Resolve issues with generated surrogate classes
ellykits May 12, 2025
921df63
Delete unused properties
ellykits May 12, 2025
428e133
Implement functionality for retrieving enum constant from code
ellykits May 12, 2025
3ac0125
Implement the control getDisplay and getDefinition methods
ellykits May 12, 2025
0f4bc97
Fix enum constant formatting
ellykits May 12, 2025
641f441
Generate enums only for commonbinding elements
ellykits May 12, 2025
fd7790e
Revert json formatting
ellykits May 12, 2025
0a2d310
Fix failing test
ellykits May 12, 2025
9f6e905
Merge branch 'main' into implement-enums
ellykits May 12, 2025
9a72b99
Resolve PR feedback
ellykits May 26, 2025
7453d2f
Document enum class constant naming convention
ellykits May 26, 2025
ae1e472
Merge remote-tracking branch 'ellykits/main' into HEAD
ellykits May 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Comment on lines +164 to +165
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe create a separate table - since this whole table is about structure definitions, backbone elements and choice of data types are also structure definitions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually i think this shoudl totally be a separate section:

Mapping FHIR value sets to Kotlin

| 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`) |

Expand Down Expand Up @@ -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 |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table is great!

One thing - is it possible to add a column that includes some examples of codes that are being converted under these rules.

For example, I imagine a lot of the comparison ones (e.g. >=, <, etc.) in this table are actually from some specific value set / code system?

|--------|----------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|-------------------|
| 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, CodeSystem>,
): 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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: should the enum values just include system, display, and definition fields?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, this is implemented similarly to the HAPI structures in Java. They include the most useful information that can be obtained from the enum class.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm what I mean here is that your enum classes only include code e.g.:

public enum class AdministrativeGender(
  private val code: String,
) {
  ...
}

But what about including other values, so that you don't have to generate those values in the functions:

  public fun getSystem(): String = "http://hl7.org/fhir/administrative-gender"

  public fun getDisplay(): String? = when (this) {
    Male -> "Male"
    Female -> "Female"
    Other -> "Other"
    Unknown -> "Unknown"
    else -> null
  }

which I find to be quite cumbersome

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. We can include the system, its always-present display is not.

)

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<FhirEnumConstant>,
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<String, CodeSystem>): FhirEnum? {
return valueSet.compose
?.include
?.filter { it.isValueSystemSupported() }
?.let {
val enumConstants = mutableListOf<FhirEnumConstant>()
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<Concept>?,
valueSetConcepts: List<Concept>?,
enumConstants: MutableList<FhirEnumConstant>,
) {
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<Concept>,
system: String,
enumConstants: MutableList<FhirEnumConstant>,
expectedCodesSet: Set<String>?,
) {
val arrayDeque: ArrayDeque<Concept> = 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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +51,9 @@ object FhirCodegen {
packageName: String,
structureDefinition: StructureDefinition,
isBaseClass: Boolean,
valueSetMap: Map<String, ValueSet>,
codeSystemMap: Map<String, CodeSystem>,
commonBindingValueSetUrls: MutableMap<String, HashSet<String>>,
): List<FileSpec> {
val modelClassName = ClassName(packageName, structureDefinition.name.capitalized())
val modelFileSpec = FileSpec.builder(modelClassName)
Expand All @@ -65,6 +70,9 @@ object FhirCodegen {
isBaseClass,
surrogateFileSpec,
serializerFileSpec,
valueSetMap = valueSetMap,
codeSystemMap = codeSystemMap,
commonBindingValueSetUrls = commonBindingValueSetUrls,
)
)
.addSuppressAnnotation()
Expand All @@ -81,6 +89,7 @@ object FhirCodegen {
SurrogateTypeSpecGenerator.generate(
ClassName(packageName, structureDefinition.name),
structureDefinition.rootElements,
valueSetMap,
)
)
.addAnnotation(
Expand Down
Loading
Loading