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

Merged
merged 47 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 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
57f2b73
Generate Enumeration for supported versions
ellykits Jun 4, 2025
2035e39
Refactor enum generation
ellykits Jun 4, 2025
9029290
Update README
ellykits Jun 4, 2025
8f4394c
Split extensions into separate files
ellykits Jun 9, 2025
6f2e8ad
Refactor implementation
ellykits Jun 9, 2025
d8302db
Delete unused annotations
ellykits Jun 9, 2025
07a0b91
Delete unused property
ellykits Jun 9, 2025
b15baf0
Update README
ellykits Jun 9, 2025
8d191cd
Refactor implementation to use idiomatic functional programming concepts
ellykits Jun 9, 2025
0fc46e1
Update README.md
ellykits Jun 11, 2025
fedae8b
Fix setting null for display and definition properties
ellykits Jun 11, 2025
10615b5
Refactor code
ellykits Jun 11, 2025
2a52fa4
Improve kdoc and inline comments documentation
ellykits Jun 11, 2025
28d05d9
Refactor model Concept
ellykits Jun 12, 2025
0f0ec1d
Format table
ellykits Jun 12, 2025
0330b05
Implement String#capitalized function
ellykits Jun 12, 2025
eda675e
Refactor enum class code generation
ellykits Jun 12, 2025
459a056
Improve code documentation
ellykits Jun 12, 2025
ca6bd8c
Refactor code for generating enum constants
ellykits Jun 14, 2025
263f2ae
Update Include.isValueSystemSupported() function logic
ellykits Jun 14, 2025
b573ee1
Fix grammar
ellykits Jun 17, 2025
d2b7b29
Improve documentation and code refactor
ellykits Jun 19, 2025
be9cd27
Merge branch 'implement-enums' of github.com:ellykits/kotlin-fhir int…
ellykits Jun 19, 2025
b2e1558
Refactor code to re-use model and surrogate TypeSpecGenerators
ellykits Jun 23, 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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ as well as a subset of
| macosArm64 | `-macosarm64` | ⛔ |
| iosSimulatorArm64 | `-iossimulatorarm64` | ✅ |
| iosX64 | `-iosx64` | ✅ |
| iosArm64 | `-iosarm64` | ✅ |
| iosArm64 | `-iosarm64` | ✅ |

The library does not support `macos` targets in the tier 1 list, or any
[tier2](https://kotlinlang.org/docs/native-target-support.html#tier-2) and
Expand Down Expand Up @@ -195,6 +195,49 @@ 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`.

### Mapping FHIR ValueSets to Kotlin Enums
`Enums` are generated for `code` elements that are [bound](https://hl7.org/fhir/R5/terminologies.html#binding) to a `ValueSet`.
The constants in the generated Kotlin `enum` classes are derived from the `code` property of concepts defined in FHIR `CodeSystem` and `ValueSet` resources.

#### Shared vs. Local Enums

- If the `StructureDefinition` defines an element with a [**common binding**](https://build.fhir.org/ig/HL7/fhir-extensions/StructureDefinition-elementdefinition-isCommonBinding.html), a **shared enum** is generated and placed in the `com.google.fhir.model.<r4|r4b|r5>` package.
**Example:** `AdministrativeGender`
- If the element uses a **non-common binding**, a **local enum** is created inside the associated parent class.
**Example:** `NameUse` inside the `HumanName` class

#### Enum Naming and Content

- The **enum class name** is based on the value of the [binding name](http://hl7.org/fhir/extensions/StructureDefinition-elementdefinition-bindingName.html) extension
- The **enum constants** are sourced from the [concepts](https://hl7.org/fhir/valueset-definitions.html#ValueSet.compose.include.concept) in the binding's `ValueSet`, which are a subset of the concepts in the referenced [code system](https://hl7.org/fhir/valueset-definitions.html#ValueSet.compose.include.system).
- If the value set's `concept` element is empty, the entire set of the code system's concepts is used.

| FHIR concept <img src="images/fhir.png" alt="kotlin" style="height: 1em"/> | Kotlin concept <img src="images/kotlin.png" alt="kotlin" style="height: 1em"/> |
|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
| 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`) |

To comply with Kotlin’s enum naming convention—which requires 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 | For codes that are full URLs, extract and return the last segment after the dot | `http://hl7.org/fhirpath/System.DateTime` from [CodeSystem-fhirpath-types](http://hl7.org/fhir/R5/codesystem-fhirpath-types.html) | `DateTime` |
| 2 | Specific special characters are replaced with readable keywords | `>=` from [CodeSystem-quantity-comparator](http://hl7.org/fhir/R5/codesystem-quantity-comparator.html) | `GreaterThanOrEqualTo` |
| | | `>` | `GreaterThan` |
| | | `<` | `LessThan` |
| | | `<=` | `LessThanOrEqualTo` |
| | | `!=` or `<>` | `NotEqualTo` |
| | | `=` | `EqualTo` |
| | | `*` | `Multiply` |
| | | `+` | `Plus` |
| | | `-` | `Minus` |
| | | `/` | `Divide` |
| | | `%` | `Percent` |
| 3.1 | Replace all non-alphanumeric characters including dashes (`-`) and dots (`.`) with underscore | `4.0.1` from [CodeSystem-FHIR-version](http://hl7.org/fhir/R5/codesystem-FHIR-version.html) | `4_0_1` |
| 3.2 | Prefix codes starting with a digit with an underscore | `4.0.1` from [CodeSystem-FHIR-version](http://hl7.org/fhir/R5/codesystem-FHIR-version.html) | `_4_0_1` |
| 3.3 | Apply PascalCase to each segment between underscores while preserving the underscores | `entered-in-error` from [CodeSystem-document-reference-status](http://hl7.org/fhir/R5/codesystem-document-reference-status.html) | `Entered_In_Error` |

### 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,308 @@
/*
* 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.CodeSystem
import com.google.fhir.codegen.schema.codesystem.Concept as CodeSystemConcept
import com.google.fhir.codegen.schema.isValueSystemSupported
import com.google.fhir.codegen.schema.normalizeEnumName
import com.google.fhir.codegen.schema.sanitizeKDoc
import com.google.fhir.codegen.schema.valueset.Concept as ValueSystemConcept
import com.google.fhir.codegen.schema.valueset.ValueSet
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.asClassName

/**
* 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.
*/
class EnumTypeSpecGenerator(val codeSystemMap: Map<String, CodeSystem>) {

fun generate(enumClassName: String, valueSet: ValueSet): TypeSpec? {
val fhirEnum = generateEnum(valueSet, codeSystemMap) ?: return null
val typeSpec =
TypeSpec.enumBuilder(enumClassName)
.apply {
fhirEnum.description?.sanitizeKDoc()?.let { addKdoc(it) }
primaryConstructor(
FunSpec.constructorBuilder()
.addParameter("code", String::class)
.addParameter("system", String::class)
.addParameter("display", String::class.asClassName().copy(nullable = true))
.addParameter("definition", String::class.asClassName().copy(nullable = true))
.build()
)

fhirEnum.constants.forEach {
addEnumConstant(
it.name,
TypeSpec.anonymousClassBuilder()
.apply {
if (!it.definition.isNullOrBlank()) addKdoc("%L", it.definition.sanitizeKDoc())
addSuperclassConstructorParameter("%S", it.code)
addSuperclassConstructorParameter("%S", it.system)
if (it.display != null) {
addSuperclassConstructorParameter("%S", it.display)
} else {
addSuperclassConstructorParameter("null")
}
if (it.definition != null) {
addSuperclassConstructorParameter("%S", it.definition)
} else {
addSuperclassConstructorParameter("null")
}
}
.build(),
)
.addProperty(
PropertySpec.builder("code", String::class, KModifier.PRIVATE)
.initializer("code")
.build()
)
.addProperty(
PropertySpec.builder("system", String::class, KModifier.PRIVATE)
.initializer("system")
.build()
)
.addProperty(
PropertySpec.builder(
"display",
String::class.asClassName().copy(nullable = true),
KModifier.PRIVATE,
)
.initializer("display")
.build()
)
.addProperty(
PropertySpec.builder(
"definition",
String::class.asClassName().copy(nullable = true),
KModifier.PRIVATE,
)
.initializer("definition")
.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(
FunSpec.builder("getSystem")
.addModifiers(KModifier.PUBLIC)
.returns(String::class)
.addStatement("return system")
.build()
)
addFunction(
FunSpec.builder("getDisplay")
.addModifiers(KModifier.PUBLIC)
.returns(String::class.asClassName().copy(nullable = true))
.addStatement("return display")
.build()
)
addFunction(
FunSpec.builder("getDefinition")
.addModifiers(KModifier.PUBLIC)
.returns(String::class.asClassName().copy(nullable = true))
.addStatement("return definition")
.build()
)
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
}

/**
* Instantiates 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.
*/
private fun generateEnum(valueSet: ValueSet, codeSystemMap: Map<String, CodeSystem>): FhirEnum? {
val enumConstants =
valueSet.compose
?.include
?.filter { it.isValueSystemSupported() }
?.flatMap { include ->
val system = include.system!!
generateEnumConstants(
system = system,
codeSystemConcepts = codeSystemMap[system]?.concept,
valueSetConcepts = include.concept,
)
} ?: emptyList()
return if (enumConstants.isNotEmpty()) {
FhirEnum(valueSet.description, enumConstants)
} else {
null
}
}

private fun generateEnumConstants(
system: String,
codeSystemConcepts: List<CodeSystemConcept>?,
valueSetConcepts: List<ValueSystemConcept>?,
): List<FhirEnumConstant> {
val valueSetConceptCodeSet = valueSetConcepts?.mapTo(hashSetOf()) { it.code } ?: emptySet()
val flattenedCodeSystemConcepts = flattenCodeSystemConcepts(codeSystemConcepts)
// Select concepts for enum generation. Prefer flattened CodeSystem concepts filtered
// by codes present in the ValueSet. If no CodeSystem concepts exist, fall back to
// ValueSet concepts, which include only code and system—the key properties for enums.
// To address missing concepts in R4B and R5, we may need to add v3 CodeSystems like
// CodeSystem-v3-AdministrativeGender, which are present in R4 but absent in later versions.
return if (valueSetConceptCodeSet.isNotEmpty()) {
if (flattenedCodeSystemConcepts.isNotEmpty()) {
flattenedCodeSystemConcepts
.filter { valueSetConceptCodeSet.contains(it.code) }
.map { concept ->
FhirEnumConstant(
code = concept.code,
system = system,
name = concept.code.formatEnumConstantName(),
display = concept.display,
definition = concept.definition,
)
}
} else {
valueSetConcepts!!.map { concept ->
FhirEnumConstant(
code = concept.code,
system = system,
name = concept.code.formatEnumConstantName(),
display = concept.display,
definition = concept.definition,
)
}
}
} else
flattenedCodeSystemConcepts.map { concept ->
FhirEnumConstant(
code = concept.code,
system = system,
name = concept.code.formatEnumConstantName(),
display = concept.display,
definition = concept.definition,
)
}
}

/**
* Flattens a list of Concept objects, including all nested concepts, into a single, flat list.
*/
private fun flattenCodeSystemConcepts(
concepts: List<CodeSystemConcept>?
): List<CodeSystemConcept> =
concepts?.flatMap { currentConcept ->
buildList {
add(currentConcept)
currentConcept.concept?.let { nestedConcepts ->
addAll(flattenCodeSystemConcepts(nestedConcepts))
}
}
} ?: emptyList()

/**
* 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.1. Replace all remaining non-alphanumeric characters with underscores Example:
* "some-value.123" → "some_value_123"
*
* 3.2. If the string starts with a digit, prefix it with an underscore to make it a valid
* identifier Example: "123test" → "_123test"
*
* 3.3. 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 "GreaterThanOrEqualTo"
"<=" -> return "LessThanOrEqualTo"
"<>",
"!=" -> return "NotEqualTo"
"=" -> return "EqualTo"
"*" -> 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.first().isDigit()) {
"_$withUnderscores"
} else {
withUnderscores
}
return prefixed.split("_").joinToString("_") { part ->
if (part.isEmpty()) "" else part.normalizeEnumName()
}
}
}
Loading
Loading