Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 106 additions & 0 deletions core/baker-openapi/baker-openapi-dsl/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.ing.baker</groupId>
<artifactId>baker-openapi</artifactId>
<version>5.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>baker-openapi-dsl</artifactId>
<name>Baker OpenAPI DSL</name>
<description>Runtime DSL for building Baker recipes from generated OpenAPI operation descriptors</description>

<dependencies>
<dependency>
<groupId>com.ing.baker</groupId>
<artifactId>baker-recipe-dsl-kotlin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ing.baker</groupId>
<artifactId>baker-interface-kotlin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ing.baker</groupId>
<artifactId>baker-compiler</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
</dependency>
<dependency>
<groupId>community.flock.wirespec.integration</groupId>
<artifactId>wirespec-jvm</artifactId>
<version>${wirespec.version}</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>1.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<jvmTarget>${jvm.target}</jvmTarget>
</configuration>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals><goal>compile</goal></goals>
</execution>
<execution>
<id>test-compile</id>
<phase>process-test-sources</phase>
<goals><goal>test-compile</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>false</skipTests>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package com.ing.baker.openapi.dsl

import com.ing.baker.recipe.kotlindsl.Event
import com.ing.baker.recipe.kotlindsl.Ingredient
import com.ing.baker.recipe.kotlindsl.Interaction
import com.ing.baker.recipe.kotlindsl.RecipeBuilder
import community.flock.wirespec.kotlin.Wirespec
import java.util.Optional
import kotlin.reflect.KClass
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.javaType

@DslMarker
annotation class ApiDslMarker

/**
* Registers a Baker interaction in this recipe based on an OpenAPI [operation].
* The [configure] block declares status → user-event mappers and optional Baker
* controls (required events, ingredient name overrides, maximumInteractionCount).
*/
fun <RequestBody : Any> RecipeBuilder.api(
operation: ApiOperation<RequestBody>,
configure: ApiInteractionScope<RequestBody>.() -> Unit,
) {
val scope = ApiInteractionScope(operation).apply(configure)
addInteraction(scope.buildInteraction())
apiInteractionConfigCollector.get()?.put(
operation.operationName,
ApiInteractionConfig(
operation = operation,
mappers = scope.configuredMappers,
nameOverrides = scope.configuredNameOverrides,
inputEventClass = scope.configuredInputEventClass,
inputMapper = scope.configuredInputMapper,
),
)
}

internal data class ApiInteractionConfig(
val operation: ApiOperation<*>,
val mappers: Map<Int, (Wirespec.Response<*>) -> Any>,
val nameOverrides: Map<String, String>,
/** Set when the recipe declared `inputFrom<E> { ... }`. */
val inputEventClass: KClass<*>? = null,
/** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */
val inputMapper: ((Any) -> Any)? = null,
)

@ApiDslMarker
class ApiInteractionScope<RequestBody : Any> internal constructor(private val operation: ApiOperation<RequestBody>) {

@PublishedApi
internal val mappers = mutableMapOf<Int, (Wirespec.Response<*>) -> Any>()

@PublishedApi
internal val outputEventClasses = mutableSetOf<KClass<*>>()

@PublishedApi
internal val requiredEvents = mutableSetOf<String>()

@PublishedApi
internal val ingredientNameOverridesMap = mutableMapOf<String, String>()

@PublishedApi
internal var inputEventClass: KClass<*>? = null

@PublishedApi
internal var inputMapper: ((Any) -> Any)? = null

private var maxInteractionCount: Int? = null

/**
* Maps responses of HTTP [status] to a user-defined event. The [mapper]
* receives the wirespec response and returns a domain event instance.
*/
inline fun <reified R : Wirespec.Response<*>, reified E : Any> on(
status: Int,
noinline mapper: (R) -> E,
) {
@Suppress("UNCHECKED_CAST")
mappers[status] = { resp -> mapper(resp as R) }
outputEventClasses.add(E::class)
}

fun requires(vararg eventClasses: KClass<*>) {
eventClasses.forEach { requiredEvents.add(it.simpleName!!) }
}

fun maximumInteractionCount(n: Int) {
maxInteractionCount = n
}

/**
* Maps API input field names (left) to recipe ingredient names (right).
* Use this when the recipe's ingredient name doesn't match the API contract
* (e.g. domain calls it `customerId`, the OpenAPI doc calls it `userId`).
*
* The wirespec request DTO itself is never an ingredient — only flat values
* (path / query / header / flattened body fields) flow through Baker.
*/
fun ingredientNameOverrides(block: IngredientNameOverridesScope.() -> Unit) {
val scope = IngredientNameOverridesScope().apply(block)
ingredientNameOverridesMap.putAll(scope.entries)
}

/**
* Typed input mapping — symmetric to the response side's `on<R, E>(N) { ... }`.
* Declares that event [E] populates the API request body via the given lambda.
* The body type is fixed by the operation (each API operation has exactly one
* request body type), so only [E] needs to be supplied:
*
* inputFrom<CreateAccountCommand> { cmd ->
* CreateAccountRequest(
* userId = cmd.customerId,
* profileId = cmd.profileId,
* ...
* )
* }
*
* The wirespec request DTO appears inside the lambda only — never as a
* Baker ingredient. The runtime reconstructs an [E] instance from Baker
* ingredients (E's primary constructor parameters), calls the mapper, and
* lets the descriptor wrap the returned body into the operation's Request
* envelope.
*/
inline fun <reified E : Any> inputFrom(noinline mapper: (E) -> RequestBody) {
requiredEvents.add(E::class.simpleName!!)
inputEventClass = E::class
@Suppress("UNCHECKED_CAST")
inputMapper = { event -> mapper(event as E) }
}

/** Read-only view of configured mappers, useful for app-startup binding. */
val configuredMappers: Map<Int, (Wirespec.Response<*>) -> Any> get() = mappers.toMap()

/** Read-only view of API field → ingredient overrides. */
val configuredNameOverrides: Map<String, String> get() = ingredientNameOverridesMap.toMap()

/** Set when `inputFrom<E, R>(mapper)` was used. */
val configuredInputEventClass: KClass<*>? get() = inputEventClass

/** Set when `inputFrom<E, R>(mapper)` was used. */
val configuredInputMapper: ((Any) -> Any)? get() = inputMapper

internal fun buildInteraction(): Interaction {
// When inputFrom<E, R> was declared, the interaction's required ingredients
// are E's primary-constructor parameters (each becomes an ingredient that
// Baker fills from the fired E event). Otherwise fall back to the API
// descriptor's flat inputFields.
val inputIngredients: Set<Ingredient> = inputEventClass?.let { evt ->
evt.primaryConstructor?.parameters?.map { p ->
Ingredient(p.name!!, p.type.javaType)
}?.toSet()
} ?: operation.inputFields
.map { Ingredient(it.name, it.type.java) }
.toSet()
val events: Set<Event> = outputEventClasses
.map { it.toEvent() }
.toSet()
return Interaction.of(
operation.operationName,
operation.operationName,
inputIngredients,
events,
requiredEvents,
emptySet<Set<String>>(),
emptyMap<String, Any>(),
ingredientNameOverridesMap.toMap(),
emptyMap(),
Optional.ofNullable(maxInteractionCount),
Optional.empty(),
false,
)
}
}

@ApiDslMarker
class IngredientNameOverridesScope internal constructor() {
internal val entries = mutableMapOf<String, String>()
/** Maps the API input field name (receiver) to the recipe ingredient name. */
infix fun String.to(other: String) { entries[this] = other }
}

private fun KClass<*>.toEvent(): Event {
val ctor = primaryConstructor
val ingredients: List<Ingredient> = if (ctor != null) {
ctor.parameters.map { Ingredient(it.name!!, it.type.javaType) }
} else {
memberProperties.map { Ingredient(it.name, it.returnType.javaType) }
}
return Event(simpleName!!, ingredients, Optional.empty())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.ing.baker.openapi.dsl

import community.flock.wirespec.kotlin.Wirespec
import kotlin.reflect.KClass

/**
* A single input ingredient an API operation expects (from path, query, headers, or
* flattened request body fields). The runtime DSL turns each entry into a Baker
* ingredient with the same name.
*/
data class InputField(
val name: String,
val type: KClass<*>,
)

/**
* Descriptor for one OpenAPI operation. Implementations are generated by the plugin —
* one `object` per operation — and are pure data plus a few callbacks the runtime
* uses to build requests and invoke the wirespec handler.
*
* Each operation has at most one request body type; that type is the [RequestBody]
* parameter on the descriptor. Operations without a body use [Unit].
*/
interface ApiOperation<RequestBody : Any> {
/** Stable name for this operation. Used as the Baker interaction name. */
val operationName: String

/** Input ingredients in declaration order. */
val inputFields: List<InputField>

/** Maps HTTP status codes to the wirespec response class for that status. */
val responseTypes: Map<Int, KClass<*>>

/** The wirespec handler class this operation expects. The plugin generates this. */
val handlerClass: KClass<out Wirespec.Handler>

/**
* Builds the wirespec Request from a name → value ingredient map. Used by the
* default flat-ingredient flow when the recipe didn't declare an explicit
* typed `inputFrom<E> { ... }` mapper.
*/
fun buildRequest(ingredients: Map<String, Any?>): Any

/**
* Wraps a wirespec body DTO into the operation's Request envelope. Used by
* the typed `inputFrom<E> { ... }` flow — the user lambda produces a value
* of the operation's specific [RequestBody] type and the descriptor knows
* how to put it into the Endpoint.Request.
*/
fun buildRequestFromBody(body: RequestBody): Any

/** Invokes the underlying wirespec handler. The handler must be of the operation's expected type. */
suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*>

/**
* Builds the wirespec handler for this operation from a generic transport and
* serialization. The default implementation lets the runtime construct handlers
* for every API operation in a recipe from just (transport, serialization) —
* the caller doesn't need to register handlers per operation.
*/
fun buildHandler(
transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,
serialization: Wirespec.Serialization,
): Wirespec.Handler
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.ing.baker.openapi.dsl

import com.ing.baker.runtime.javadsl.InteractionInstance
import community.flock.wirespec.kotlin.Wirespec
import kotlin.reflect.KClass

/**
* Pairs an [ApiOperation] descriptor with the wirespec handler that knows how to
* execute it, plus the status → event mappers from the recipe. Produces an
* [InteractionInstance] for Baker to register at startup.
*
* Mappers normally mirror those configured in the recipe's `api(...)` block.
* [nameOverrides] map API field names to recipe ingredient names; the runtime
* reverses the map to translate IngredientInstance names back to the API names
* before invoking [ApiOperation.buildRequest].
*
* When the recipe used the typed `inputFrom<E, R>(mapper)` flow,
* [inputEventClass] and [inputMapper] are set: the runtime reconstructs an [E]
* instance from ingredients and applies [inputMapper] to produce the wirespec
* body DTO, which the descriptor wraps via [ApiOperation.buildRequestFromBody].
*/
class ApiOperationBinding(
private val operation: ApiOperation<*>,
private val handler: Wirespec.Handler,
private val mappers: Map<Int, (Wirespec.Response<*>) -> Any>,
private val nameOverrides: Map<String, String> = emptyMap(),
private val inputEventClass: KClass<*>? = null,
private val inputMapper: ((Any) -> Any)? = null,
) {
fun toInteractionInstance(): InteractionInstance =
ApiOperationInteraction(operation, handler, mappers, nameOverrides, inputEventClass, inputMapper)
}
Loading