Skip to content

Development Guide

Table of Contents

Getting Started

Prerequisites

  • Java: JDK 21 or higher
  • Maven: 3.9.0 or higher
  • IDE: IntelliJ IDEA, Eclipse, or VS Code with Java extensions
  • Docker: For integration testing (optional)
  • Git: For version control

Clone the Repository

git clone https://github.com/FortuneN/kete.git
cd kete

IDE Setup

IntelliJ IDEA

  1. Open the project: File β†’ Open β†’ Select pom.xml
  2. Wait for Maven import to Complete
  3. Set JDK: File β†’ Project Structure β†’ Project SDK β†’ 21
  4. Enable annotation processing: Settings β†’ Build β†’ Compiler β†’ Annotation Processors β†’ Enable

VS Code

  1. Install extensions:
  2. Extension Pack for Java
  3. Maven for Java
  4. Open folder
  5. Select Java 21 in status bar
  6. Maven will auto-import dependencies

Eclipse

  1. Import project: File β†’ Import β†’ Maven β†’ Existing Maven Projects
  2. Select project directory
  3. Set JDK: Properties β†’ Java Build Path β†’ Libraries β†’ Add Library β†’ JRE System Library

Project Structure

kete/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ main/
β”‚   β”‚   β”œβ”€β”€ java/io/github/fortunen/kete/
β”‚   β”‚   β”‚   β”œβ”€β”€ CertificateLoader.java            # Abstract certificate loader
β”‚   β”‚   β”‚   β”œβ”€β”€ Component.java                    # DI annotation
β”‚   β”‚   β”‚   β”œβ”€β”€ Configuration.java                # Config parser
β”‚   β”‚   β”‚   β”œβ”€β”€ Constants.java                    # Constants
β”‚   β”‚   β”‚   β”œβ”€β”€ Destination.java                  # Abstract destination
β”‚   β”‚   β”‚   β”œβ”€β”€ DestinationConfig.java            # Destination config base
β”‚   β”‚   β”‚   β”œβ”€β”€ DestinationPooledObjectFactory.java # Destination pooling
β”‚   β”‚   β”‚   β”œβ”€β”€ EventMessage.java                 # Event data record
β”‚   β”‚   β”‚   β”œβ”€β”€ Matcher.java                      # Abstract matcher
β”‚   β”‚   β”‚   β”œβ”€β”€ MatchMode.java                    # Match mode enum
β”‚   β”‚   β”‚   β”œβ”€β”€ OAuthMaterial.java                # OAuth configuration
β”‚   β”‚   β”‚   β”œβ”€β”€ Provider.java                     # Event handler
β”‚   β”‚   β”‚   β”œβ”€β”€ ProviderFactory.java              # Lifecycle mgmt
β”‚   β”‚   β”‚   β”œβ”€β”€ Route.java                        # Route configuration
β”‚   β”‚   β”‚   β”œβ”€β”€ Serializer.java                   # Abstract serializer
β”‚   β”‚   β”‚   β”œβ”€β”€ SerializerRoutes.java             # Serializer-routes mapping
β”‚   β”‚   β”‚   β”œβ”€β”€ TlsMaterial.java                  # TLS configuration
β”‚   β”‚   β”‚   β”œβ”€β”€ certificateloaders/               # Certificate loader implementations
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ DerFileBase64CertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ DerFilePathCertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ JksFileBase64CertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ JksFilePathCertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ PemFileBase64CertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ PemFilePathCertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ PemFileTextCertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Pkcs12FileBase64CertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Pkcs12FilePathCertificateLoader.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Pkcs7FileBase64CertificateLoader.java
β”‚   β”‚   β”‚   β”‚   └── Pkcs7FilePathCertificateLoader.java
β”‚   β”‚   β”‚   β”œβ”€β”€ destinations/                     # Destination implementations
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Amqp091Destination.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Amqp091DestinationConfig.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Amqp1Destination.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Amqp1DestinationConfig.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ HttpDestination.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ HttpDestinationConfig.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ KafkaDestination.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ KafkaDestinationConfig.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Mqtt3Destination.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Mqtt3DestinationConfig.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ Mqtt5Destination.java
β”‚   β”‚   β”‚   β”‚   └── Mqtt5DestinationConfig.java
β”‚   β”‚   β”‚   β”œβ”€β”€ matchers/                         # Matcher implementations
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ GlobMatcher.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ListMatcher.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ RegexMatcher.java
β”‚   β”‚   β”‚   β”‚   └── SqlMatcher.java
β”‚   β”‚   β”‚   β”œβ”€β”€ serializers/                      # Serializer implementations
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ CborSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ CsvSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ JsonSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ PropertiesSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ SmileSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ TomlSerializer.java
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ XmlSerializer.java
β”‚   β”‚   β”‚   β”‚   └── YamlSerializer.java
β”‚   β”‚   β”‚   └── utils/                            # Utility classes
β”‚   β”‚   β”‚       β”œβ”€β”€ CertificateUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ ConfigurationUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ DestinationUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ FileUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ IocUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ MatcherUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ RetryUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ RouteUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ SerializerUtils.java
β”‚   β”‚   β”‚       β”œβ”€β”€ TemplateUtils.java
β”‚   β”‚   β”‚       └── ValidationUtils.java
β”‚   β”‚   └── resources/
β”‚   β”‚       └── META-INF/services/
β”‚   β”‚           └── org.keycloak.events.EventListenerProviderFactory
β”‚   └── tests/
β”‚       β”œβ”€β”€ java/io/github/fortunen/kete/
β”‚       β”‚   β”œβ”€β”€ certificateloaders/               # Certificate loader tests
β”‚       β”‚   β”œβ”€β”€ destinationpooledobjectfactory/   # Pool factory tests
β”‚       β”‚   β”œβ”€β”€ destinations/                     # Destination tests
β”‚       β”‚   β”œβ”€β”€ e2e/                              # End-to-end tests
β”‚       β”‚   β”œβ”€β”€ eventmessage/                     # EventMessage tests
β”‚       β”‚   β”œβ”€β”€ matchers/                         # Matcher tests
β”‚       β”‚   β”œβ”€β”€ oauthmaterial/                    # OAuth tests
β”‚       β”‚   β”œβ”€β”€ provider/                         # Provider tests
β”‚       β”‚   β”œβ”€β”€ providerfactory/                  # Factory tests
β”‚       β”‚   β”œβ”€β”€ route/                            # Route tests
β”‚       β”‚   β”œβ”€β”€ serializers/                      # Serializer tests
β”‚       β”‚   β”œβ”€β”€ tlsmaterial/                      # TLS tests
β”‚       β”‚   └── utils/                            # Utility tests
β”‚       └── resources/
β”‚           └── testcontainers.properties         # Test config
β”œβ”€β”€ docs/                                         # Documentation
β”œβ”€β”€ Dockerfile                                    # Container image
β”œβ”€β”€ pom.xml                                       # Maven config
└── README.md                                     # Main documentation

Key Files

File Purpose
pom.xml Maven dependencies and build configuration
META-INF/services/... SPI registration for Keycloak
Component.java Custom DI annotation for component discovery
ProviderFactory.java Extension entry point and lifecycle management
Provider.java Event processing logic
Route.java Route configuration with matchers, serializer, destination
DestinationConfig.java Base class for destination configurations
TlsMaterial.java TLS/SSL configuration builder

Building

Dependency Shading (Critical)

All runtime dependencies are shade-relocated to prevent classpath conflicts with Keycloak's internal libraries.

Why this matters: - Keycloak bundles Guava, Jackson, Netty, Apache Commons, etc. - Different Keycloak versions use different library versions - Without shading: NoSuchMethodError, ClassNotFoundException at runtime - With shading: Extension works across Keycloak 25.x β†’ 26.x+ without recompilation

What is shaded: Every dependency without <scope>provided</scope> or <scope>test</scope> is relocated under io.github.fortunen.kete.shaded.*

See: pom.xml β†’ maven-shade-plugin β†’ <relocations> section (17 libraries relocated)

NEVER: Add runtime dependencies without corresponding <relocation> entries.

Full Build

mvn clean package

Output: target/kete.jar (shaded JAR with all dependencies isolated)

Skip Tests

mvn clean package -DskipTests

Clean Build

mvn clean

Build with Coverage

mvn clean test jacoco:report

Output: target/site/jacoco/index.html

Verify Build

mvn verify

Runs all tests and creates the final JAR.

Testing

Unit Tests

Run all unit tests:

mvn test

Run specific test:

mvn test -Dtest=Provider_onEvent

Run tests in a package:

mvn test -Dtest=io.github.fortunen.kete.provider.*

Test Naming Convention

Tests are organized following the one-method-per-file pattern:

Directory structure: {classname}/{methodName}Tests.java

Examples: - provider/constructorTests.java - Tests Provider constructor - provider/onEventTests.java - Tests Provider.onEvent() method - providerfactory/closeTests.java - Tests ProviderFactory.close() method

Method naming: should{ExpectedBehavior}[When{Condition}]

Examples: - shouldReturnTrueWhenEventMatches() - shouldThrowWhenConfigurationIsNull() - shouldSerializeEventToJson()

Test Structure (AAA Pattern)

All tests use the Arrange-Act-Assert pattern with required comments:

@Test
public void shouldDoSomethingWhenConditionMet() {

    // arrange

    var instance = new ClassUnderTest();
    var input = "test-input";

    // act

    var result = instance.methodUnderTest(input);

    // assert

    assertThat(result).isNotNull();
    assertThat(result).isEqualTo("expected");
}

Mocking with Mockito

Use mock() method (not @Mock annotations):

@Test
public void shouldSendMessageToDestination() {

    // arrange

    var destination = mock(Destination.class);
    when(destination.accept("LOGIN")).thenReturn(true);

    // act

    route.send(message);

    // assert

    verify(destination).sendMessage(any(EventMessage.class));
}

Exception Testing

Use catchThrowable() with chained assertions:

@Test
public void shouldThrowWhenConfigurationIsNull() {

    // arrange

    var instance = new ClassUnderTest();

    // act

    var thrown = catchThrowable(() -> instance.initialize(null));

    // assert

    assertThat(thrown)
        .isInstanceOf(IllegalStateException.class)
        .hasMessage("configuration is required");
}

Integration Tests

Integration tests use Testcontainers for Kafka, RabbitMQ, etc.:

# Requires Docker
mvn verify

Tests automatically start containers, run tests, and clean up.

Coverage Reports

mvn clean test jacoco:report
open target/site/jacoco/index.html  # macOS/Linux
start target/site/jacoco/index.html # Windows

Current Coverage: ~90% (check latest report)

Code Conventions

Java Style

  • Indentation: Tabs, no spaces
  • Line Length: None
  • Braces: K&R style (opening brace on same line)
  • Naming:
  • Classes: PascalCase
  • Methods: camelCase
  • Constants: UPPER_SNAKE_CASE
  • Packages: lowercase

Code Example

public class MyComponent {

    private static final String CONSTANT_VALUE = "value";

    private final Logger logger;
    private final String configuration;

    public MyComponent(Logger logger, String configuration) {
        this.logger = Objects.requireNonNull(logger, "logger is required");
        this.configuration = Objects.requireNonNull(configuration, "configuration is required");
    }

    public void processEvent(Event event) {
        try {
            // Process event
        } catch (Exception exception) {
            logger.log(Level.WARNING, "Failed to process event", exception);
        }
    }
}

Documentation

  • No JavaDoc: Code should be self-documenting through clear naming
  • Inline Comments: Minimal - only for truly complex logic
  • README: Keep README.md up to date
  • Developer Docs: Update docs/ when adding features

Extending the Extension

Adding a New Destination

See Extending KETE for comprehensive details.

Quick Steps:

  1. Create config class extending DestinationConfig
  2. Create destination class extending Destination<TConfig>
  3. Add @Component(name = "xxx") annotation
  4. Implement doInitialize() and doSend(EventMessage)
  5. Add tests following the test patterns

Adding a New Serializer

See Extending KETE for comprehensive details.

Quick Steps:

  1. Create class extending Serializer
  2. Add @Component(name = "xxx", scope = Component.SINGLETON)
  3. Set contentType in constructor
  4. Implement serialize(Event) and serialize(AdminEvent)
  5. Add tests

Adding a New Matcher

See Extending KETE for comprehensive details.

Quick Steps:

  1. Create class extending Matcher
  2. Add @Component(name = "xxx") annotation
  3. Implement initialize() and matches(String)
  4. Add tests

Debugging

Local Debugging with IDE

  1. Build the extension:

    mvn clean package
    

  2. Copy to local Keycloak:

    cp target/kete.jar ~/keycloak-26.5.0/providers/
    

  3. Start Keycloak in debug mode:

    export KC_LOG_LEVEL=DEBUG
    export JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
    ~/keycloak-26.5.0/bin/kc.sh start-dev
    

  4. Attach debugger:

  5. IntelliJ: Run β†’ Attach to Process β†’ Select Keycloak
  6. VS Code: Add configuration:
{
    "type": "java",
    "name": "Attach to Keycloak",
    "request": "attach",
    "hostName": "localhost",
    "port": 5005
}
  1. Set breakpoints in your code

  2. Trigger events in Keycloak (login, logout, etc.)

Docker Debugging

  1. Build Docker image with debug enabled:
FROM quay.io/keycloak/keycloak:26.5.0
COPY target/kete.jar /opt/keycloak/providers/
ENV JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
RUN /opt/keycloak/bin/kc.sh build
  1. Run with port exposed:

    docker run -p 8080:8080 -p 5005:5005 keycloak-debug start-dev
    

  2. Attach debugger to localhost:5005

Logging

Enable detailed logging:

export kete.log.level=DEBUG
export KC_LOG_LEVEL=DEBUG,io.github.fortunen.kete:TRACE

View logs:

# Docker
docker logs -f keycloak

# Standalone
tail -f $KEYCLOAK_HOME/data/log/keycloak.log

Common Debug Scenarios

Extension not loading

  1. Check providers/ directory has JAR
  2. Check logs for SPI registration
  3. Verify META-INF/services file is in JAR:
    jar tf target/kete.jar | grep META-INF
    

Events not streaming

  1. Check enabled is not set to false (defaults to true)
  2. Check realm has listener registered:
  3. Admin Console β†’ Realm β†’ Events β†’ Event Listeners
  4. Check destination configuration is valid
  5. Check destination connection logs

Serialization errors

  1. Add breakpoint in Serializer.serialize()
  2. Check event structure
  3. Verify Jackson configuration

Contributing

Development Workflow

  1. Fork the repository on GitHub

  2. Clone your fork:

    git clone https://github.com/YOUR_USERNAME/kete.git
    cd kete
    

  3. Create a feature branch:

    git checkout -b feature/my-new-feature
    

  4. Make changes following code conventions

  5. Write tests for new code

  6. Run tests locally:

    mvn clean test
    

  7. Run coverage check:

    mvn clean test jacoco:report
    # Aim for >80% coverage
    

  8. Commit with descriptive message:

    git commit -m "Add SQS destination support"
    

  9. Push to your fork:

    git push origin feature/my-new-feature
    

  10. Create Pull Request on GitHub

Pull Request Guidelines

  • One feature per PR: Keep PRs focused
  • Tests required: All new code must have tests
  • Documentation: Update docs for user-facing changes
  • Code style: Follow existing conventions
  • Clean history: Squash commits if needed
  • Descriptive title: "Add SQS destination" not "Update"

Commit Message Format

[Type] Short description

Longer description if needed explaining what and why.

Fixes #123

Types: - feat: New feature - fix: Bug fix - docs: Documentation changes - test: Test additions/changes - refactor: Code refactoring - perf: Performance improvement - chore: Build/tooling changes

Code Review Process

  1. Automated checks run (tests, coverage)
  2. Maintainer reviews code
  3. Address feedback
  4. Approval β†’ Merge

Getting Help

  • Issues: Open GitHub issue for bugs/features
  • Discussions: Use GitHub discussions for questions
  • Documentation: Check existing docs first

Useful Maven Commands

# Build without tests
mvn clean package -DskipTests

# Run single test class
mvn test -Dtest=ProviderTest

# Run tests matching pattern
mvn test -Dtest=*Provider*

# Show dependency tree
mvn dependency:tree

# Update dependencies
mvn versions:display-dependency-updates

# Format code (if formatter configured)
mvn formatter:format

# Check for outdated plugins
mvn versions:display-plugin-updates

# Install to local Maven repo
mvn clean install

Troubleshooting

Build Fails

Problem: JAVA_HOME not set

Solution:

export JAVA_HOME=/path/to/jdk-21
# or on Windows
set JAVA_HOME=C:\Program Files\Java\jdk-21

Problem: Maven version too old

Solution: Upgrade to Maven 3.9+

mvn --version  # Check version
# Download from https://maven.apache.org/download.cgi

Tests Fail

Problem: Docker not running for Testcontainers

Solution: Start Docker daemon

docker ps  # Verify Docker is running

Problem: Port conflicts in tests

Solution: Stop services using ports 9092 (Kafka), 5672 (RabbitMQ)

IDE Issues

Problem: "Cannot resolve symbol" errors

Solution: Reimport Maven project - IntelliJ: Maven β†’ Reload Project - Eclipse: Right-click project β†’ Maven β†’ Update Project - VS Code: Cmd+Shift+P β†’ Java: Clean Language Server Workspace

Resources