Adding a Custom Code Generator¶
Kraft's code generation phase is pluggable via a ServiceLoader-based SPI. Instead of (or alongside) the built-in extension function generator, you can provide your own MapperGenerator that consumes the same intermediate representation (MapperDescriptor) and emits any output you need — interface adapters, JSON schemas, documentation, test stubs, or anything else.
Data Structure Diagrams¶
SPI Wiring¶
How your generator is discovered and invoked by the processor:
classDiagram
direction LR
class MapperGeneratorProvider {
<<fun interface>>
+create(env) MapperGenerator
}
class GeneratorEnvironment {
+logger: KSPLogger
+options: Map~String, String~
+config: GenerationConfig
}
class GenerationConfig {
+functionNameTemplate: String
+functionNameFor(descriptor) String
}
class MapperGenerator {
<<fun interface>>
+generate(descriptor, codeGenerator)
}
class EnumMapperGeneratorProvider {
<<fun interface>>
+create(env) EnumMapperGeneratorSpi
}
class EnumMapperGeneratorSpi {
<<fun interface>>
+generate(descriptors, codeGenerator)
}
MapperGeneratorProvider --> GeneratorEnvironment : receives
MapperGeneratorProvider --> MapperGenerator : creates
GeneratorEnvironment --> GenerationConfig
MapperGenerator --> MapperDescriptor : consumes
EnumMapperGeneratorProvider --> GeneratorEnvironment : receives
EnumMapperGeneratorProvider --> EnumMapperGeneratorSpi : creates
EnumMapperGeneratorSpi --> EnumMappingDescriptor : consumes
MapperDescriptor — The Central IR¶
The MapperDescriptor is what your generate() method receives. It contains everything needed to emit mapper code for one source-target pair:
classDiagram
direction TB
class MapperDescriptor {
+id: MapperId
+sourceType: TypeInfo
+targetType: TypeInfo
+source: MappingSource
+propertyMappings: List
+nestedMappings: List
+enumMappings: List
+converters: List
}
class MapperId {
+sourceQualifiedName: String
+targetQualifiedName: String
}
class TypeInfo {
+packageName: String
+simpleName: String
+isNullable: Boolean
+qualifiedName: String
}
class PropertyInfo {
+name: String
+type: TypeInfo
+hasDefault: Boolean
}
class MappingSource {
<<sealed>>
}
class ClassAnnotation {
+direction: MappingDirection
}
class ConfigObject
class NestedMappingDescriptor {
+nestedMapperId: MapperId
+sourceType: TypeInfo
+targetType: TypeInfo
+collectionKind: CollectionKind?
}
class EnumMappingDescriptor {
+sourceType: TypeInfo
+targetType: TypeInfo
+entries: List~EnumEntryMapping~
}
class ConverterDescriptor {
+targetPropertyName: String
+sourcePropertyName: String?
+functionName: String
+isExtension: Boolean
+resolvedDirection: ConverterDirection
}
MapperDescriptor --> MapperId : id
MapperDescriptor --> TypeInfo : sourceType / targetType
MapperDescriptor --> MappingSource : source
MapperDescriptor --> NestedMappingDescriptor : nestedMappings *
MapperDescriptor --> EnumMappingDescriptor : enumMappings *
MapperDescriptor --> ConverterDescriptor : converters *
MappingSource <|-- ClassAnnotation
MappingSource <|-- ConfigObject
NestedMappingDescriptor --> MapperId
NestedMappingDescriptor --> TypeInfo
EnumMappingDescriptor --> TypeInfo
PropertyMappingStrategy Variants¶
Each target property is resolved to exactly one strategy. Your generator iterates descriptor.propertyMappings and handles each variant:
classDiagram
direction TB
class PropertyMappingStrategy {
<<sealed>>
+targetProperty: PropertyInfo
}
class Direct {
+sourceProperty: PropertyInfo
}
class Renamed {
+sourceProperty: PropertyInfo
}
class ConverterFunction {
+source: ConverterSource
+converter: ConverterDescriptor
}
class NestedMapper {
+sourceProperty: PropertyInfo
+nestedMappingDescriptor: NestedMappingDescriptor
}
class Constant {
+expression: String
}
class Ignored
class ConverterSource {
<<sealed>>
}
class Property {
+info: PropertyInfo
}
class WholeObject {
+sourceType: TypeInfo
}
PropertyMappingStrategy <|-- Direct
PropertyMappingStrategy <|-- Renamed
PropertyMappingStrategy <|-- ConverterFunction
PropertyMappingStrategy <|-- NestedMapper
PropertyMappingStrategy <|-- Constant
PropertyMappingStrategy <|-- Ignored
ConverterFunction --> ConverterSource
ConverterSource <|-- Property
ConverterSource <|-- WholeObject
Step-by-Step Guide¶
1. Create a Gradle Module¶
Create a new JVM module (or standalone project) for your generator:
// my-kraft-generator/build.gradle.kts
plugins {
kotlin("jvm")
}
dependencies {
implementation("com.blu3berry.kraft:kraft-core:<version>")
// kraft-core brings in the KSP API transitively.
// Add KotlinPoet only if your generator needs it — it is NOT required.
}
Note
kraft-core does not depend on KotlinPoet. You are free to use any code-emission strategy — raw string templates, KotlinPoet, or your own writer.
2. Implement MapperGeneratorProvider¶
This is the ServiceLoader entry point. It receives a GeneratorEnvironment and returns your MapperGenerator:
class JsonSchemaGeneratorProvider : MapperGeneratorProvider {
override fun create(environment: GeneratorEnvironment): MapperGenerator {
return JsonSchemaGenerator(
logger = environment.logger,
config = environment.config,
options = environment.options
)
}
}
3. Implement MapperGenerator¶
Your generator receives one MapperDescriptor per source-target pair. Use its fields to emit whatever output you need:
class JsonSchemaGenerator(
private val logger: KSPLogger,
private val config: GenerationConfig,
private val options: Map<String, String>
) : MapperGenerator {
override fun generate(descriptor: MapperDescriptor, codeGenerator: CodeGenerator) {
val targetName = descriptor.targetType.simpleName
val fileName = "${targetName}_schema"
// Open an output file via the KSP CodeGenerator
val file = codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = false),
packageName = "",
fileName = fileName,
extensionName = "json"
)
val schema = buildString {
appendLine("{")
appendLine(""" "type": "object",""")
appendLine(""" "title": "$targetName",""")
appendLine(""" "properties": {""")
descriptor.propertyMappings.forEachIndexed { index, strategy ->
val propName = strategy.targetProperty.name
val propType = strategy.targetProperty.type.simpleName
val comma = if (index < descriptor.propertyMappings.lastIndex) "," else ""
// Map Kotlin types to JSON Schema types
val jsonType = when (propType) {
"String" -> "string"
"Int", "Long" -> "integer"
"Double", "Float" -> "number"
"Boolean" -> "boolean"
else -> "object"
}
appendLine(""" "$propName": { "type": "$jsonType" }$comma""")
}
appendLine(" }")
appendLine("}")
}
file.write(schema.toByteArray())
file.close()
logger.info("Generated JSON schema: $fileName.json")
}
}
4. Handle PropertyMappingStrategy Variants¶
When iterating descriptor.propertyMappings, handle each variant according to your output format:
fun processStrategies(descriptor: MapperDescriptor) {
for (strategy in descriptor.propertyMappings) {
when (strategy) {
is PropertyMappingStrategy.Direct -> {
// Same name, same type: source.x -> target.x
val name = strategy.targetProperty.name
val type = strategy.targetProperty.type
}
is PropertyMappingStrategy.Renamed -> {
// Different name: source.oldName -> target.newName
val sourceName = strategy.sourceProperty.name
val targetName = strategy.targetProperty.name
}
is PropertyMappingStrategy.ConverterFunction -> {
// Custom converter applied
val converter = strategy.converter
val targetName = strategy.targetProperty.name
}
is PropertyMappingStrategy.NestedMapper -> {
// Delegates to a child mapper
val nested = strategy.nestedMappingDescriptor
val isCollection = nested.isCollection
}
is PropertyMappingStrategy.Constant -> {
// Literal expression (reserved for future use)
val expr = strategy.expression
}
is PropertyMappingStrategy.Ignored -> {
// Property skipped — has a default value
}
}
}
}
5. (Optional) Implement Enum Generator¶
If you also want to customize enum mapping output, implement the enum SPI:
class MyEnumGeneratorProvider : EnumMapperGeneratorProvider {
override fun create(environment: GeneratorEnvironment): EnumMapperGeneratorSpi {
return MyEnumGenerator(environment.logger, environment.config)
}
}
class MyEnumGenerator(
private val logger: KSPLogger,
private val config: GenerationConfig
) : EnumMapperGeneratorSpi {
override fun generate(
descriptors: List<EnumMappingDescriptor>,
codeGenerator: CodeGenerator
) {
for (descriptor in descriptors) {
// descriptor.sourceType / descriptor.targetType — the enum types
// descriptor.entries — List<EnumEntryMapping> with source/target constant names
}
}
}
6. Register via ServiceLoader¶
Create the service file so AutoMapperProcessor can discover your provider at compile time.
For class mappers, create:
com.example.JsonSchemaGeneratorProvider
For enum mappers (optional), create:
com.example.MyEnumGeneratorProvider
7. Wire into the Consumer Build¶
Add your generator module as a KSP dependency alongside kraft-ksp:
// app/build.gradle.kts
dependencies {
ksp("com.blu3berry.kraft:kraft-ksp:<version>")
ksp("com.example:my-kraft-generator:<version>")
}
Both your generator and the built-in one are on the KSP classpath, but only one MapperGeneratorProvider is used per compilation (see discovery rules below).
GeneratorEnvironment Reference¶
Your provider's create() method receives a GeneratorEnvironment with:
| Field | Type | Description |
|---|---|---|
logger |
KSPLogger |
KSP logger — use info(), warn(), error() for compile-time messages. |
options |
Map<String, String> |
All KSP processor options from build.gradle.kts. Read custom options here. |
config |
GenerationConfig |
Holds the functionNameTemplate (default "to${target}"). Use config.functionNameFor(descriptor) to get the resolved function name. |
Discovery and Fallback Behavior¶
AutoMapperProcessor uses java.util.ServiceLoader to find providers on the KSP classpath:
| Providers found | Behavior |
|---|---|
| 0 | Falls back to the built-in ExtensionMapperGenerator. |
| 1 | Uses that single provider. |
| 2+ | Uses the first provider found and logs a warning. |
The same logic applies independently to EnumMapperGeneratorProvider.
Warning
If you provide a custom MapperGeneratorProvider, it replaces the built-in extension function generator entirely. If you still want extension functions alongside your custom output, call through to ExtensionMapperGenerator from within your generator, or structure your project so they are separate KSP executions.
Tips¶
- Function naming: Use
config.functionNameFor(descriptor)to respect the user'skraft.functionNameFormatKSP option. This keeps your generator consistent with the naming convention users configure. - Incremental processing: Pass originating
KSFilereferences toDependencies(...)when creating output files. This allows KSP to skip regeneration when source files haven't changed. -
Custom KSP options: Read your own options from
environment.options. For example, configure them inbuild.gradle.kts:Then read in your generator:
-
Accessing nested info:
descriptor.nestedDependenciesgives you the set ofMapperIds this mapper depends on — useful if your output format needs to declare imports or references to child mappers. - Collection handling: Check
NestedMappingDescriptor.collectionKindto distinguishListvsSetwrappers on nested properties.