diff --git a/core/baker-openapi/baker-openapi-dsl/pom.xml b/core/baker-openapi/baker-openapi-dsl/pom.xml
new file mode 100644
index 000000000..174d6f106
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/pom.xml
@@ -0,0 +1,106 @@
+
+
+ 4.0.0
+
+
+ com.ing.baker
+ baker-openapi
+ 5.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ baker-openapi-dsl
+ Baker OpenAPI DSL
+ Runtime DSL for building Baker recipes from generated OpenAPI operation descriptors
+
+
+
+ com.ing.baker
+ baker-recipe-dsl-kotlin
+ ${project.version}
+
+
+ com.ing.baker
+ baker-interface-kotlin
+ ${project.version}
+
+
+ com.ing.baker
+ baker-compiler
+ ${project.version}
+ test
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlinx
+ kotlinx-coroutines-core
+
+
+ community.flock.wirespec.integration
+ wirespec-jvm
+ ${wirespec.version}
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.junit.platform
+ junit-platform-launcher
+ 1.13.2
+ test
+
+
+ org.jetbrains.kotlin
+ kotlin-test-junit5
+ ${kotlin.version}
+ test
+
+
+
+
+ src/main/kotlin
+ src/test/kotlin
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+ ${jvm.target}
+
+
+
+ compile
+ process-sources
+ compile
+
+
+ test-compile
+ process-test-sources
+ test-compile
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+
+
+
+
+
diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt
new file mode 100644
index 000000000..1176b1575
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt
@@ -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 RecipeBuilder.api(
+ operation: ApiOperation,
+ configure: ApiInteractionScope.() -> 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) -> Any>,
+ val nameOverrides: Map,
+ /** Set when the recipe declared `inputFrom { ... }`. */
+ val inputEventClass: KClass<*>? = null,
+ /** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */
+ val inputMapper: ((Any) -> Any)? = null,
+)
+
+@ApiDslMarker
+class ApiInteractionScope internal constructor(private val operation: ApiOperation) {
+
+ @PublishedApi
+ internal val mappers = mutableMapOf) -> Any>()
+
+ @PublishedApi
+ internal val outputEventClasses = mutableSetOf>()
+
+ @PublishedApi
+ internal val requiredEvents = mutableSetOf()
+
+ @PublishedApi
+ internal val ingredientNameOverridesMap = mutableMapOf()
+
+ @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 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(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 { 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 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) -> Any> get() = mappers.toMap()
+
+ /** Read-only view of API field → ingredient overrides. */
+ val configuredNameOverrides: Map get() = ingredientNameOverridesMap.toMap()
+
+ /** Set when `inputFrom(mapper)` was used. */
+ val configuredInputEventClass: KClass<*>? get() = inputEventClass
+
+ /** Set when `inputFrom(mapper)` was used. */
+ val configuredInputMapper: ((Any) -> Any)? get() = inputMapper
+
+ internal fun buildInteraction(): Interaction {
+ // When inputFrom 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 = 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 = outputEventClasses
+ .map { it.toEvent() }
+ .toSet()
+ return Interaction.of(
+ operation.operationName,
+ operation.operationName,
+ inputIngredients,
+ events,
+ requiredEvents,
+ emptySet>(),
+ emptyMap(),
+ ingredientNameOverridesMap.toMap(),
+ emptyMap(),
+ Optional.ofNullable(maxInteractionCount),
+ Optional.empty(),
+ false,
+ )
+ }
+}
+
+@ApiDslMarker
+class IngredientNameOverridesScope internal constructor() {
+ internal val entries = mutableMapOf()
+ /** 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 = 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())
+}
diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt
new file mode 100644
index 000000000..c9af11bdc
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt
@@ -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 {
+ /** Stable name for this operation. Used as the Baker interaction name. */
+ val operationName: String
+
+ /** Input ingredients in declaration order. */
+ val inputFields: List
+
+ /** Maps HTTP status codes to the wirespec response class for that status. */
+ val responseTypes: Map>
+
+ /** The wirespec handler class this operation expects. The plugin generates this. */
+ val handlerClass: KClass
+
+ /**
+ * 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 { ... }` mapper.
+ */
+ fun buildRequest(ingredients: Map): Any
+
+ /**
+ * Wraps a wirespec body DTO into the operation's Request envelope. Used by
+ * the typed `inputFrom { ... }` 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
+}
diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt
new file mode 100644
index 000000000..b9d82951e
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt
@@ -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(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) -> Any>,
+ private val nameOverrides: Map = emptyMap(),
+ private val inputEventClass: KClass<*>? = null,
+ private val inputMapper: ((Any) -> Any)? = null,
+) {
+ fun toInteractionInstance(): InteractionInstance =
+ ApiOperationInteraction(operation, handler, mappers, nameOverrides, inputEventClass, inputMapper)
+}
diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt
new file mode 100644
index 000000000..b66aefbdf
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt
@@ -0,0 +1,109 @@
+package com.ing.baker.openapi.dsl
+
+import com.ing.baker.runtime.javadsl.EventInstance
+import com.ing.baker.runtime.javadsl.IngredientInstance
+import com.ing.baker.runtime.javadsl.InteractionInstance
+import com.ing.baker.runtime.javadsl.InteractionInstanceInput
+import com.ing.baker.types.Converters
+import community.flock.wirespec.kotlin.Wirespec
+import kotlinx.coroutines.runBlocking
+import scala.collection.immutable.Map
+import java.util.Optional
+import java.util.concurrent.CompletableFuture
+import kotlin.reflect.KClass
+import kotlin.reflect.full.primaryConstructor
+import kotlin.reflect.jvm.javaType
+
+typealias ResponseMapper = (Wirespec.Response<*>) -> Any
+
+class ApiOperationInteraction(
+ private val operation: ApiOperation<*>,
+ private val handler: Wirespec.Handler,
+ private val mappers: kotlin.collections.Map,
+ /**
+ * Map from API field name → recipe ingredient name. Used only in the flat
+ * ingredient flow.
+ */
+ private val nameOverrides: kotlin.collections.Map = emptyMap(),
+ /** Set when the recipe used the typed `inputFrom(mapper)` flow. */
+ private val inputEventClass: KClass<*>? = null,
+ /** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */
+ private val inputMapper: ((Any) -> Any)? = null,
+) : InteractionInstance() {
+
+ private val recipeToApi: kotlin.collections.Map =
+ nameOverrides.entries.associate { (api, recipe) -> recipe to api }
+
+ /**
+ * Declared ingredients depend on which input flow the recipe chose:
+ * - Typed flow: the input event's primary-constructor parameters.
+ * - Flat flow: the API descriptor's inputFields.
+ */
+ private val declaredInputs: List =
+ inputEventClass?.let { evt ->
+ evt.primaryConstructor?.parameters?.map { p ->
+ InteractionInstanceInput(
+ Optional.of(p.name!!),
+ Converters.readJavaType(p.type.javaType),
+ )
+ } ?: emptyList()
+ } ?: operation.inputFields.map { field ->
+ InteractionInstanceInput(
+ Optional.of(field.name),
+ Converters.readJavaType(field.type.java),
+ )
+ }
+
+ override fun name(): String = operation.operationName
+
+ override fun input(): MutableList = declaredInputs.toMutableList()
+
+ override fun execute(
+ input: MutableList,
+ metadata: Map,
+ ): CompletableFuture> = run(input)
+
+ override fun execute(input: Any, metaData: Map): CompletableFuture> {
+ throw UnsupportedOperationException("ApiOperationInteraction does not support single-input execute()")
+ }
+
+ override fun run(input: MutableList): CompletableFuture> {
+ return try {
+ val request: Any = if (inputEventClass != null && inputMapper != null) {
+ val event = reconstructInputEvent(inputEventClass, input)
+ val body = inputMapper.invoke(event)
+ @Suppress("UNCHECKED_CAST")
+ (operation as ApiOperation).buildRequestFromBody(body)
+ } else {
+ val ingredientMap: kotlin.collections.Map =
+ input.associate { instance ->
+ val apiName = recipeToApi[instance.name] ?: instance.name
+ apiName to instance.value.`as`(operation.inputFieldType(apiName))
+ }
+ operation.buildRequest(ingredientMap)
+ }
+ val response = runBlocking { operation.invoke(handler, request) }
+ val mapper = mappers[response.status]
+ ?: error("No mapping configured for status ${response.status} on operation ${operation.operationName}")
+ val event = mapper(response)
+ CompletableFuture.completedFuture(Optional.ofNullable(EventInstance.from(event)))
+ } catch (e: Exception) {
+ CompletableFuture.failedFuture(e)
+ }
+ }
+}
+
+private fun reconstructInputEvent(eventClass: KClass<*>, input: List): Any {
+ val ctor = eventClass.primaryConstructor
+ ?: error("Input event class ${eventClass.simpleName} has no primary constructor")
+ val byName = input.associate { it.name to it }
+ val args = ctor.parameters.map { p ->
+ val instance = byName[p.name]
+ ?: error("Missing ingredient '${p.name}' to reconstruct ${eventClass.simpleName}")
+ instance.value.`as`(p.type.javaType)
+ }
+ return ctor.call(*args.toTypedArray())
+}
+
+private fun ApiOperation<*>.inputFieldType(name: String): java.lang.reflect.Type =
+ inputFields.first { it.name == name }.type.java
diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt
new file mode 100644
index 000000000..eeefefdda
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt
@@ -0,0 +1,66 @@
+package com.ing.baker.openapi.dsl
+
+import com.ing.baker.recipe.kotlindsl.ExperimentalDsl
+import com.ing.baker.recipe.kotlindsl.Recipe
+import com.ing.baker.recipe.kotlindsl.RecipeBuilder
+import com.ing.baker.runtime.javadsl.InteractionInstance
+import community.flock.wirespec.kotlin.Wirespec
+
+/**
+ * A [Recipe] paired with the per-operation configuration declared inside
+ * `api(...) { ... }` blocks (response mappers, ingredient name overrides).
+ * Startup code only needs to supply the transport + serialization.
+ */
+class ApiRecipe internal constructor(
+ val recipe: Recipe,
+ internal val configsByOperation: Map,
+) {
+ /**
+ * Builds an [InteractionInstance] for every API operation in the recipe using
+ * the supplied [transport] and [serialization]. Every descriptor knows how to
+ * build its own wirespec handler — callers register nothing per operation.
+ */
+ fun toInteractionInstances(
+ transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,
+ serialization: Wirespec.Serialization,
+ ): List =
+ configsByOperation.values.map { cfg ->
+ val handler = cfg.operation.buildHandler(transport, serialization)
+ ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance()
+ }
+
+ /**
+ * Overload for callers who want to supply a handler explicitly per operation
+ * (e.g. tests with custom fakes). Operations not in [overrides] fall back to
+ * the descriptor's default handler built from (transport, serialization).
+ */
+ fun toInteractionInstances(
+ transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,
+ serialization: Wirespec.Serialization,
+ overrides: Map, Wirespec.Handler>,
+ ): List =
+ configsByOperation.values.map { cfg ->
+ val handler = overrides[cfg.operation] ?: cfg.operation.buildHandler(transport, serialization)
+ ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance()
+ }
+}
+
+/**
+ * Builds an [ApiRecipe] — same shape as `recipe(name) { ... }` but the returned
+ * wrapper carries the configuration collected from `api(...)` blocks so the
+ * runtime can construct interaction instances without re-declaring it.
+ */
+@OptIn(ExperimentalDsl::class)
+fun apiRecipe(name: String, configure: RecipeBuilder.() -> Unit): ApiRecipe {
+ val collector = mutableMapOf()
+ apiInteractionConfigCollector.set(collector)
+ try {
+ val recipe = com.ing.baker.recipe.kotlindsl.recipe(name, configure)
+ return ApiRecipe(recipe, collector.toMap())
+ } finally {
+ apiInteractionConfigCollector.remove()
+ }
+}
+
+internal val apiInteractionConfigCollector: ThreadLocal?> =
+ ThreadLocal.withInitial { null }
diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt
new file mode 100644
index 000000000..321ab2ffb
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt
@@ -0,0 +1,97 @@
+package com.ing.baker.openapi.dsl
+
+import com.ing.baker.compiler.RecipeCompiler
+import com.ing.baker.recipe.kotlindsl.ExperimentalDsl
+import com.ing.baker.recipe.kotlindsl.recipe
+import community.flock.wirespec.kotlin.Wirespec
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import kotlin.reflect.KClass
+import scala.jdk.javaapi.CollectionConverters
+
+private data class DslSensoryEvent(val firstName: String, val email: String)
+private data class DslUserCreated(val id: String, val email: String)
+private data class DslUserCreationFailed(val reason: String)
+
+private class DslFakeHandler : Wirespec.Handler
+private object DslFakeHeaders : Wirespec.Response.Headers
+private class DslFakeResponse(override val status: Int) : Wirespec.Response {
+ override val body: Unit = Unit
+ override val headers: Wirespec.Response.Headers = DslFakeHeaders
+}
+
+private object CreateUser : ApiOperation {
+ override val operationName = "CreateUser"
+ override val inputFields = listOf(
+ InputField("firstName", String::class),
+ InputField("email", String::class),
+ )
+ override val responseTypes: Map> = mapOf(
+ 201 to DslFakeResponse::class,
+ 400 to DslFakeResponse::class,
+ )
+ override val handlerClass = DslFakeHandler::class
+ override fun buildRequest(ingredients: Map): Any = ingredients
+ override fun buildRequestFromBody(body: Unit): Any = body
+ override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =
+ DslFakeResponse(201)
+ override fun buildHandler(
+ transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,
+ serialization: Wirespec.Serialization,
+ ): Wirespec.Handler = DslFakeHandler()
+}
+
+@OptIn(ExperimentalDsl::class)
+class ApiDslTest {
+
+ @Test
+ fun `api block registers an interaction with the operation name and inputs`() {
+ val r = recipe("r") {
+ sensoryEvents { event() }
+ api(CreateUser) {
+ on(201) { DslUserCreated("u1", "a@b") }
+ on(400) { DslUserCreationFailed("nope") }
+ }
+ }
+ val compiled = RecipeCompiler.compileRecipe(r)
+ val interaction = CollectionConverters.asJava(compiled.interactionTransitions())
+ .single { it.interactionName() == "CreateUser" }
+ val ingredientNames = CollectionConverters.asJava(interaction.requiredIngredients())
+ .map { it.name() }
+ .toSet()
+ assertEquals(setOf("firstName", "email"), ingredientNames)
+ }
+
+ @Test
+ fun `api block exposes user-mapped events as interaction outputs`() {
+ val r = recipe("r") {
+ sensoryEvents { event() }
+ api(CreateUser) {
+ on(201) { DslUserCreated("u1", "a@b") }
+ on(400) { DslUserCreationFailed("nope") }
+ }
+ }
+ val compiled = RecipeCompiler.compileRecipe(r)
+ val outputs = CollectionConverters.asJava(compiled.events())
+ .map { it.name() }
+ .toSet()
+ assertTrue(outputs.contains("DslUserCreated"), "outputs were: $outputs")
+ assertTrue(outputs.contains("DslUserCreationFailed"), "outputs were: $outputs")
+ }
+
+ @Test
+ fun `requires registers required events on the DSL interaction`() {
+ val r = recipe("r") {
+ sensoryEvents { event() }
+ api(CreateUser) {
+ requires(DslUserCreated::class)
+ on(201) { DslUserCreated("u1", "a@b") }
+ }
+ }
+ val interaction = CollectionConverters.asJava(r.interactions())
+ .single { it.name() == "CreateUser" }
+ val reqEvents = CollectionConverters.asJava(interaction.requiredEvents())
+ assertTrue(reqEvents.contains("DslUserCreated"), "got: $reqEvents")
+ }
+}
diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt
new file mode 100644
index 000000000..abd74f111
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt
@@ -0,0 +1,39 @@
+package com.ing.baker.openapi.dsl
+
+import community.flock.wirespec.kotlin.Wirespec
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import kotlin.reflect.KClass
+
+private class BindingStubHandler : Wirespec.Handler
+private object BindingStubHeaders : Wirespec.Response.Headers
+private class BindingStubResponse(override val status: Int) : Wirespec.Response {
+ override val body: Unit = Unit
+ override val headers: Wirespec.Response.Headers = BindingStubHeaders
+}
+
+private object StubOp : ApiOperation {
+ override val operationName = "Stub"
+ override val inputFields: List = emptyList()
+ override val responseTypes: Map> = mapOf(200 to BindingStubResponse::class)
+ override val handlerClass = BindingStubHandler::class
+ override fun buildRequest(ingredients: Map): Any = Unit
+ override fun buildRequestFromBody(body: Unit): Any = Unit
+ override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =
+ BindingStubResponse(200)
+ override fun buildHandler(
+ transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,
+ serialization: Wirespec.Serialization,
+ ): Wirespec.Handler = BindingStubHandler()
+}
+
+class ApiOperationBindingTest {
+
+ @Test
+ fun `binding produces an ApiOperationInteraction with the operation name`() {
+ val handler = BindingStubHandler()
+ val binding = ApiOperationBinding(StubOp, handler, mappers = mapOf(200 to { _ -> "ok" }))
+ val interaction = binding.toInteractionInstance()
+ assertEquals("Stub", interaction.name())
+ }
+}
diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt
new file mode 100644
index 000000000..ffee681a0
--- /dev/null
+++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt
@@ -0,0 +1,120 @@
+package com.ing.baker.openapi.dsl
+
+import com.ing.baker.runtime.javadsl.IngredientInstance
+import com.ing.baker.types.PrimitiveValue
+import community.flock.wirespec.kotlin.Wirespec
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import java.util.concurrent.ExecutionException
+import kotlin.reflect.KClass
+
+private class FakeHandler : Wirespec.Handler
+
+private object FakeHeaders : Wirespec.Response.Headers
+
+private class FakeResponse(
+ override val status: Int,
+ override val body: Map,
+) : Wirespec.Response