From 01c2363d442be85b551909c3b8b663e975d0fa76 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 27 Mar 2026 15:55:40 +0100 Subject: [PATCH 01/11] Openapi plugin --- core/baker-openapi/baker-openapi-dsl/pom.xml | 106 + .../com/ing/baker/openapi/dsl/ApiDsl.kt | 104 + .../com/ing/baker/openapi/dsl/ApiOperation.kt | 39 + .../baker/openapi/dsl/ApiOperationBinding.kt | 20 + .../openapi/dsl/ApiOperationInteraction.kt | 60 + .../com/ing/baker/openapi/dsl/ApiRecipe.kt | 50 + .../com/ing/baker/openapi/dsl/ApiDslTest.kt | 92 + .../openapi/dsl/ApiOperationBindingTest.kt | 34 + .../dsl/ApiOperationInteractionTest.kt | 115 + .../baker-openapi-emitter/pom.xml | 81 + .../openapi/emitter/BakerOpenApiEmitter.java | 224 ++ .../emitter/BakerOpenApiEmitterTest.kt | 101 + .../baker-openapi-plugin/pom.xml | 117 + .../openapi/plugin/GenerateFromOpenApiMojo.kt | 99 + .../baker-openapi-transportation/pom.xml | 50 + .../openapi/transportation/Transportation.kt | 46 + core/baker-openapi/pom.xml | 28 + .../ing/baker/recipe/kotlindsl/KotlinDsl.kt | 8 + core/baker-wirespec/pom.xml | 87 + .../recipe/wirespec/BakerJavaEmitter.java | 246 ++ .../recipe/wirespec/BakerKotlinEmitter.java | 480 ++++ .../recipe/wirespec/BakerJavaEmitterTest.kt | 189 ++ .../recipe/wirespec/BakerKotlinEmitterTest.kt | 338 +++ .../2026-03-27-wirespec-baker-integration.md | 1253 ++++++++++ .../plans/2026-05-22-baker-openapi-plugin.md | 2124 +++++++++++++++++ ...03-27-wirespec-baker-integration-design.md | 268 +++ .../2026-05-22-baker-openapi-plugin-design.md | 301 +++ examples/baker-openapi-example/pom.xml | 175 ++ .../examples/account/openapi/AccountRecipe.kt | 23 + .../baker/examples/account/openapi/Events.kt | 11 + .../src/main/openapi/account-api.json | 70 + .../openapi/AccountRecipeWireMockTest.kt | 84 + examples/baker-wirespec-example/pom.xml | 239 ++ .../account/CreateCurrentAccountRecipe.kt | 42 + .../com/ing/baker/examples/account/Events.kt | 12 + .../examples/account/HandlerFactories.kt | 37 + .../baker/examples/account/Transportation.kt | 46 + .../src/main/openapi/profile-api.json | 77 + .../src/main/wirespec/account.ws | 20 + .../src/main/wirespec/user.ws | 24 + .../account/CreateCurrentAccountTest.kt | 105 + .../CreateCurrentAccountWireMockTest.kt | 154 ++ pom.xml | 5 + 43 files changed, 7784 insertions(+) create mode 100644 core/baker-openapi/baker-openapi-dsl/pom.xml create mode 100644 core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt create mode 100644 core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt create mode 100644 core/baker-openapi/baker-openapi-emitter/pom.xml create mode 100644 core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java create mode 100644 core/baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt create mode 100644 core/baker-openapi/baker-openapi-plugin/pom.xml create mode 100644 core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt create mode 100644 core/baker-openapi/baker-openapi-transportation/pom.xml create mode 100644 core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt create mode 100644 core/baker-openapi/pom.xml create mode 100644 core/baker-wirespec/pom.xml create mode 100644 core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java create mode 100644 core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java create mode 100644 core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.kt create mode 100644 core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.kt create mode 100644 docs/superpowers/plans/2026-03-27-wirespec-baker-integration.md create mode 100644 docs/superpowers/plans/2026-05-22-baker-openapi-plugin.md create mode 100644 docs/superpowers/specs/2026-03-27-wirespec-baker-integration-design.md create mode 100644 docs/superpowers/specs/2026-05-22-baker-openapi-plugin-design.md create mode 100644 examples/baker-openapi-example/pom.xml create mode 100644 examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt create mode 100644 examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt create mode 100644 examples/baker-openapi-example/src/main/openapi/account-api.json create mode 100644 examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt create mode 100644 examples/baker-wirespec-example/pom.xml create mode 100644 examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/CreateCurrentAccountRecipe.kt create mode 100644 examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Events.kt create mode 100644 examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/HandlerFactories.kt create mode 100644 examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Transportation.kt create mode 100644 examples/baker-wirespec-example/src/main/openapi/profile-api.json create mode 100644 examples/baker-wirespec-example/src/main/wirespec/account.ws create mode 100644 examples/baker-wirespec-example/src/main/wirespec/user.ws create mode 100644 examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountTest.kt create mode 100644 examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountWireMockTest.kt 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..4476effd2 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt @@ -0,0 +1,104 @@ +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()) + apiMappersCollector.get()?.put(operation.operationName, operation to scope.configuredMappers) +} + +@ApiDslMarker +class ApiInteractionScope internal constructor(private val operation: ApiOperation) { + + @PublishedApi + internal val mappers = mutableMapOf) -> Any>() + + @PublishedApi + internal val outputEventClasses = mutableSetOf>() + + private val requiredEvents = mutableSetOf() + private val ingredientNameOverridesMap = mutableMapOf() + 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 + } + + fun ingredientNameOverrides(block: MutableMap.() -> Unit) { + ingredientNameOverridesMap.apply(block) + } + + /** Read-only view of configured mappers, useful for app-startup binding. */ + val configuredMappers: Map) -> Any> get() = mappers.toMap() + + internal fun buildInteraction(): Interaction { + val inputIngredients: Set = 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, + ) + } +} + +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..d6530d272 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt @@ -0,0 +1,39 @@ +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 three callbacks the runtime + * uses to build requests and invoke the wirespec handler. + */ +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. */ + fun buildRequest(ingredients: Map): 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<*> +} 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..8688175e9 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt @@ -0,0 +1,20 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.runtime.javadsl.InteractionInstance +import community.flock.wirespec.kotlin.Wirespec + +/** + * 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. + */ +class ApiOperationBinding( + private val operation: ApiOperation, + private val handler: Wirespec.Handler, + private val mappers: Map) -> Any>, +) { + fun toInteractionInstance(): InteractionInstance = + ApiOperationInteraction(operation, handler, mappers) +} 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..89ec5ce57 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt @@ -0,0 +1,60 @@ +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 + +typealias ResponseMapper = (Wirespec.Response<*>) -> Any + +class ApiOperationInteraction( + private val operation: ApiOperation, + private val handler: Wirespec.Handler, + private val mappers: kotlin.collections.Map, +) : InteractionInstance() { + + override fun name(): String = operation.operationName + + override fun input(): MutableList = + operation.inputFields + .map { field -> + InteractionInstanceInput( + Optional.of(field.name), + Converters.readJavaType(field.type.java), + ) + } + .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 ingredientMap: kotlin.collections.Map = + input.associate { it.name to it.value.`as`(operation.inputFieldType(it.name)) } + val request = 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 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..d9e7bce37 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt @@ -0,0 +1,50 @@ +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 response mappers configured by `api(...) { on(...) { ... } }` + * blocks. The mappers are the recipe's responsibility — startup code only needs to + * supply the underlying wirespec handlers. + */ +class ApiRecipe internal constructor( + val recipe: Recipe, + internal val mappersByOperation: Map) -> Any>>>, +) { + /** + * Builds an [InteractionInstance] for every operation declared in the recipe by + * pairing each operation with its handler from [handlers]. Throws if any + * operation lacks a handler. + */ + fun toInteractionInstances(handlers: Map): List = + mappersByOperation.values.map { (op, mappers) -> + val handler = handlers[op] + ?: error("No handler provided for operation '${op.operationName}'. " + + "Pass it via handlers = mapOf(${op.operationName} to )") + ApiOperationBinding(op, handler, mappers).toInteractionInstance() + } +} + +/** + * Builds an [ApiRecipe] — same shape as `recipe(name) { ... }` but the returned + * wrapper carries the response mappers configured inside `api(...)` blocks so the + * runtime can construct interaction instances without re-declaring them. + */ +@OptIn(ExperimentalDsl::class) +fun apiRecipe(name: String, configure: RecipeBuilder.() -> Unit): ApiRecipe { + val collector = mutableMapOf) -> Any>>>() + apiMappersCollector.set(collector) + try { + val recipe = com.ing.baker.recipe.kotlindsl.recipe(name, configure) + return ApiRecipe(recipe, collector.toMap()) + } finally { + apiMappersCollector.remove() + } +} + +internal val apiMappersCollector: ThreadLocal) -> Any>>>?> = + 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..a83505220 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt @@ -0,0 +1,92 @@ +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 suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = + DslFakeResponse(201) +} + +@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..8adb08677 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt @@ -0,0 +1,34 @@ +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 suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = + BindingStubResponse(200) +} + +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..9d8b35966 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt @@ -0,0 +1,115 @@ +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> { + override val headers: Wirespec.Response.Headers = FakeHeaders +} + +private data class UserCreated(val id: String, val email: String) + +private class FakeOperation( + private val nextResponse: Wirespec.Response<*>, +) : ApiOperation { + override val operationName: String = "CreateUser" + override val inputFields = listOf( + InputField("firstName", String::class), + InputField("email", String::class), + ) + override val responseTypes: Map> = mapOf( + 201 to FakeResponse::class, + 400 to FakeResponse::class, + ) + override val handlerClass: KClass = FakeHandler::class + var capturedRequest: Map? = null + override fun buildRequest(ingredients: Map): Any { + capturedRequest = ingredients + return ingredients + } + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = nextResponse +} + +private val emptyScalaMetadata: scala.collection.immutable.Map = + scala.collection.immutable.`Map$`.`MODULE$`.empty() as scala.collection.immutable.Map + +class ApiOperationInteractionTest { + + @Test + fun `name returns operationName`() { + val op = FakeOperation(FakeResponse(201, emptyMap())) + val interaction = ApiOperationInteraction(op, FakeHandler(), emptyMap()) + assertEquals("CreateUser", interaction.name()) + } + + @Test + fun `input returns one InteractionInstanceInput per input field`() { + val op = FakeOperation(FakeResponse(201, emptyMap())) + val interaction = ApiOperationInteraction(op, FakeHandler(), emptyMap()) + val inputs = interaction.input() + assertEquals(2, inputs.size) + assertEquals("firstName", inputs[0].name.orElseThrow()) + assertEquals("email", inputs[1].name.orElseThrow()) + } + + @Test + fun `execute routes 201 through configured mapper and returns the fired event`() { + val op = FakeOperation(FakeResponse(201, mapOf("id" to "u1", "email" to "a@b"))) + val mapper: (Wirespec.Response<*>) -> Any = { resp -> + val body = (resp as FakeResponse).body + UserCreated(id = body["id"] as String, email = body["email"] as String) + } + val interaction = ApiOperationInteraction(op, FakeHandler(), mapOf(201 to mapper)) + + val result = interaction.execute( + mutableListOf( + IngredientInstance("firstName", PrimitiveValue("John")), + IngredientInstance("email", PrimitiveValue("a@b")), + ), + emptyScalaMetadata, + ).get() + + assertTrue(result.isPresent) + assertEquals("UserCreated", result.get().name) + assertEquals(mapOf("firstName" to "John", "email" to "a@b"), op.capturedRequest) + } + + @Test + fun `execute fails the future on unmapped status`() { + val op = FakeOperation(FakeResponse(500, emptyMap())) + val interaction = ApiOperationInteraction( + op, + FakeHandler(), + mapOf(201 to { _ -> UserCreated("x", "y") }) + ) + + val ex = assertThrows { + interaction.execute( + mutableListOf( + IngredientInstance("firstName", PrimitiveValue("John")), + IngredientInstance("email", PrimitiveValue("a@b")), + ), + emptyScalaMetadata, + ).get() + } + assertNotNull(ex.cause) + val msg = ex.cause!!.message ?: "" + assertTrue(msg.contains("500"), "expected message to mention status 500, was: $msg") + assertTrue(msg.contains("CreateUser"), "expected message to mention operation, was: $msg") + } +} diff --git a/core/baker-openapi/baker-openapi-emitter/pom.xml b/core/baker-openapi/baker-openapi-emitter/pom.xml new file mode 100644 index 000000000..f9a9a21db --- /dev/null +++ b/core/baker-openapi/baker-openapi-emitter/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-emitter + Baker OpenAPI Emitter + Wirespec LanguageEmitter that generates ApiOperation descriptor objects for use with baker-openapi-dsl + + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + org.jetbrains + annotations + 13.0 + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.platform + junit-platform-launcher + 1.13.2 + test + + + + + src/test/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + test-compile + test-compile + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java new file mode 100644 index 000000000..a0006ffde --- /dev/null +++ b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java @@ -0,0 +1,224 @@ +package com.ing.baker.openapi.emitter; + +import community.flock.wirespec.compiler.core.emit.Emitted; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.PackageName; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Definition; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import community.flock.wirespec.compiler.utils.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BakerOpenApiEmitter extends LanguageEmitter { + + private final PackageName packageName; + private Module currentModule; + + public BakerOpenApiEmitter(PackageName packageName) { + this.packageName = packageName; + } + + public BakerOpenApiEmitter() { + this.packageName = null; + } + + @NotNull @Override public String getSingleLineComment() { return "//"; } + @NotNull @Override public FileExtension getExtension() { return FileExtension.Kotlin; } + @Nullable @Override public Shared getShared() { return null; } + @NotNull @Override public String notYetImplemented() { return ""; } + + @NotNull + @Override + public Emitted emit(@NotNull Definition definition, @NotNull Module module, @NotNull Logger logger) { + this.currentModule = module; + Emitted base = super.emit(definition, module, logger); + if (packageName != null && !packageName.getValue().isEmpty() && definition instanceof Endpoint) { + String dir = packageName.getValue().replace('.', '/') + "/api/"; + return new Emitted(dir + base.getFile(), base.getResult()); + } + return base; + } + + @NotNull @Override public String emit(@NotNull Identifier identifier) { return identifier.getValue(); } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + + StringBuilder sb = new StringBuilder(); + if (packageName != null && !packageName.getValue().isEmpty()) { + sb.append("package ").append(packageName.getValue()).append(".api\n\n"); + sb.append("import ").append(packageName.getValue()).append(".endpoint.").append(name).append("\n"); + sb.append("import ").append(packageName.getValue()).append(".model.*\n"); + } + sb.append("import com.ing.baker.openapi.dsl.ApiOperation\n"); + sb.append("import com.ing.baker.openapi.dsl.InputField\n"); + sb.append("import community.flock.wirespec.kotlin.Wirespec\n"); + sb.append("import kotlin.reflect.KClass\n\n"); + + sb.append("object ").append(name).append(" : ApiOperation {\n"); + sb.append(" override val operationName = \"").append(name).append("\"\n\n"); + + // Input fields: path + query + headers + flattened body fields. + // For ::class references we strip nullability — KClass has no nullable variant. + List inputs = collectInputs(endpoint); + sb.append(" override val inputFields = listOf(\n"); + for (String[] f : inputs) { + String classRef = f[1].endsWith("?") ? f[1].substring(0, f[1].length() - 1) : f[1]; + sb.append(" InputField(\"").append(f[0]).append("\", ").append(classRef).append("::class),\n"); + } + sb.append(" )\n\n"); + + // Response types + sb.append(" override val responseTypes: Map> = mapOf(\n"); + for (Endpoint.Response resp : endpoint.getResponses()) { + sb.append(" ").append(resp.getStatus()).append(" to ").append(name) + .append(".Response").append(resp.getStatus()).append("::class,\n"); + } + sb.append(" )\n\n"); + + // handlerClass + sb.append(" override val handlerClass = ").append(name).append(".Handler::class\n\n"); + + // buildRequest + String bodyTypeName = bodyTypeName(endpoint); + List ctorArgs = new ArrayList<>(); + for (Endpoint.Segment seg : endpoint.getPath()) { + if (seg instanceof Endpoint.Segment.Param p) { + ctorArgs.add(p.getIdentifier().getValue() + " = ingredients[\"" + p.getIdentifier().getValue() + "\"] as " + kotlinType(p.getReference())); + } + } + for (Field q : endpoint.getQueries()) { + ctorArgs.add(q.getIdentifier().getValue() + " = ingredients[\"" + q.getIdentifier().getValue() + "\"] as " + kotlinType(q.getReference())); + } + for (Field h : endpoint.getHeaders()) { + ctorArgs.add(h.getIdentifier().getValue() + " = ingredients[\"" + h.getIdentifier().getValue() + "\"] as " + kotlinType(h.getReference())); + } + if (bodyTypeName != null) { + Type bodyType = findType(bodyTypeName); + StringBuilder bodyCtor = new StringBuilder(bodyTypeName + "("); + if (bodyType != null) { + String fields = bodyType.getShape().getValue().stream() + .map(f -> f.getIdentifier().getValue() + " = ingredients[\"" + f.getIdentifier().getValue() + "\"] as " + kotlinType(f.getReference())) + .collect(Collectors.joining(", ")); + bodyCtor.append(fields); + } + bodyCtor.append(")"); + ctorArgs.add(bodyCtor.toString()); + } + sb.append(" override fun buildRequest(ingredients: Map): Any =\n"); + sb.append(" ").append(name).append(".Request(\n"); + sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); + sb.append(" )\n\n"); + + // invoke + String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); + sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n"); + sb.append(" (handler as ").append(name).append(".Handler).").append(handlerMethod) + .append("(request as ").append(name).append(".Request)\n"); + sb.append("}\n"); + + return sb.toString(); + } + + @NotNull @Override public String emit(@NotNull Type type, @NotNull Module module) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Type.Shape shape) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Field field) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Reference reference) { return kotlinType(reference); } + @NotNull @Override public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Enum anEnum, @NotNull Module module) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Union union) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Refined refined) { return notYetImplemented(); } + @NotNull @Override public String emitValidator(@NotNull Refined refined) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Channel channel) { return notYetImplemented(); } + + private List collectInputs(Endpoint endpoint) { + List out = new ArrayList<>(); + for (Endpoint.Segment seg : endpoint.getPath()) { + if (seg instanceof Endpoint.Segment.Param p) { + out.add(new String[]{p.getIdentifier().getValue(), kotlinType(p.getReference())}); + } + } + for (Field q : endpoint.getQueries()) { + out.add(new String[]{q.getIdentifier().getValue(), kotlinType(q.getReference())}); + } + for (Field h : endpoint.getHeaders()) { + out.add(new String[]{h.getIdentifier().getValue(), kotlinType(h.getReference())}); + } + for (Endpoint.Request req : endpoint.getRequests()) { + if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { + Type bodyType = findType(c.getValue()); + if (bodyType != null) { + for (Field f : bodyType.getShape().getValue()) { + out.add(new String[]{f.getIdentifier().getValue(), kotlinType(f.getReference())}); + } + } + } + } + return out; + } + + private String bodyTypeName(Endpoint endpoint) { + for (Endpoint.Request req : endpoint.getRequests()) { + if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { + return c.getValue(); + } + } + return null; + } + + private Type findType(String name) { + if (currentModule == null) return null; + for (var stmt : currentModule.getStatements()) { + if (stmt instanceof Type t && t.getIdentifier().getValue().equals(name)) return t; + } + return null; + } + + private String kotlinType(Reference ref) { + String base; + if (ref instanceof Reference.Primitive primitive) { + base = switch (primitive.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { + case P32 -> "Int"; + case P64 -> "Long"; + }; + case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { + case P32 -> "Float"; + case P64 -> "Double"; + }; + case Reference.Primitive.Type.Boolean b -> "Boolean"; + case Reference.Primitive.Type.Bytes b -> "ByteArray"; + default -> "Any"; + }; + } else if (ref instanceof Reference.Custom c) { + base = c.getValue(); + } else if (ref instanceof Reference.Iterable it) { + base = "List<" + kotlinType(it.getReference()) + ">"; + } else if (ref instanceof Reference.Dict d) { + base = "Map"; + } else if (ref instanceof Reference.Unit) { + base = "Unit"; + } else { + base = "Any"; + } + return ref.isNullable() ? base + "?" : base; + } +} diff --git a/core/baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt b/core/baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt new file mode 100644 index 000000000..6c69601f5 --- /dev/null +++ b/core/baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt @@ -0,0 +1,101 @@ +package com.ing.baker.openapi.emitter + +import arrow.core.NonEmptyList +import community.flock.wirespec.compiler.core.FileUri +import community.flock.wirespec.compiler.core.emit.PackageName +import community.flock.wirespec.compiler.core.parse.ast.DefinitionIdentifier +import community.flock.wirespec.compiler.core.parse.ast.Endpoint +import community.flock.wirespec.compiler.core.parse.ast.Field +import community.flock.wirespec.compiler.core.parse.ast.FieldIdentifier +import community.flock.wirespec.compiler.core.parse.ast.Module +import community.flock.wirespec.compiler.core.parse.ast.Reference +import community.flock.wirespec.compiler.core.parse.ast.Type +import community.flock.wirespec.compiler.utils.Logger +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BakerOpenApiEmitterTest { + + private val emitter = BakerOpenApiEmitter(PackageName("com.example.generated")) + private val logger = object : Logger(Logger.Level.ERROR) { + override fun debug(s: String) = Unit + override fun info(s: String) = Unit + override fun warn(s: String) = Unit + override fun error(s: String) = Unit + } + + private fun field(name: String, ref: Reference) = Field(emptyList(), FieldIdentifier(name), ref) + private fun primString() = Reference.Primitive(Reference.Primitive.Type.String(null), false) + + @Test + fun `emits an object implementing ApiOperation for a POST endpoint with body`() { + val userType = Type( + null, emptyList(), + DefinitionIdentifier("UserDto"), + Type.Shape(listOf(field("firstName", primString()), field("email", primString()))), + emptyList(), + ) + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("CreateUser"), + method = Endpoint.Method.POST, + path = listOf(Endpoint.Segment.Literal("/users")), + queries = emptyList(), + headers = emptyList(), + requests = listOf( + Endpoint.Request(Endpoint.Content("application/json", Reference.Custom("UserDto", false))) + ), + responses = listOf( + Endpoint.Response("201", emptyList(), + Endpoint.Content("application/json", Reference.Custom("UserDto", false)), + emptyList()), + Endpoint.Response("400", emptyList(), + Endpoint.Content("application/json", Reference.Custom("UserDto", false)), + emptyList()), + ), + ) + val module = Module(FileUri("test.ws"), NonEmptyList(userType, listOf(endpoint))) + + val emitted = emitter.emit(endpoint, module, logger) + + val src = emitted.result + assertTrue(src.contains("package com.example.generated.api"), src) + assertTrue(src.contains("import com.ing.baker.openapi.dsl.ApiOperation"), src) + assertTrue(src.contains("import com.ing.baker.openapi.dsl.InputField"), src) + assertTrue(src.contains("import com.example.generated.endpoint.CreateUser"), src) + assertTrue(src.contains("object CreateUser : ApiOperation"), src) + assertTrue(src.contains("override val operationName = \"CreateUser\""), src) + assertTrue(src.contains("InputField(\"firstName\", String::class)"), src) + assertTrue(src.contains("InputField(\"email\", String::class)"), src) + assertTrue(src.contains("201 to CreateUser.Response201::class"), src) + assertTrue(src.contains("400 to CreateUser.Response400::class"), src) + assertTrue(src.contains("override val handlerClass = CreateUser.Handler::class"), src) + assertEquals("com/example/generated/api/CreateUser", emitted.file) + } + + @Test + fun `emits InputField for path parameters`() { + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("GetUser"), + method = Endpoint.Method.GET, + path = listOf( + Endpoint.Segment.Literal("/users/"), + Endpoint.Segment.Param(FieldIdentifier("id"), primString()), + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response("200", emptyList(), null, emptyList()) + ), + ) + val module = Module(FileUri("test.ws"), NonEmptyList(endpoint, emptyList())) + + val src = emitter.emit(endpoint, module, logger).result + assertTrue(src.contains("InputField(\"id\", String::class)"), src) + } +} diff --git a/core/baker-openapi/baker-openapi-plugin/pom.xml b/core/baker-openapi/baker-openapi-plugin/pom.xml new file mode 100644 index 000000000..8c9f58392 --- /dev/null +++ b/core/baker-openapi/baker-openapi-plugin/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-plugin + Baker OpenAPI Maven Plugin + maven-plugin + + + 3.9.6 + + + + + org.apache.maven + maven-plugin-api + ${maven.api.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.10.2 + provided + + + org.apache.maven + maven-core + ${maven.api.version} + provided + + + com.ing.baker + baker-openapi-emitter + ${project.version} + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + community.flock.wirespec.compiler.emitters + kotlin-jvm + ${wirespec.version} + + + community.flock.wirespec.converter + openapi-jvm + ${wirespec.version} + + + community.flock.wirespec.plugin.arguments + arguments-jvm + ${wirespec.version} + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + src/main/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + compile + process-sources + compile + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.10.2 + + + default-descriptor + process-classes + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + + diff --git a/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt b/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt new file mode 100644 index 000000000..eee44d0e4 --- /dev/null +++ b/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt @@ -0,0 +1,99 @@ +package com.ing.baker.openapi.plugin + +import arrow.core.nonEmptySetOf +import com.ing.baker.openapi.emitter.BakerOpenApiEmitter +import community.flock.wirespec.compiler.core.emit.EmitShared +import community.flock.wirespec.compiler.core.emit.PackageName +import community.flock.wirespec.emitters.kotlin.KotlinEmitter +import community.flock.wirespec.compiler.utils.Logger +import community.flock.wirespec.plugin.ConverterArguments +import community.flock.wirespec.plugin.Format +import community.flock.wirespec.plugin.convert +import community.flock.wirespec.plugin.io.Source +import org.apache.maven.plugin.AbstractMojo +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugins.annotations.LifecyclePhase +import org.apache.maven.plugins.annotations.Mojo +import org.apache.maven.plugins.annotations.Parameter +import org.apache.maven.project.MavenProject +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths + +@Mojo(name = "generate-from-openapi", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true) +class GenerateFromOpenApiMojo : AbstractMojo() { + + @Parameter(required = true) + private lateinit var input: String + + @Parameter(required = true) + private lateinit var packageName: String + + @Parameter(defaultValue = "\${project.build.directory}/generated-sources/baker-openapi") + private lateinit var outputDirectory: String + + @Parameter(defaultValue = "true") + private var addToSources: Boolean = true + + @Parameter(defaultValue = "\${project}", readonly = true, required = true) + private lateinit var project: MavenProject + + private val logger: Logger = object : Logger(Level.INFO) { + override fun debug(string: String) { log.debug(string) } + override fun info(string: String) { log.info(string) } + override fun warn(string: String) { log.warn(string) } + override fun error(string: String) { log.error(string) } + } + + override fun execute() { + val inputPath = Paths.get(input) + if (!Files.exists(inputPath)) { + throw MojoExecutionException("OpenAPI file not found: $input") + } + val json = Files.readString(inputPath) + val pkg = PackageName(packageName) + + val outDir = File(outputDirectory).apply { mkdirs() } + + val source = Source( + name = community.flock.wirespec.plugin.io.Name(inputPath.fileName.toString().substringBeforeLast('.')), + content = json, + ) + + val emitters = nonEmptySetOf( + KotlinEmitter(pkg, EmitShared(false)) as community.flock.wirespec.compiler.core.emit.Emitter, + BakerOpenApiEmitter(pkg) as community.flock.wirespec.compiler.core.emit.Emitter, + ) + + val writer: (arrow.core.NonEmptyList) -> Unit = { emitted -> + emitted.forEach { e -> + val target = File(outDir, e.file) + target.parentFile.mkdirs() + target.writeText(e.result) + } + } + + val args = ConverterArguments( + format = Format.OpenAPIV3, + input = nonEmptySetOf(source), + emitters = emitters, + writer = writer, + error = { msg -> throw MojoExecutionException(msg) }, + packageName = pkg, + logger = logger, + shared = false, + strict = true, + ) + + try { + convert(args) + } catch (e: Exception) { + throw MojoExecutionException("OpenAPI conversion failed: ${e.message}", e) + } + + if (addToSources) { + project.addCompileSourceRoot(outDir.absolutePath) + log.info("Added ${outDir.absolutePath} as compile source root") + } + } +} diff --git a/core/baker-openapi/baker-openapi-transportation/pom.xml b/core/baker-openapi/baker-openapi-transportation/pom.xml new file mode 100644 index 000000000..6669517b5 --- /dev/null +++ b/core/baker-openapi/baker-openapi-transportation/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-transportation + Baker OpenAPI Transportation + Default HTTP transport (java.net.http.HttpClient) bridging wirespec RawRequest/RawResponse + + + + org.jetbrains.kotlin + kotlin-stdlib + + + community.flock.wirespec.integration + wirespec-jvm + ${wirespec.version} + + + + + src/main/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + compile + process-sources + compile + + + + + + diff --git a/core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt b/core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt new file mode 100644 index 000000000..0aa9527d3 --- /dev/null +++ b/core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt @@ -0,0 +1,46 @@ +package com.ing.baker.openapi.transportation + +import community.flock.wirespec.kotlin.Wirespec +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +typealias Transportation = suspend (Wirespec.RawRequest) -> Wirespec.RawResponse + +fun javaHttpTransportation(baseUrl: String, client: HttpClient = HttpClient.newHttpClient()): Transportation = { rawRequest -> + val path = rawRequest.path.joinToString("/") + val queryString = rawRequest.queries + .flatMap { (key, values) -> values.map { "$key=$it" } } + .joinToString("&") + .let { if (it.isNotEmpty()) "?$it" else "" } + + val uri = URI.create("$baseUrl/$path$queryString") + + val bodyPublisher = rawRequest.body + ?.let { HttpRequest.BodyPublishers.ofByteArray(it) } + ?: HttpRequest.BodyPublishers.noBody() + + val builder = HttpRequest.newBuilder() + .uri(uri) + .method(rawRequest.method, bodyPublisher) + + rawRequest.headers.forEach { (name, values) -> + values.forEach { value -> builder.header(name, value) } + } + + if (rawRequest.body != null) { + builder.header("Content-Type", "application/json") + } + + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()) + + val responseHeaders = response.headers().map() + .mapValues { (_, values) -> values.toList() } + + Wirespec.RawResponse( + statusCode = response.statusCode(), + headers = responseHeaders, + body = response.body()?.takeIf { it.isNotEmpty() } + ) +} diff --git a/core/baker-openapi/pom.xml b/core/baker-openapi/pom.xml new file mode 100644 index 000000000..7c8b73eef --- /dev/null +++ b/core/baker-openapi/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-openapi + Baker OpenAPI (aggregator) + pom + + + 0.17.20 + + + + baker-openapi-dsl + baker-openapi-emitter + baker-openapi-plugin + baker-openapi-transportation + + diff --git a/core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt b/core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt index 9d71aa74e..a96b4e649 100644 --- a/core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt +++ b/core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt @@ -98,6 +98,14 @@ class RecipeBuilder(private val name: String) { interactions.add(InteractionBuilder(T::class).apply(configuration).build()) } + /** + * Adds a pre-built [Interaction] directly. Intended for DSL extensions that + * construct their own [Interaction] (e.g. baker-openapi-dsl's `api(...)`). + */ + fun addInteraction(interaction: Interaction) { + interactions.add(interaction) + } + /** * Registers a sieve [T1, R] to the recipe. diff --git a/core/baker-wirespec/pom.xml b/core/baker-wirespec/pom.xml new file mode 100644 index 000000000..3fadf20b4 --- /dev/null +++ b/core/baker-wirespec/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-wirespec + Baker Wirespec + Wirespec emitters that generate Baker Interaction interfaces from API endpoint definitions + + + 0.17.20 + + + + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + + + org.jetbrains + annotations + 13.0 + + + + + org.jetbrains.kotlin + kotlin-stdlib + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + src/test/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + test-compile + + test-compile + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java b/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java new file mode 100644 index 000000000..7c366273c --- /dev/null +++ b/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java @@ -0,0 +1,246 @@ +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BakerJavaEmitter extends LanguageEmitter { + + @NotNull + @Override + public String getSingleLineComment() { + return "//"; + } + + @NotNull + @Override + public FileExtension getExtension() { + return FileExtension.Java; + } + + @Nullable + @Override + public Shared getShared() { + return null; + } + + @NotNull + @Override + public String notYetImplemented() { + return ""; + } + + @NotNull + @Override + public String emit(@NotNull Identifier identifier) { + return identifier.getValue(); + } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + String interactionName = name + "Interaction"; + String outcomeName = name + "Outcome"; + + StringBuilder sb = new StringBuilder(); + + // Imports + sb.append("import com.ing.baker.recipe.javadsl.Interaction;\n"); + sb.append("import com.ing.baker.recipe.annotations.FiresEvent;\n\n"); + + // Interface + sb.append("public interface ").append(interactionName).append(" extends Interaction {\n"); + sb.append(" interface ").append(outcomeName).append(" {}\n"); + + // Response events — one per status code + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + if (response.getContent() != null) { + String bodyType = emitReference(response.getContent().getReference()); + sb.append(" class ").append(eventName) + .append(" implements ").append(outcomeName).append(" {\n"); + sb.append(" public final ").append(bodyType).append(" body;\n"); + sb.append(" public ").append(eventName).append("(").append(bodyType).append(" body) { this.body = body; }\n"); + sb.append(" }\n"); + } else { + sb.append(" class ").append(eventName) + .append(" implements ").append(outcomeName).append(" {}\n"); + } + } + + sb.append("\n"); + + // @FiresEvent annotation + String responseClasses = endpoint.getResponses().stream() + .map(r -> name + "Response" + r.getStatus() + ".class") + .collect(Collectors.joining(", ")); + sb.append(" @FiresEvent(oneOf = {").append(responseClasses).append("})\n"); + + // apply() method — collect all input params + List params = collectParams(endpoint); + String paramList = params.stream() + .map(p -> p[1] + " " + p[0]) + .collect(Collectors.joining(", ")); + sb.append(" ").append(outcomeName).append(" apply(").append(paramList).append(");\n"); + sb.append("}\n"); + + return sb.toString(); + } + + @NotNull + @Override + public String emit(@NotNull Type type, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Type.Shape shape) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Field field) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Reference reference) { + return emitReference(reference); + } + + @NotNull + @Override + public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Enum anEnum, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Union union) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emitValidator(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Channel channel) { + return notYetImplemented(); + } + + /** + * Maps wirespec Reference types to Java type names. + */ + private String emitReference(Reference reference) { + String typeName; + if (reference instanceof Reference.Primitive primitive) { + typeName = switch (primitive.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { + case P32 -> "Integer"; + case P64 -> "Long"; + }; + case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { + case P32 -> "Float"; + case P64 -> "Double"; + }; + case Reference.Primitive.Type.Boolean b -> "Boolean"; + case Reference.Primitive.Type.Bytes b -> "byte[]"; + default -> "Object"; + }; + } else if (reference instanceof Reference.Custom custom) { + typeName = custom.getValue(); + } else if (reference instanceof Reference.Iterable iterable) { + typeName = "java.util.List<" + emitReference(iterable.getReference()) + ">"; + } else if (reference instanceof Reference.Dict dict) { + typeName = "java.util.Map"; + } else if (reference instanceof Reference.Unit u) { + typeName = "Void"; + } else { + typeName = "Object"; + } + return typeName; + } + + /** + * Collects all input parameters from path segments, query params, headers, and request body. + * Returns list of [name, type] pairs. + */ + private List collectParams(Endpoint endpoint) { + List params = new ArrayList<>(); + + // Path parameters + for (Endpoint.Segment segment : endpoint.getPath()) { + if (segment instanceof Endpoint.Segment.Param param) { + params.add(new String[]{ + param.getIdentifier().getValue(), + emitReference(param.getReference()) + }); + } + } + + // Query parameters + for (Field query : endpoint.getQueries()) { + params.add(new String[]{ + query.getIdentifier().getValue(), + emitReference(query.getReference()) + }); + } + + // Header parameters + for (Field header : endpoint.getHeaders()) { + params.add(new String[]{ + header.getIdentifier().getValue(), + emitReference(header.getReference()) + }); + } + + // Request body + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null) { + params.add(new String[]{ + "body", + emitReference(request.getContent().getReference()) + }); + } + } + + return params; + } +} diff --git a/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java b/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java new file mode 100644 index 000000000..8fbf94db1 --- /dev/null +++ b/core/baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java @@ -0,0 +1,480 @@ +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.emit.Emitted; +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.PackageName; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Definition; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import community.flock.wirespec.compiler.utils.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class BakerKotlinEmitter extends LanguageEmitter { + + private final PackageName packageName; + private Module currentModule; + + public BakerKotlinEmitter(PackageName packageName) { + this.packageName = packageName; + } + + public BakerKotlinEmitter() { + this.packageName = null; + } + + /** + * Sets the module context for type lookups during flattening. + * Package-private for testing purposes. + */ + void setModule(Module module) { + this.currentModule = module; + } + + @NotNull + @Override + public String getSingleLineComment() { + return "//"; + } + + @NotNull + @Override + public FileExtension getExtension() { + return FileExtension.Kotlin; + } + + @Nullable + @Override + public Shared getShared() { + return null; + } + + @NotNull + @Override + public String notYetImplemented() { + return ""; + } + + @NotNull + @Override + public Emitted emit(@NotNull Definition definition, @NotNull Module module, @NotNull Logger logger) { + this.currentModule = module; + Emitted base = super.emit(definition, module, logger); + if (packageName != null && !packageName.getValue().isEmpty()) { + String dir = packageName.getValue().replace('.', '/') + "/"; + return new Emitted(dir + base.getFile(), base.getResult()); + } + return base; + } + + @NotNull + @Override + public String emit(@NotNull Identifier identifier) { + return identifier.getValue(); + } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + String interactionName = name + "Interaction"; + String outcomeName = name + "Outcome"; + + StringBuilder sb = new StringBuilder(); + + // Package declaration + if (packageName != null && !packageName.getValue().isEmpty()) { + sb.append("package ").append(packageName.getValue()).append("\n\n"); + } + + // Collect referenced custom types for imports + Set customTypes = collectCustomTypeNames(endpoint); + + // Import Baker Interaction and coroutines + sb.append("import com.ing.baker.recipe.javadsl.Interaction\n"); + sb.append("import kotlinx.coroutines.runBlocking\n"); + + // Import referenced types from endpoint and model sub-packages + if (packageName != null && !packageName.getValue().isEmpty()) { + String pkg = packageName.getValue(); + // Import endpoint class (for Handler, Request, Response types) + sb.append("import ").append(pkg).append(".endpoint.").append(name).append("\n"); + // Import model types + for (String typeName : customTypes) { + sb.append("import ").append(pkg).append(".model.").append(typeName).append("\n"); + } + } + + sb.append("\n"); + + // Interface + sb.append("interface ").append(interactionName).append(" : Interaction {\n"); + sb.append(" sealed interface ").append(outcomeName).append("\n"); + + // Response events — one per status code + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + if (response.getContent() != null) { + Reference ref = response.getContent().getReference(); + if (ref instanceof Reference.Custom custom) { + Type responseType = findTypeInModule(custom.getValue()); + if (responseType != null) { + // Flatten response fields + String fields = responseType.getShape().getValue().stream() + .map(f -> "val " + f.getIdentifier().getValue() + ": " + emitReference(f.getReference())) + .collect(Collectors.joining(", ")); + sb.append(" data class ").append(eventName) + .append("(").append(fields).append(") : ") + .append(outcomeName).append("\n"); + } else { + // Fallback: use body param + sb.append(" data class ").append(eventName) + .append("(val body: ").append(custom.getValue()).append(") : ") + .append(outcomeName).append("\n"); + } + } else { + String bodyType = emitReference(ref); + sb.append(" data class ").append(eventName) + .append("(val body: ").append(bodyType).append(") : ") + .append(outcomeName).append("\n"); + } + } else { + sb.append(" data object ").append(eventName).append(" : ") + .append(outcomeName).append("\n"); + } + } + + sb.append("\n"); + + // apply() method — collect all input params + List params = collectParams(endpoint); + String paramList = params.stream() + .map(p -> p[0] + ": " + p[1]) + .collect(Collectors.joining(", ")); + sb.append(" fun apply(").append(paramList).append("): ").append(outcomeName).append("\n"); + sb.append("}\n\n"); + + // Find request body type info for implementation + String requestBodyTypeName = null; + Type requestBodyType = null; + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null && request.getContent().getReference() instanceof Reference.Custom custom) { + requestBodyTypeName = custom.getValue(); + requestBodyType = findTypeInModule(custom.getValue()); + break; + } + } + + // Implementation class + String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); + sb.append("class ").append(interactionName).append("Impl(\n"); + sb.append(" private val client: ").append(name).append(".Handler\n"); + sb.append(") : ").append(interactionName).append(" {\n"); + + sb.append(" override fun apply(").append(paramList).append("): ") + .append(interactionName).append(".").append(outcomeName).append(" {\n"); + + if (requestBodyType != null) { + // Construct body from flattened params + String namedArgs = requestBodyType.getShape().getValue().stream() + .map(f -> f.getIdentifier().getValue() + " = " + f.getIdentifier().getValue()) + .collect(Collectors.joining(", ")); + sb.append(" val body = ").append(requestBodyTypeName).append("(").append(namedArgs).append(")\n"); + // Build request with non-body params + body + List requestArgs = new ArrayList<>(); + // Path params + for (Endpoint.Segment segment : endpoint.getPath()) { + if (segment instanceof Endpoint.Segment.Param param) { + requestArgs.add(param.getIdentifier().getValue()); + } + } + // Query params + for (Field query : endpoint.getQueries()) { + requestArgs.add(query.getIdentifier().getValue()); + } + // Header params + for (Field header : endpoint.getHeaders()) { + requestArgs.add(header.getIdentifier().getValue()); + } + // Body + requestArgs.add("body"); + String requestArgList = String.join(", ", requestArgs); + sb.append(" val request = ").append(name).append(".Request(").append(requestArgList).append(")\n"); + } else { + String argList = params.stream() + .map(p -> p[0]) + .collect(Collectors.joining(", ")); + sb.append(" val request = ").append(name).append(".Request(").append(argList).append(")\n"); + } + + sb.append(" val response = runBlocking { client.").append(handlerMethod).append("(request) }\n"); + sb.append(" return when (response) {\n"); + + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + sb.append(" is ").append(name).append(".Response").append(response.getStatus()) + .append(" -> ").append(interactionName).append(".").append(eventName); + if (response.getContent() != null) { + Reference ref = response.getContent().getReference(); + if (ref instanceof Reference.Custom custom) { + Type responseType = findTypeInModule(custom.getValue()); + if (responseType != null) { + String fieldMappings = responseType.getShape().getValue().stream() + .map(f -> f.getIdentifier().getValue() + " = response.body." + f.getIdentifier().getValue()) + .collect(Collectors.joining(", ")); + sb.append("(").append(fieldMappings).append(")"); + } else { + sb.append("(response.body)"); + } + } else { + sb.append("(response.body)"); + } + } + sb.append("\n"); + } + + sb.append(" }\n"); + sb.append(" }\n"); + sb.append("}"); + + return sb.toString(); + } + + @NotNull + @Override + public String emit(@NotNull Type type, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Type.Shape shape) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Field field) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Reference reference) { + return emitReference(reference); + } + + @NotNull + @Override + public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Enum anEnum, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Union union) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emitValidator(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Channel channel) { + return notYetImplemented(); + } + + /** + * Looks up a Type definition by name in the current module. + */ + private Type findTypeInModule(String typeName) { + if (currentModule == null) return null; + for (var stmt : currentModule.getStatements()) { + if (stmt instanceof Type type && type.getIdentifier().getValue().equals(typeName)) { + return type; + } + } + return null; + } + + /** + * Maps wirespec Reference types to Kotlin type names. + */ + private String emitReference(Reference reference) { + String typeName; + if (reference instanceof Reference.Primitive primitive) { + typeName = switch (primitive.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { + case P32 -> "Int"; + case P64 -> "Long"; + }; + case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { + case P32 -> "Float"; + case P64 -> "Double"; + }; + case Reference.Primitive.Type.Boolean b -> "Boolean"; + case Reference.Primitive.Type.Bytes b -> "ByteArray"; + default -> "Any"; + }; + } else if (reference instanceof Reference.Custom custom) { + typeName = custom.getValue(); + } else if (reference instanceof Reference.Iterable iterable) { + typeName = "List<" + emitReference(iterable.getReference()) + ">"; + } else if (reference instanceof Reference.Dict dict) { + typeName = "Map"; + } else if (reference instanceof Reference.Unit u) { + typeName = "Unit"; + } else { + typeName = "Any"; + } + return reference.isNullable() ? typeName + "?" : typeName; + } + + /** + * Collects all input parameters from path segments, query params, headers, and request body. + * Returns list of [name, type] pairs. + */ + private List collectParams(Endpoint endpoint) { + List params = new ArrayList<>(); + + // Path parameters + for (Endpoint.Segment segment : endpoint.getPath()) { + if (segment instanceof Endpoint.Segment.Param param) { + params.add(new String[]{ + param.getIdentifier().getValue(), + emitReference(param.getReference()) + }); + } + } + + // Query parameters + for (Field query : endpoint.getQueries()) { + params.add(new String[]{ + query.getIdentifier().getValue(), + emitReference(query.getReference()) + }); + } + + // Header parameters + for (Field header : endpoint.getHeaders()) { + params.add(new String[]{ + header.getIdentifier().getValue(), + emitReference(header.getReference()) + }); + } + + // Request body — flatten if possible + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null) { + Reference ref = request.getContent().getReference(); + if (ref instanceof Reference.Custom custom) { + Type bodyType = findTypeInModule(custom.getValue()); + if (bodyType != null) { + // Flatten: add each field as a parameter + for (Field field : bodyType.getShape().getValue()) { + params.add(new String[]{ + field.getIdentifier().getValue(), + emitReference(field.getReference()) + }); + } + continue; // skip adding "body" param + } + } + // Fallback: add as "body" param + params.add(new String[]{ + "body", + emitReference(ref) + }); + } + } + + return params; + } + + /** + * Collects all custom (non-primitive) type names referenced in an endpoint. + * When flattening is possible, also collects the body type itself and any custom types in its fields. + */ + private Set collectCustomTypeNames(Endpoint endpoint) { + Set types = new HashSet<>(); + + // Request body types + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null) { + Reference ref = request.getContent().getReference(); + collectCustomTypeNamesFromRef(ref, types); + // When flattening, also collect custom types from the body type's fields + if (ref instanceof Reference.Custom custom) { + Type bodyType = findTypeInModule(custom.getValue()); + if (bodyType != null) { + for (Field field : bodyType.getShape().getValue()) { + collectCustomTypeNamesFromRef(field.getReference(), types); + } + } + } + } + } + + // Response body types + for (Endpoint.Response response : endpoint.getResponses()) { + if (response.getContent() != null) { + Reference ref = response.getContent().getReference(); + collectCustomTypeNamesFromRef(ref, types); + // When flattening, also collect custom types from the response type's fields + if (ref instanceof Reference.Custom custom) { + Type responseType = findTypeInModule(custom.getValue()); + if (responseType != null) { + for (Field field : responseType.getShape().getValue()) { + collectCustomTypeNamesFromRef(field.getReference(), types); + } + } + } + } + } + + return types; + } + + private void collectCustomTypeNamesFromRef(Reference reference, Set types) { + if (reference instanceof Reference.Custom custom) { + types.add(custom.getValue()); + } else if (reference instanceof Reference.Iterable iterable) { + collectCustomTypeNamesFromRef(iterable.getReference(), types); + } else if (reference instanceof Reference.Dict dict) { + collectCustomTypeNamesFromRef(dict.getReference(), types); + } + } +} diff --git a/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.kt b/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.kt new file mode 100644 index 000000000..b0530bb83 --- /dev/null +++ b/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.kt @@ -0,0 +1,189 @@ +package com.ing.baker.recipe.wirespec + +import community.flock.wirespec.compiler.core.parse.ast.* +import community.flock.wirespec.compiler.core.parse.ast.Reference.Primitive.Type.Precision +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BakerJavaEmitterTest { + + private val emitter = BakerJavaEmitter() + + @Test + fun emitGetEndpointWithPathParamAndMultipleResponses() { + // GET /todos/{id: Integer} -> { 200 -> TodoDto, 404 -> ErrorDto } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("GetTodo"), + method = Endpoint.Method.GET, + path = listOf( + Endpoint.Segment.Literal("/todos/"), + Endpoint.Segment.Param( + FieldIdentifier("id"), + Reference.Primitive( + Reference.Primitive.Type.Integer(Precision.P64, null), + false + ) + ) + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response( + "200", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("TodoDto", false)), + emptyList() + ), + Endpoint.Response( + "404", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("ErrorDto", false)), + emptyList() + ) + ) + ) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface GetTodoInteraction extends Interaction { + interface GetTodoOutcome {} + class GetTodoResponse200 implements GetTodoOutcome { + public final TodoDto body; + public GetTodoResponse200(TodoDto body) { this.body = body; } + } + class GetTodoResponse404 implements GetTodoOutcome { + public final ErrorDto body; + public GetTodoResponse404(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {GetTodoResponse200.class, GetTodoResponse404.class}) + GetTodoOutcome apply(Long id); + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } + + @Test + fun emitPostEndpointWithBodyAndMultipleResponses() { + // POST /todos CreateTodoRequest -> { 201 -> TodoDto, 400 -> ErrorDto } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("CreateTodo"), + method = Endpoint.Method.POST, + path = listOf(Endpoint.Segment.Literal("/todos")), + queries = emptyList(), + headers = emptyList(), + requests = listOf( + Endpoint.Request( + Endpoint.Content("application/json", Reference.Custom("CreateTodoRequest", false)) + ) + ), + responses = listOf( + Endpoint.Response( + "201", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("TodoDto", false)), + emptyList() + ), + Endpoint.Response( + "400", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("ErrorDto", false)), + emptyList() + ) + ) + ) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface CreateTodoInteraction extends Interaction { + interface CreateTodoOutcome {} + class CreateTodoResponse201 implements CreateTodoOutcome { + public final TodoDto body; + public CreateTodoResponse201(TodoDto body) { this.body = body; } + } + class CreateTodoResponse400 implements CreateTodoOutcome { + public final ErrorDto body; + public CreateTodoResponse400(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {CreateTodoResponse201.class, CreateTodoResponse400.class}) + CreateTodoOutcome apply(CreateTodoRequest body); + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } + + @Test + fun emitEndpointWithNoBodyResponse() { + // DELETE /todos/{id} -> { 204 -> (no body), 404 -> ErrorDto } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("DeleteTodo"), + method = Endpoint.Method.DELETE, + path = listOf( + Endpoint.Segment.Literal("/todos/"), + Endpoint.Segment.Param( + FieldIdentifier("id"), + Reference.Primitive( + Reference.Primitive.Type.Integer(Precision.P64, null), + false + ) + ) + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response( + "204", + emptyList(), + null, + emptyList() + ), + Endpoint.Response( + "404", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("ErrorDto", false)), + emptyList() + ) + ) + ) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface DeleteTodoInteraction extends Interaction { + interface DeleteTodoOutcome {} + class DeleteTodoResponse204 implements DeleteTodoOutcome {} + class DeleteTodoResponse404 implements DeleteTodoOutcome { + public final ErrorDto body; + public DeleteTodoResponse404(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {DeleteTodoResponse204.class, DeleteTodoResponse404.class}) + DeleteTodoOutcome apply(Long id); + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } +} diff --git a/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.kt b/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.kt new file mode 100644 index 000000000..73c0a1ebd --- /dev/null +++ b/core/baker-wirespec/src/test/kotlin/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.kt @@ -0,0 +1,338 @@ +package com.ing.baker.recipe.wirespec + +import arrow.core.NonEmptyList +import community.flock.wirespec.compiler.core.FileUri +import community.flock.wirespec.compiler.core.parse.ast.* +import community.flock.wirespec.compiler.core.parse.ast.Reference.Primitive.Type.Precision +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BakerKotlinEmitterTest { + + private val emitter = BakerKotlinEmitter() + + // Workaround for Kotlin name collision: Reference.Primitive.Type.Boolean clashes with kotlin.Boolean + private val BOOLEAN_TYPE: Reference.Primitive.Type = + Class.forName("community.flock.wirespec.compiler.core.parse.ast.Reference\$Primitive\$Type\$Boolean") + .getField("INSTANCE").get(null) as Reference.Primitive.Type + + // Helper to create a Type definition + private fun type(name: String, vararg fields: Field): Type = + Type( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier(name), + shape = Type.Shape(fields.toList()), + extends = emptyList() + ) + + // Helper to create a Field + private fun field(name: String, ref: Reference): Field = + Field(emptyList(), FieldIdentifier(name), ref) + + // Helper to create a Module with definitions and set it on the emitter + private fun setModuleWithTypes(vararg definitions: Definition) { + val module = Module( + fileUri = FileUri("test.ws"), + statements = NonEmptyList(definitions.first(), definitions.drop(1)) + ) + emitter.setModule(module) + } + + @Test + fun emitSimpleGetEndpoint() { + // Define the types used in responses + val todoDtoType = type( + "TodoDto", + field("id", Reference.Primitive(Reference.Primitive.Type.Integer(Precision.P64, null), false)), + field("title", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("completed", Reference.Primitive(BOOLEAN_TYPE, false)) + ) + val errorDtoType = type( + "ErrorDto", + field("code", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("message", Reference.Primitive(Reference.Primitive.Type.String(null), false)) + ) + + // GET /todos/{id: Integer} -> { 200 -> TodoDto, 404 -> ErrorDto } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("GetTodo"), + method = Endpoint.Method.GET, + path = listOf( + Endpoint.Segment.Literal("/todos/"), + Endpoint.Segment.Param( + FieldIdentifier("id"), + Reference.Primitive( + Reference.Primitive.Type.Integer(Precision.P64, null), + false + ) + ) + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response( + "200", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("TodoDto", false)), + emptyList() + ), + Endpoint.Response( + "404", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("ErrorDto", false)), + emptyList() + ) + ) + ) + + setModuleWithTypes(todoDtoType, errorDtoType, endpoint) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction + import kotlinx.coroutines.runBlocking + + interface GetTodoInteraction : Interaction { + sealed interface GetTodoOutcome + data class GetTodoResponse200(val id: Long, val title: String, val completed: Boolean) : GetTodoOutcome + data class GetTodoResponse404(val code: String, val message: String) : GetTodoOutcome + + fun apply(id: Long): GetTodoOutcome + } + + class GetTodoInteractionImpl( + private val client: GetTodo.Handler + ) : GetTodoInteraction { + override fun apply(id: Long): GetTodoInteraction.GetTodoOutcome { + val request = GetTodo.Request(id) + val response = runBlocking { client.getTodo(request) } + return when (response) { + is GetTodo.Response200 -> GetTodoInteraction.GetTodoResponse200(id = response.body.id, title = response.body.title, completed = response.body.completed) + is GetTodo.Response404 -> GetTodoInteraction.GetTodoResponse404(code = response.body.code, message = response.body.message) + } + } + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } + + @Test + fun emitPostEndpointWithBody() { + // Define the types used in request and responses + val createTodoRequestType = type( + "CreateTodoRequest", + field("title", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("completed", Reference.Primitive(BOOLEAN_TYPE, false)) + ) + val todoDtoType = type( + "TodoDto", + field("id", Reference.Primitive(Reference.Primitive.Type.Integer(Precision.P64, null), false)), + field("title", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("completed", Reference.Primitive(BOOLEAN_TYPE, false)) + ) + val errorDtoType = type( + "ErrorDto", + field("code", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("message", Reference.Primitive(Reference.Primitive.Type.String(null), false)) + ) + + // POST /todos CreateTodoRequest -> { 201 -> TodoDto, 400 -> ErrorDto } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("CreateTodo"), + method = Endpoint.Method.POST, + path = listOf(Endpoint.Segment.Literal("/todos")), + queries = emptyList(), + headers = emptyList(), + requests = listOf( + Endpoint.Request( + Endpoint.Content("application/json", Reference.Custom("CreateTodoRequest", false)) + ) + ), + responses = listOf( + Endpoint.Response( + "201", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("TodoDto", false)), + emptyList() + ), + Endpoint.Response( + "400", + emptyList(), + Endpoint.Content("application/json", Reference.Custom("ErrorDto", false)), + emptyList() + ) + ) + ) + + setModuleWithTypes(createTodoRequestType, todoDtoType, errorDtoType, endpoint) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction + import kotlinx.coroutines.runBlocking + + interface CreateTodoInteraction : Interaction { + sealed interface CreateTodoOutcome + data class CreateTodoResponse201(val id: Long, val title: String, val completed: Boolean) : CreateTodoOutcome + data class CreateTodoResponse400(val code: String, val message: String) : CreateTodoOutcome + + fun apply(title: String, completed: Boolean): CreateTodoOutcome + } + + class CreateTodoInteractionImpl( + private val client: CreateTodo.Handler + ) : CreateTodoInteraction { + override fun apply(title: String, completed: Boolean): CreateTodoInteraction.CreateTodoOutcome { + val body = CreateTodoRequest(title = title, completed = completed) + val request = CreateTodo.Request(body) + val response = runBlocking { client.createTodo(request) } + return when (response) { + is CreateTodo.Response201 -> CreateTodoInteraction.CreateTodoResponse201(id = response.body.id, title = response.body.title, completed = response.body.completed) + is CreateTodo.Response400 -> CreateTodoInteraction.CreateTodoResponse400(code = response.body.code, message = response.body.message) + } + } + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } + + @Test + fun emitEndpointWithQueryParams() { + // Define the type used in response + val todoDtoType = type( + "TodoDto", + field("id", Reference.Primitive(Reference.Primitive.Type.Integer(Precision.P64, null), false)), + field("title", Reference.Primitive(Reference.Primitive.Type.String(null), false)), + field("completed", Reference.Primitive(BOOLEAN_TYPE, false)) + ) + + // GET /todos ? search: String, limit: Integer -> { 200 -> TodoDto[] } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("ListTodos"), + method = Endpoint.Method.GET, + path = listOf(Endpoint.Segment.Literal("/todos")), + queries = listOf( + Field( + emptyList(), + FieldIdentifier("search"), + Reference.Primitive(Reference.Primitive.Type.String(null), false) + ), + Field( + emptyList(), + FieldIdentifier("limit"), + Reference.Primitive(Reference.Primitive.Type.Integer(Precision.P32, null), false) + ) + ), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response( + "200", + emptyList(), + Endpoint.Content( + "application/json", + Reference.Iterable(Reference.Custom("TodoDto", false), false) + ), + emptyList() + ) + ) + ) + + setModuleWithTypes(todoDtoType, endpoint) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction + import kotlinx.coroutines.runBlocking + + interface ListTodosInteraction : Interaction { + sealed interface ListTodosOutcome + data class ListTodosResponse200(val body: List) : ListTodosOutcome + + fun apply(search: String, limit: Int): ListTodosOutcome + } + + class ListTodosInteractionImpl( + private val client: ListTodos.Handler + ) : ListTodosInteraction { + override fun apply(search: String, limit: Int): ListTodosInteraction.ListTodosOutcome { + val request = ListTodos.Request(search, limit) + val response = runBlocking { client.listTodos(request) } + return when (response) { + is ListTodos.Response200 -> ListTodosInteraction.ListTodosResponse200(response.body) + } + } + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } + + @Test + fun emitEndpointWithNoBodyResponse() { + // DELETE /todos/{id} -> { 204 -> (no body) } + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("DeleteTodo"), + method = Endpoint.Method.DELETE, + path = listOf( + Endpoint.Segment.Literal("/todos/"), + Endpoint.Segment.Param( + FieldIdentifier("id"), + Reference.Primitive( + Reference.Primitive.Type.Integer(Precision.P64, null), + false + ) + ) + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response("204", emptyList(), null, emptyList()) + ) + ) + + val result = emitter.emit(endpoint) + + val expected = """ + import com.ing.baker.recipe.javadsl.Interaction + import kotlinx.coroutines.runBlocking + + interface DeleteTodoInteraction : Interaction { + sealed interface DeleteTodoOutcome + data object DeleteTodoResponse204 : DeleteTodoOutcome + + fun apply(id: Long): DeleteTodoOutcome + } + + class DeleteTodoInteractionImpl( + private val client: DeleteTodo.Handler + ) : DeleteTodoInteraction { + override fun apply(id: Long): DeleteTodoInteraction.DeleteTodoOutcome { + val request = DeleteTodo.Request(id) + val response = runBlocking { client.deleteTodo(request) } + return when (response) { + is DeleteTodo.Response204 -> DeleteTodoInteraction.DeleteTodoResponse204 + } + } + } + """.trimIndent() + + assertEquals(expected.trim(), result.trim()) + } +} diff --git a/docs/superpowers/plans/2026-03-27-wirespec-baker-integration.md b/docs/superpowers/plans/2026-03-27-wirespec-baker-integration.md new file mode 100644 index 000000000..e207aa696 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-wirespec-baker-integration.md @@ -0,0 +1,1253 @@ +# Wirespec-Baker Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `baker-wirespec` Maven module containing custom wirespec emitters that generate Baker Interaction interfaces and implementations from wirespec endpoint definitions. + +**Architecture:** Two custom emitters (`BakerKotlinEmitter`, `BakerJavaEmitter`) extend wirespec's `LanguageEmitter`. They process only `Endpoint` AST nodes, generating Baker `Interaction` interfaces with sealed outcome events (one per HTTP status code) and implementation classes that bridge to wirespec's generated endpoint client code. All other AST node types return no-op output. + +**Tech Stack:** Java 21, Kotlin 2.2.20, wirespec compiler core (`community.flock.wirespec.compiler:core-jvm:0.17.20`), Baker recipe DSL, JUnit 5 for testing. + +--- + +## File Structure + +``` +baker-wirespec/ +├── pom.xml +├── src/main/java/com/ing/baker/recipe/wirespec/ +│ ├── BakerKotlinEmitter.java +│ └── BakerJavaEmitter.java +└── src/test/java/com/ing/baker/recipe/wirespec/ + ├── BakerKotlinEmitterTest.java + └── BakerJavaEmitterTest.java +``` + +| File | Responsibility | +|------|---------------| +| `pom.xml` | Module build config: depends on wirespec compiler core | +| `BakerKotlinEmitter.java` | Extends `LanguageEmitter`, emits `.kt` files with Kotlin interaction interfaces + implementations | +| `BakerJavaEmitter.java` | Extends `LanguageEmitter`, emits `.java` files with Java interaction interfaces + implementations using `@FiresEvent` | +| `BakerKotlinEmitterTest.java` | Tests Kotlin emitter output against expected generated code | +| `BakerJavaEmitterTest.java` | Tests Java emitter output against expected generated code | + +--- + +### Task 1: Create the Maven module + +**Files:** +- Create: `baker-wirespec/pom.xml` +- Modify: `pom.xml` (root — add module entry) + +- [ ] **Step 1: Create `baker-wirespec/pom.xml`** + +```xml + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-wirespec + Baker Wirespec + Wirespec emitters that generate Baker Interaction interfaces from API endpoint definitions + + + 0.17.20 + + + + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + +``` + +- [ ] **Step 2: Add the module to the root `pom.xml`** + +In `pom.xml` (root), add `baker-wirespec` to the `` section, after the existing modules. + +- [ ] **Step 3: Verify the module builds** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec compile -q` +Expected: BUILD SUCCESS (empty module compiles) + +- [ ] **Step 4: Commit** + +```bash +git add baker-wirespec/pom.xml pom.xml +git commit -m "feat: add baker-wirespec module skeleton" +``` + +--- + +### Task 2: Implement `BakerKotlinEmitter` — scaffold with no-op methods + +**Files:** +- Create: `baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java` + +- [ ] **Step 1: Create `BakerKotlinEmitter.java` with all required overrides returning no-op** + +This establishes the emitter contract. All methods return empty/no-op. Endpoint emission will be implemented in the next task. + +```java +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class BakerKotlinEmitter extends LanguageEmitter { + + @NotNull + @Override + public String getSingleLineComment() { + return "//"; + } + + @NotNull + @Override + public FileExtension getExtension() { + return FileExtension.Kotlin; + } + + @Nullable + @Override + public Shared getShared() { + return null; + } + + @NotNull + @Override + public String notYetImplemented() { + return ""; + } + + @NotNull + @Override + public String emit(@NotNull Identifier identifier) { + return identifier.getValue(); + } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + return ""; // implemented in Task 3 + } + + @NotNull + @Override + public String emit(@NotNull Type type, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Type.Shape shape) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Field field) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Reference reference) { + return emitReference(reference); + } + + @NotNull + @Override + public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Enum anEnum, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Union union) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emitValidator(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Channel channel) { + return notYetImplemented(); + } + + /** + * Maps wirespec Reference types to Kotlin type names. + */ + private String emitReference(Reference reference) { + String typeName; + if (reference instanceof Reference.Primitive primitive) { + typeName = switch (primitive.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { + case P32 -> "Int"; + case P64 -> "Long"; + }; + case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { + case P32 -> "Float"; + case P64 -> "Double"; + }; + case Reference.Primitive.Type.Boolean b -> "Boolean"; + case Reference.Primitive.Type.Bytes b -> "ByteArray"; + default -> "Any"; + }; + } else if (reference instanceof Reference.Custom custom) { + typeName = custom.getValue(); + } else if (reference instanceof Reference.Iterable iterable) { + typeName = "List<" + emitReference(iterable.getReference()) + ">"; + } else if (reference instanceof Reference.Dict dict) { + typeName = "Map"; + } else if (reference instanceof Reference.Unit u) { + typeName = "Unit"; + } else { + typeName = "Any"; + } + return reference.isNullable() ? typeName + "?" : typeName; + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java +git commit -m "feat: add BakerKotlinEmitter scaffold with type mapping" +``` + +--- + +### Task 3: Implement `BakerKotlinEmitter.emit(Endpoint)` — Kotlin interaction generation + +**Files:** +- Modify: `baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java` +- Create: `baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.java` + +- [ ] **Step 1: Write the test** + +```java +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.parse.ast.*; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BakerKotlinEmitterTest { + + private final BakerKotlinEmitter emitter = new BakerKotlinEmitter(); + + @Test + void emitSimpleGetEndpoint() { + // GET /todos/{id: Integer} -> { 200 -> TodoDto, 404 -> ErrorDto } + Endpoint endpoint = new Endpoint( + null, // comment + List.of(), // annotations + new DefinitionIdentifier("GetTodo"), + Endpoint.Method.GET, + List.of( + new Endpoint.Segment.Literal("/todos/"), + new Endpoint.Segment.Param( + new FieldIdentifier("id"), + new Reference.Primitive( + new Reference.Primitive.Type.Integer(Reference.Primitive.Type.Precision.P64, null), + false + ) + ) + ), + List.of(), // queries + List.of(), // headers + List.of(new Endpoint.Request(null)), // requests (no body) + List.of( + new Endpoint.Response( + "200", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("TodoDto", false)), + List.of() + ), + new Endpoint.Response( + "404", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("ErrorDto", false)), + List.of() + ) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction + + interface GetTodoInteraction : Interaction { + sealed interface GetTodoOutcome + data class GetTodoResponse200(val body: TodoDto) : GetTodoOutcome + data class GetTodoResponse404(val body: ErrorDto) : GetTodoOutcome + + fun apply(id: Long): GetTodoOutcome + } + + class GetTodoInteractionImpl( + private val client: GetTodo.Handler + ) : GetTodoInteraction { + override fun apply(id: Long): GetTodoInteraction.GetTodoOutcome { + val request = GetTodo.Request(id) + val response = client.getTodo(request) + return when (response) { + is GetTodo.Response200 -> GetTodoInteraction.GetTodoResponse200(response.body) + is GetTodo.Response404 -> GetTodoInteraction.GetTodoResponse404(response.body) + } + } + } + """; + + assertEquals(expected.strip(), result.strip()); + } + + @Test + void emitPostEndpointWithBody() { + // POST /todos CreateTodoRequest -> { 201 -> TodoDto, 400 -> ErrorDto } + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("CreateTodo"), + Endpoint.Method.POST, + List.of(new Endpoint.Segment.Literal("/todos")), + List.of(), // queries + List.of(), // headers + List.of(new Endpoint.Request( + new Endpoint.Content("application/json", new Reference.Custom("CreateTodoRequest", false)) + )), + List.of( + new Endpoint.Response( + "201", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("TodoDto", false)), + List.of() + ), + new Endpoint.Response( + "400", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("ErrorDto", false)), + List.of() + ) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction + + interface CreateTodoInteraction : Interaction { + sealed interface CreateTodoOutcome + data class CreateTodoResponse201(val body: TodoDto) : CreateTodoOutcome + data class CreateTodoResponse400(val body: ErrorDto) : CreateTodoOutcome + + fun apply(body: CreateTodoRequest): CreateTodoOutcome + } + + class CreateTodoInteractionImpl( + private val client: CreateTodo.Handler + ) : CreateTodoInteraction { + override fun apply(body: CreateTodoRequest): CreateTodoInteraction.CreateTodoOutcome { + val request = CreateTodo.Request(body) + val response = client.createTodo(request) + return when (response) { + is CreateTodo.Response201 -> CreateTodoInteraction.CreateTodoResponse201(response.body) + is CreateTodo.Response400 -> CreateTodoInteraction.CreateTodoResponse400(response.body) + } + } + } + """; + + assertEquals(expected.strip(), result.strip()); + } + + @Test + void emitEndpointWithQueryParams() { + // GET /todos ? search: String, limit: Integer -> { 200 -> TodoDto[] } + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("ListTodos"), + Endpoint.Method.GET, + List.of(new Endpoint.Segment.Literal("/todos")), + List.of( + new Field( + List.of(), + new FieldIdentifier("search"), + new Reference.Primitive(new Reference.Primitive.Type.String(null), false) + ), + new Field( + List.of(), + new FieldIdentifier("limit"), + new Reference.Primitive( + new Reference.Primitive.Type.Integer(Reference.Primitive.Type.Precision.P32, null), + false + ) + ) + ), + List.of(), // headers + List.of(new Endpoint.Request(null)), + List.of( + new Endpoint.Response( + "200", + List.of(), + new Endpoint.Content("application/json", + new Reference.Iterable(new Reference.Custom("TodoDto", false), false)), + List.of() + ) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction + + interface ListTodosInteraction : Interaction { + sealed interface ListTodosOutcome + data class ListTodosResponse200(val body: List) : ListTodosOutcome + + fun apply(search: String, limit: Int): ListTodosOutcome + } + + class ListTodosInteractionImpl( + private val client: ListTodos.Handler + ) : ListTodosInteraction { + override fun apply(search: String, limit: Int): ListTodosInteraction.ListTodosOutcome { + val request = ListTodos.Request(search, limit) + val response = client.listTodos(request) + return when (response) { + is ListTodos.Response200 -> ListTodosInteraction.ListTodosResponse200(response.body) + } + } + } + """; + + assertEquals(expected.strip(), result.strip()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec test -q` +Expected: FAIL — `emit(Endpoint)` returns empty string + +- [ ] **Step 3: Implement `emit(Endpoint)` in `BakerKotlinEmitter`** + +Replace the `emit(Endpoint)` method in `BakerKotlinEmitter.java` with: + +```java +@NotNull +@Override +public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + String interactionName = name + "Interaction"; + String outcomeName = name + "Outcome"; + + StringBuilder sb = new StringBuilder(); + + // Import + sb.append("import com.ing.baker.recipe.javadsl.Interaction\n\n"); + + // Interface + sb.append("interface ").append(interactionName).append(" : Interaction {\n"); + sb.append(" sealed interface ").append(outcomeName).append("\n"); + + // Response events — one per status code + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + if (response.getContent() != null) { + String bodyType = emitReference(response.getContent().getReference()); + sb.append(" data class ").append(eventName) + .append("(val body: ").append(bodyType).append(") : ") + .append(outcomeName).append("\n"); + } else { + sb.append(" data object ").append(eventName).append(" : ") + .append(outcomeName).append("\n"); + } + } + + sb.append("\n"); + + // apply() method — collect all input params + List params = collectParams(endpoint); + String paramList = params.stream() + .map(p -> p[0] + ": " + p[1]) + .collect(java.util.stream.Collectors.joining(", ")); + sb.append(" fun apply(").append(paramList).append("): ").append(outcomeName).append("\n"); + sb.append("}\n\n"); + + // Implementation class + String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); + sb.append("class ").append(interactionName).append("Impl(\n"); + sb.append(" private val client: ").append(name).append(".Handler\n"); + sb.append(") : ").append(interactionName).append(" {\n"); + + String argList = params.stream() + .map(p -> p[0]) + .collect(java.util.stream.Collectors.joining(", ")); + + sb.append(" override fun apply(").append(paramList).append("): ") + .append(interactionName).append(".").append(outcomeName).append(" {\n"); + sb.append(" val request = ").append(name).append(".Request(").append(argList).append(")\n"); + sb.append(" val response = client.").append(handlerMethod).append("(request)\n"); + sb.append(" return when (response) {\n"); + + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + sb.append(" is ").append(name).append(".Response").append(response.getStatus()) + .append(" -> ").append(interactionName).append(".").append(eventName); + if (response.getContent() != null) { + sb.append("(response.body)"); + } + sb.append("\n"); + } + + sb.append(" }\n"); + sb.append(" }\n"); + sb.append("}"); + + return sb.toString(); +} + +/** + * Collects all input parameters from path segments, query params, headers, and request body. + * Returns list of [name, type] pairs. + */ +private java.util.List collectParams(Endpoint endpoint) { + java.util.List params = new java.util.ArrayList<>(); + + // Path parameters + for (Endpoint.Segment segment : endpoint.getPath()) { + if (segment instanceof Endpoint.Segment.Param param) { + params.add(new String[]{ + param.getIdentifier().getValue(), + emitReference(param.getReference()) + }); + } + } + + // Query parameters + for (Field query : endpoint.getQueries()) { + params.add(new String[]{ + query.getIdentifier().getValue(), + emitReference(query.getReference()) + }); + } + + // Header parameters + for (Field header : endpoint.getHeaders()) { + params.add(new String[]{ + header.getIdentifier().getValue(), + emitReference(header.getReference()) + }); + } + + // Request body + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null) { + params.add(new String[]{ + "body", + emitReference(request.getContent().getReference()) + }); + } + } + + return params; +} +``` + +Also add these imports at the top of the file: + +```java +import java.util.List; +import java.util.stream.Collectors; +``` + +And change the `emitReference` method from `private` to package-private (remove `private` modifier) so it can be reused. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec test -q` +Expected: PASS — all 3 tests green + +- [ ] **Step 5: Commit** + +```bash +git add baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitter.java +git add baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.java +git commit -m "feat: implement BakerKotlinEmitter endpoint-to-interaction generation" +``` + +--- + +### Task 4: Implement `BakerJavaEmitter` — scaffold with no-op methods + +**Files:** +- Create: `baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java` + +- [ ] **Step 1: Create `BakerJavaEmitter.java`** + +Same structure as `BakerKotlinEmitter` but targeting Java output. All methods return no-op except `emit(Identifier)` and `emit(Reference)`. + +```java +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BakerJavaEmitter extends LanguageEmitter { + + @NotNull + @Override + public String getSingleLineComment() { + return "//"; + } + + @NotNull + @Override + public FileExtension getExtension() { + return FileExtension.Java; + } + + @Nullable + @Override + public Shared getShared() { + return null; + } + + @NotNull + @Override + public String notYetImplemented() { + return ""; + } + + @NotNull + @Override + public String emit(@NotNull Identifier identifier) { + return identifier.getValue(); + } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + return ""; // implemented in Task 5 + } + + @NotNull + @Override + public String emit(@NotNull Type type, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Type.Shape shape) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Field field) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Reference reference) { + return emitReference(reference); + } + + @NotNull + @Override + public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Enum anEnum, @NotNull Module module) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Union union) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emitValidator(@NotNull Refined refined) { + return notYetImplemented(); + } + + @NotNull + @Override + public String emit(@NotNull Channel channel) { + return notYetImplemented(); + } + + /** + * Maps wirespec Reference types to Java type names. + */ + String emitReference(Reference reference) { + String typeName; + if (reference instanceof Reference.Primitive primitive) { + typeName = switch (primitive.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { + case P32 -> "Integer"; + case P64 -> "Long"; + }; + case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { + case P32 -> "Float"; + case P64 -> "Double"; + }; + case Reference.Primitive.Type.Boolean b -> "Boolean"; + case Reference.Primitive.Type.Bytes b -> "byte[]"; + default -> "Object"; + }; + } else if (reference instanceof Reference.Custom custom) { + typeName = custom.getValue(); + } else if (reference instanceof Reference.Iterable iterable) { + typeName = "java.util.List<" + emitReference(iterable.getReference()) + ">"; + } else if (reference instanceof Reference.Dict dict) { + typeName = "java.util.Map"; + } else if (reference instanceof Reference.Unit u) { + typeName = "Void"; + } else { + typeName = "Object"; + } + return typeName; + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java +git commit -m "feat: add BakerJavaEmitter scaffold with type mapping" +``` + +--- + +### Task 5: Implement `BakerJavaEmitter.emit(Endpoint)` — Java interaction generation + +**Files:** +- Modify: `baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java` +- Create: `baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.java` + +- [ ] **Step 1: Write the test** + +```java +package com.ing.baker.recipe.wirespec; + +import community.flock.wirespec.compiler.core.parse.ast.*; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BakerJavaEmitterTest { + + private final BakerJavaEmitter emitter = new BakerJavaEmitter(); + + @Test + void emitSimpleGetEndpoint() { + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("GetTodo"), + Endpoint.Method.GET, + List.of( + new Endpoint.Segment.Literal("/todos/"), + new Endpoint.Segment.Param( + new FieldIdentifier("id"), + new Reference.Primitive( + new Reference.Primitive.Type.Integer(Reference.Primitive.Type.Precision.P64, null), + false + ) + ) + ), + List.of(), + List.of(), + List.of(new Endpoint.Request(null)), + List.of( + new Endpoint.Response( + "200", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("TodoDto", false)), + List.of() + ), + new Endpoint.Response( + "404", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("ErrorDto", false)), + List.of() + ) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface GetTodoInteraction extends Interaction { + interface GetTodoOutcome {} + class GetTodoResponse200 implements GetTodoOutcome { + public final TodoDto body; + public GetTodoResponse200(TodoDto body) { this.body = body; } + } + class GetTodoResponse404 implements GetTodoOutcome { + public final ErrorDto body; + public GetTodoResponse404(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {GetTodoResponse200.class, GetTodoResponse404.class}) + GetTodoOutcome apply(Long id); + } + """; + + assertEquals(expected.strip(), result.strip()); + } + + @Test + void emitPostEndpointWithBody() { + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("CreateTodo"), + Endpoint.Method.POST, + List.of(new Endpoint.Segment.Literal("/todos")), + List.of(), + List.of(), + List.of(new Endpoint.Request( + new Endpoint.Content("application/json", new Reference.Custom("CreateTodoRequest", false)) + )), + List.of( + new Endpoint.Response( + "201", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("TodoDto", false)), + List.of() + ), + new Endpoint.Response( + "400", + List.of(), + new Endpoint.Content("application/json", new Reference.Custom("ErrorDto", false)), + List.of() + ) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface CreateTodoInteraction extends Interaction { + interface CreateTodoOutcome {} + class CreateTodoResponse201 implements CreateTodoOutcome { + public final TodoDto body; + public CreateTodoResponse201(TodoDto body) { this.body = body; } + } + class CreateTodoResponse400 implements CreateTodoOutcome { + public final ErrorDto body; + public CreateTodoResponse400(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {CreateTodoResponse201.class, CreateTodoResponse400.class}) + CreateTodoOutcome apply(CreateTodoRequest body); + } + """; + + assertEquals(expected.strip(), result.strip()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec test -q` +Expected: FAIL — Java emitter tests fail (Kotlin tests still pass) + +- [ ] **Step 3: Implement `emit(Endpoint)` in `BakerJavaEmitter`** + +Replace the `emit(Endpoint)` method in `BakerJavaEmitter.java` with: + +```java +@NotNull +@Override +public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + String interactionName = name + "Interaction"; + String outcomeName = name + "Outcome"; + + StringBuilder sb = new StringBuilder(); + + // Imports + sb.append("import com.ing.baker.recipe.javadsl.Interaction;\n"); + sb.append("import com.ing.baker.recipe.annotations.FiresEvent;\n\n"); + + // Interface + sb.append("public interface ").append(interactionName).append(" extends Interaction {\n"); + sb.append(" interface ").append(outcomeName).append(" {}\n"); + + // Response event classes — one per status code + for (Endpoint.Response response : endpoint.getResponses()) { + String eventName = name + "Response" + response.getStatus(); + if (response.getContent() != null) { + String bodyType = emitReference(response.getContent().getReference()); + sb.append(" class ").append(eventName).append(" implements ").append(outcomeName).append(" {\n"); + sb.append(" public final ").append(bodyType).append(" body;\n"); + sb.append(" public ").append(eventName).append("(").append(bodyType).append(" body) { this.body = body; }\n"); + sb.append(" }\n"); + } else { + sb.append(" class ").append(eventName).append(" implements ").append(outcomeName).append(" {}\n"); + } + } + + sb.append("\n"); + + // @FiresEvent annotation + String eventClasses = endpoint.getResponses().stream() + .map(r -> name + "Response" + r.getStatus() + ".class") + .collect(Collectors.joining(", ")); + sb.append(" @FiresEvent(oneOf = {").append(eventClasses).append("})\n"); + + // apply() method + List params = collectParams(endpoint); + String paramList = params.stream() + .map(p -> p[1] + " " + p[0]) + .collect(Collectors.joining(", ")); + sb.append(" ").append(outcomeName).append(" apply(").append(paramList).append(");\n"); + sb.append("}"); + + return sb.toString(); +} + +/** + * Collects all input parameters from path segments, query params, headers, and request body. + * Returns list of [name, type] pairs. + */ +private List collectParams(Endpoint endpoint) { + List params = new ArrayList<>(); + + // Path parameters + for (Endpoint.Segment segment : endpoint.getPath()) { + if (segment instanceof Endpoint.Segment.Param param) { + params.add(new String[]{ + param.getIdentifier().getValue(), + emitReference(param.getReference()) + }); + } + } + + // Query parameters + for (Field query : endpoint.getQueries()) { + params.add(new String[]{ + query.getIdentifier().getValue(), + emitReference(query.getReference()) + }); + } + + // Header parameters + for (Field header : endpoint.getHeaders()) { + params.add(new String[]{ + header.getIdentifier().getValue(), + emitReference(header.getReference()) + }); + } + + // Request body + for (Endpoint.Request request : endpoint.getRequests()) { + if (request.getContent() != null) { + params.add(new String[]{ + "body", + emitReference(request.getContent().getReference()) + }); + } + } + + return params; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec test -q` +Expected: PASS — all tests green (both Kotlin and Java emitter tests) + +- [ ] **Step 5: Commit** + +```bash +git add baker-wirespec/src/main/java/com/ing/baker/recipe/wirespec/BakerJavaEmitter.java +git add baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.java +git commit -m "feat: implement BakerJavaEmitter endpoint-to-interaction generation" +``` + +--- + +### Task 6: Edge case — endpoint with no response body + +**Files:** +- Modify: `baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.java` +- Modify: `baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.java` + +- [ ] **Step 1: Add Kotlin test for no-body response** + +Add to `BakerKotlinEmitterTest.java`: + +```java +@Test +void emitEndpointWithNoBodyResponse() { + // DELETE /todos/{id} -> { 204 -> Unit } + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("DeleteTodo"), + Endpoint.Method.DELETE, + List.of( + new Endpoint.Segment.Literal("/todos/"), + new Endpoint.Segment.Param( + new FieldIdentifier("id"), + new Reference.Primitive( + new Reference.Primitive.Type.Integer(Reference.Primitive.Type.Precision.P64, null), + false + ) + ) + ), + List.of(), + List.of(), + List.of(new Endpoint.Request(null)), + List.of( + new Endpoint.Response("204", List.of(), null, List.of()) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction + + interface DeleteTodoInteraction : Interaction { + sealed interface DeleteTodoOutcome + data object DeleteTodoResponse204 : DeleteTodoOutcome + + fun apply(id: Long): DeleteTodoOutcome + } + + class DeleteTodoInteractionImpl( + private val client: DeleteTodo.Handler + ) : DeleteTodoInteraction { + override fun apply(id: Long): DeleteTodoInteraction.DeleteTodoOutcome { + val request = DeleteTodo.Request(id) + val response = client.deleteTodo(request) + return when (response) { + is DeleteTodo.Response204 -> DeleteTodoInteraction.DeleteTodoResponse204 + } + } + } + """; + + assertEquals(expected.strip(), result.strip()); +} +``` + +- [ ] **Step 2: Add Java test for no-body response** + +Add to `BakerJavaEmitterTest.java`: + +```java +@Test +void emitEndpointWithNoBodyResponse() { + Endpoint endpoint = new Endpoint( + null, + List.of(), + new DefinitionIdentifier("DeleteTodo"), + Endpoint.Method.DELETE, + List.of( + new Endpoint.Segment.Literal("/todos/"), + new Endpoint.Segment.Param( + new FieldIdentifier("id"), + new Reference.Primitive( + new Reference.Primitive.Type.Integer(Reference.Primitive.Type.Precision.P64, null), + false + ) + ) + ), + List.of(), + List.of(), + List.of(new Endpoint.Request(null)), + List.of( + new Endpoint.Response("204", List.of(), null, List.of()) + ) + ); + + String result = emitter.emit(endpoint); + + String expected = """ + import com.ing.baker.recipe.javadsl.Interaction; + import com.ing.baker.recipe.annotations.FiresEvent; + + public interface DeleteTodoInteraction extends Interaction { + interface DeleteTodoOutcome {} + class DeleteTodoResponse204 implements DeleteTodoOutcome {} + + @FiresEvent(oneOf = {DeleteTodoResponse204.class}) + DeleteTodoOutcome apply(Long id); + } + """; + + assertEquals(expected.strip(), result.strip()); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec test -q` +Expected: PASS — if already handled by the null content check in Task 3/5. If not, fix the emitter logic and re-run. + +- [ ] **Step 4: Commit** + +```bash +git add baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerKotlinEmitterTest.java +git add baker-wirespec/src/test/java/com/ing/baker/recipe/wirespec/BakerJavaEmitterTest.java +git commit -m "test: add edge case tests for endpoints with no response body" +``` + +--- + +### Task 7: Final build verification + +**Files:** None — verification only. + +- [ ] **Step 1: Run full module build** + +Run: `cd /Users/wilmveel/Projects/baker && mvn -pl baker-wirespec clean verify -q` +Expected: BUILD SUCCESS with all tests passing + +- [ ] **Step 2: Verify the emitter classes are in the JAR** + +Run: `jar tf baker-wirespec/target/baker-wirespec-5.1.0-SNAPSHOT.jar | grep -i baker` +Expected: Should list `com/ing/baker/recipe/wirespec/BakerKotlinEmitter.class` and `com/ing/baker/recipe/wirespec/BakerJavaEmitter.class` + +- [ ] **Step 3: Commit any final fixes if needed** + +Only if prior steps revealed issues. diff --git a/docs/superpowers/plans/2026-05-22-baker-openapi-plugin.md b/docs/superpowers/plans/2026-05-22-baker-openapi-plugin.md new file mode 100644 index 000000000..d2b7d8f95 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-baker-openapi-plugin.md @@ -0,0 +1,2124 @@ +# Baker OpenAPI Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `baker-openapi/` aggregator with three modules — `baker-openapi-dsl` (runtime), `baker-openapi-emitter` (codegen), `baker-openapi-plugin` (Maven plugin) — so users turn OpenAPI operations into recipe interactions via `api(Operation) { on(N) fires { ... } }`, without referencing wirespec-generated response classes in the recipe. + +**Architecture:** The plugin parses OpenAPI → wirespec AST, runs wirespec's standard Kotlin emitter for models + endpoint classes, and runs a custom emitter that writes one `object` per operation implementing `ApiOperation`. At runtime, the `api(Operation) { ... }` DSL constructs a generic `ApiOperationInteraction` (implements `InteractionInstance`) bound to the descriptor plus user response mappers, and registers a matching `Interaction` in the recipe via the existing kotlin-DSL machinery. + +**Tech Stack:** Kotlin 1.9+ (DSL), Java 17 (emitter, plugin), Maven, JUnit 5, wirespec 0.17.20 (`wirespec-compiler-core-jvm`, `wirespec-openapi-jvm`), Baker 5.1.0-SNAPSHOT (`baker-recipe-dsl-kotlin`, `baker-interface-kotlin`). + +**Spec:** `docs/superpowers/specs/2026-05-22-baker-openapi-plugin-design.md` + +--- + +## File Structure + +Every new file is created under `baker-openapi/` at repo root. + +``` +baker-openapi/ +├── pom.xml # Task 1 — aggregator +│ +├── baker-openapi-dsl/ +│ ├── pom.xml # Task 2 +│ └── src/ +│ ├── main/kotlin/com/ing/baker/openapi/dsl/ +│ │ ├── ApiOperation.kt # Task 3 — interfaces + InputField +│ │ ├── ApiOperationInteraction.kt # Task 4 — generic InteractionInstance +│ │ ├── ApiDsl.kt # Task 5 — RecipeBuilder.api + scope +│ │ └── ApiOperationBinding.kt # Task 6 — runtime factory +│ └── test/kotlin/com/ing/baker/openapi/dsl/ +│ ├── ApiOperationInteractionTest.kt # Task 4 +│ ├── ApiDslTest.kt # Task 5 +│ └── ApiOperationBindingTest.kt # Task 6 +│ +├── baker-openapi-emitter/ +│ ├── pom.xml # Task 7 +│ └── src/ +│ ├── main/java/com/ing/baker/openapi/emitter/ +│ │ └── BakerOpenApiEmitter.java # Task 8 +│ └── test/kotlin/com/ing/baker/openapi/emitter/ +│ └── BakerOpenApiEmitterTest.kt # Task 8 +│ +├── baker-openapi-plugin/ +│ ├── pom.xml # Task 9 +│ └── src/ +│ ├── main/java/com/ing/baker/openapi/plugin/ +│ │ └── GenerateFromOpenApiMojo.java # Task 10 +│ └── it/ +│ ├── settings.xml # Task 10 +│ └── happy-path/ +│ ├── pom.xml # Task 10 +│ ├── src/main/openapi/petstore.json # Task 10 +│ └── verify.groovy # Task 10 +│ +└── (root pom modified — Task 1) +``` + +End-to-end example (`examples/baker-openapi-example/`) is built in Task 11 — module skeleton, OpenAPI fixture, recipe, WireMock test. + +A small upstream change to `baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt` is made in Task 5 to expose an `addInteraction(Interaction)` method we can call from a non-inline extension. + +--- + +## Task 1: Aggregator pom + root registration + +**Files:** +- Create: `baker-openapi/pom.xml` +- Modify: `pom.xml` (root, lines around 129 where `core/baker-wirespec` is listed) + +- [ ] **Step 1: Create the aggregator pom** + +Write `baker-openapi/pom.xml`: + +```xml + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi + Baker OpenAPI (aggregator) + pom + + + 0.17.20 + + + + baker-openapi-dsl + baker-openapi-emitter + baker-openapi-plugin + + +``` + +- [ ] **Step 2: Register the aggregator in the root pom** + +Open root `pom.xml`, find the `core/baker-wirespec` line (~line 129) and add the new aggregator immediately after it: + +```xml + core/baker-wirespec + baker-openapi +``` + +- [ ] **Step 3: Verify the aggregator resolves** + +Run: `mvn -pl baker-openapi -N help:effective-pom -q | tail -20` +Expected: prints the effective pom for `baker-openapi` without errors. Three child modules listed but not yet present, so a follow-up build will fail — that's expected; we add them next. + +- [ ] **Step 4: Commit** + +```bash +git add baker-openapi/pom.xml pom.xml +git commit -m "feat: add baker-openapi aggregator module" +``` + +--- + +## Task 2: baker-openapi-dsl module skeleton + +**Files:** +- Create: `baker-openapi/baker-openapi-dsl/pom.xml` +- Create: `baker-openapi/baker-openapi-dsl/src/main/kotlin/.gitkeep` +- Create: `baker-openapi/baker-openapi-dsl/src/test/kotlin/.gitkeep` + +- [ ] **Step 1: Create the dsl pom** + +Write `baker-openapi/baker-openapi-dsl/pom.xml`: + +```xml + + + 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.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 + + + + + +``` + +- [ ] **Step 2: Create empty source dirs** + +```bash +mkdir -p baker-openapi/baker-openapi-dsl/src/main/kotlin +mkdir -p baker-openapi/baker-openapi-dsl/src/test/kotlin +touch baker-openapi/baker-openapi-dsl/src/main/kotlin/.gitkeep +touch baker-openapi/baker-openapi-dsl/src/test/kotlin/.gitkeep +``` + +- [ ] **Step 3: Verify build** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl -am compile -q` +Expected: `BUILD SUCCESS` — no Kotlin sources yet, but the module compiles. + +- [ ] **Step 4: Commit** + +```bash +git add baker-openapi/baker-openapi-dsl +git commit -m "feat: add baker-openapi-dsl module skeleton" +``` + +--- + +## Task 3: Define `ApiOperation` and `InputField` + +These are the contract types the generated descriptor objects implement. No tests in this task — they're pure interfaces validated by usage in later tasks. + +**Files:** +- Create: `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt` + +- [ ] **Step 1: Write the interface file** + +Write `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt`: + +```kotlin +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 three callbacks the runtime + * uses to build requests and invoke the wirespec handler. + */ +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> + + /** Builds the wirespec Request from a name → value ingredient map. */ + fun buildRequest(ingredients: Map): 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<*> + + /** The wirespec handler class this operation expects. The plugin generates this. */ + val handlerClass: KClass +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl -am compile -q` +Expected: `BUILD SUCCESS`. + +- [ ] **Step 3: Commit** + +```bash +git add baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt +git commit -m "feat: add ApiOperation and InputField contract types" +``` + +--- + +## Task 4: `ApiOperationInteraction` — the generic `InteractionInstance` + +**Files:** +- Create: `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt` +- Create: `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Write `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt`: + +```kotlin +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.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +// A minimal fake handler so the test isn't coupled to a generated endpoint class. +private class FakeHandler : Wirespec.Handler + +private class FakeResponse(override val status: Int, val body: Map) : Wirespec.Response> { + override val headers: Map> = emptyMap() + override fun getBody(): Map = body +} + +private data class UserCreated(val id: String, val email: String) +private data class UserCreationFailed(val reason: String) + +private class FakeOperation( + private val nextResponse: Wirespec.Response<*>, +) : ApiOperation { + override val operationName: String = "CreateUser" + override val inputFields = listOf( + InputField("firstName", String::class), + InputField("email", String::class), + ) + override val responseTypes: Map> = mapOf( + 201 to FakeResponse::class, + 400 to FakeResponse::class, + ) + override val handlerClass: KClass = FakeHandler::class + var capturedRequest: Map? = null + override fun buildRequest(ingredients: Map): Any { + capturedRequest = ingredients + return ingredients + } + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = nextResponse +} + +class ApiOperationInteractionTest { + + @Test + fun `name returns operationName`() { + val op = FakeOperation(FakeResponse(201, emptyMap())) + val interaction = ApiOperationInteraction(op, FakeHandler(), emptyMap()) + assertEquals("CreateUser", interaction.name()) + } + + @Test + fun `input returns one InteractionInstanceInput per input field`() { + val op = FakeOperation(FakeResponse(201, emptyMap())) + val interaction = ApiOperationInteraction(op, FakeHandler(), emptyMap()) + val inputs = interaction.input() + assertEquals(2, inputs.size) + assertEquals("firstName", inputs[0].name.orElseThrow()) + assertEquals("email", inputs[1].name.orElseThrow()) + } + + @Test + fun `execute routes 201 through the configured mapper and returns the fired event`() { + val op = FakeOperation(FakeResponse(201, emptyMap())) + val mapper: (Wirespec.Response<*>) -> Any = { resp -> + val body = (resp as FakeResponse).body + UserCreated(id = body["id"] as String, email = body["email"] as String) + } + // Use a response that carries the data the mapper expects. + val op2 = FakeOperation(FakeResponse(201, mapOf("id" to "u1", "email" to "a@b"))) + val interaction = ApiOperationInteraction(op2, FakeHandler(), mapOf(201 to mapper)) + + val event = interaction.execute( + mutableListOf( + IngredientInstance("firstName", PrimitiveValue("John")), + IngredientInstance("email", PrimitiveValue("a@b")), + ), + scala.collection.immutable.Map.empty() + ).get() + + assertTrue(event.isPresent) + assertEquals("UserCreated", event.get().name) + // buildRequest received the ingredients + assertEquals(mapOf("firstName" to "John", "email" to "a@b"), op2.capturedRequest) + } + + @Test + fun `execute throws on unmapped status`() { + val op = FakeOperation(FakeResponse(500, emptyMap())) + val interaction = ApiOperationInteraction(op, FakeHandler(), mapOf(201 to { _ -> UserCreated("x", "y") })) + + val ex = assertThrows { + interaction.execute( + mutableListOf( + IngredientInstance("firstName", PrimitiveValue("John")), + IngredientInstance("email", PrimitiveValue("a@b")), + ), + scala.collection.immutable.Map.empty() + ).get() + } + assertNotNull(ex.cause) + assertTrue(ex.cause!!.message!!.contains("500")) + assertTrue(ex.cause!!.message!!.contains("CreateUser")) + } +} +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl test -q` +Expected: compilation error — `ApiOperationInteraction` does not exist. + +- [ ] **Step 3: Implement `ApiOperationInteraction`** + +Write `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt`: + +```kotlin +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 + +typealias ResponseMapper = (Wirespec.Response<*>) -> Any + +class ApiOperationInteraction( + private val operation: ApiOperation, + private val handler: Wirespec.Handler, + private val mappers: kotlin.collections.Map, +) : InteractionInstance() { + + override fun name(): String = operation.operationName + + override fun input(): List = + operation.inputFields.map { field -> + InteractionInstanceInput( + Optional.of(field.name), + Converters.readJavaType(field.type.java), + ) + } + + 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 ingredientMap = input.associate { it.name to it.value.`as`(operation.inputFieldType(it.name)) } + val request = 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 ApiOperation.inputFieldType(name: String): java.lang.reflect.Type = + inputFields.first { it.name == name }.type.java +``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl test -q` +Expected: `Tests run: 4, Failures: 0, Errors: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt \ + baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt +git commit -m "feat: add ApiOperationInteraction routing by status code" +``` + +--- + +## Task 5: `api(...)` DSL — `RecipeBuilder` extension + `ApiInteractionScope` + +The DSL needs to add an `Interaction` directly to the `RecipeBuilder`'s interactions set. That field is `@PublishedApi internal`. We add a public `addInteraction(Interaction)` method in `baker-recipe-dsl-kotlin` to avoid forcing the api(...) function to be inline. + +**Files:** +- Modify: `core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt` (after line 99, the existing `interaction(...)` definition) +- Create: `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt` +- Create: `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Write `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt`: + +```kotlin +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 + +private data class SensoryEvent(val firstName: String, val email: String) +private data class UserCreated(val id: String, val email: String) +private data class UserCreationFailed(val reason: String) + +private class FakeHandler : Wirespec.Handler +private class FakeResponse(override val status: Int) : Wirespec.Response { + override val headers: Map> = emptyMap() + override fun getBody(): Unit = Unit +} + +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 FakeResponse::class, 400 to FakeResponse::class) + override val handlerClass = FakeHandler::class + override fun buildRequest(ingredients: Map): Any = ingredients + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = FakeResponse(201) +} + +@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) fires { UserCreated("u1", "a@b") } + on(400) fires { UserCreationFailed("nope") } + } + } + val compiled = RecipeCompiler.compileRecipe(r) + val interaction = compiled.interactionTransitions.single { it.interactionName == "CreateUser" } + val ingredientNames = 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) fires { UserCreated("u1", "a@b") } + on(400) fires { UserCreationFailed("nope") } + } + } + val compiled = RecipeCompiler.compileRecipe(r) + val outputs = compiled.allEvents.map { it.name }.toSet() + assertTrue(outputs.contains("UserCreated")) + assertTrue(outputs.contains("UserCreationFailed")) + } + + @Test + fun `requires registers required events`() { + val r = recipe("r") { + sensoryEvents { event() } + api(CreateUser) { + requires(UserCreated::class) + on(201) fires { UserCreated("u1", "a@b") } + } + } + val compiled = RecipeCompiler.compileRecipe(r) + val interaction = compiled.interactionTransitions.single { it.interactionName == "CreateUser" } + assertTrue(interaction.requiredEvents.contains("UserCreated")) + } +} +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl test -q` +Expected: compile error — `api` is unresolved. + +- [ ] **Step 3: Add `addInteraction` to `RecipeBuilder`** + +Open `core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt`. Find the existing `interaction` function at around line 97-99 and add this method right after it: + +```kotlin + /** + * Adds a pre-built [Interaction] directly. Intended for DSL extensions that + * construct their own [Interaction] (e.g. baker-openapi-dsl's `api(...)`). + */ + fun addInteraction(interaction: Interaction) { + interactions.add(interaction) + } +``` + +(The `Interaction` import is already present in this file as `com.ing.baker.recipe.kotlindsl.Interaction`.) + +- [ ] **Step 4: Update the tests to use the final DSL form** + +The tests in Step 1 are written against `on(201) fires { ... }`, an infix-style form that requires runtime mapper invocation to learn the produced event class. We instead expose a typed form that captures both classes via reified type parameters — simpler to implement and equivalent in expressiveness. + +Edit `ApiDslTest.kt` and replace **every** `on(NNN) fires { ... }` call with the typed form: + +```kotlin +on(201) { _ -> UserCreated("u1", "a@b") } +on(400) { _ -> UserCreationFailed("nope") } +``` + +- [ ] **Step 5: Implement `ApiDsl.kt`** + +Write `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt`: + +```kotlin +package com.ing.baker.openapi.dsl + +import com.ing.baker.recipe.common.Ingredient +import com.ing.baker.recipe.kotlindsl.Event +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()) +} + +@ApiDslMarker +class ApiInteractionScope internal constructor(private val operation: ApiOperation) { + + @PublishedApi + internal val mappers = mutableMapOf) -> Any>() + + @PublishedApi + internal val outputEventClasses = mutableSetOf>() + + private val requiredEvents = mutableSetOf() + private val ingredientNameOverridesMap = mutableMapOf() + 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 + } + + fun ingredientNameOverrides(block: MutableMap.() -> Unit) { + ingredientNameOverridesMap.apply(block) + } + + /** Read-only view of configured mappers, for binding at app startup. */ + val configuredMappers: Map) -> Any> get() = mappers.toMap() + + internal fun buildInteraction(): Interaction { + val inputIngredients: Set = 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, + ) + } +} + +private fun KClass<*>.toEvent(): Event { + val ctor = primaryConstructor + val ingredients = 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()) +} +``` + +- [ ] **Step 6: Run tests, confirm pass** + +Run: `mvn -pl core/baker-recipe-dsl-kotlin -am install -q` (rebuild upstream first because we modified `KotlinDsl.kt`), then `mvn -pl baker-openapi/baker-openapi-dsl test -q`. + +Expected: `Tests run: 3, Failures: 0`. + +- [ ] **Step 7: Commit** + +```bash +git add core/baker-recipe-dsl-kotlin/src/main/kotlin/com/ing/baker/recipe/kotlindsl/KotlinDsl.kt \ + baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt \ + baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt +git commit -m "feat: add api(operation) DSL with status-coded response mapping" +``` + +--- + +## Task 6: `ApiOperationBinding` — runtime factory + +A small helper that pairs an `ApiOperation` with a transport/serialization combo and produces an `ApiOperationInteraction` ready to register with Baker. + +**Files:** +- Create: `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt` +- Create: `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt` + +- [ ] **Step 1: Write the failing test** + +Write `baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt`: + +```kotlin +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 StubHandler : Wirespec.Handler +private class StubResponse(override val status: Int) : Wirespec.Response { + override val headers: Map> = emptyMap() + override fun getBody() = Unit +} + +private object StubOp : ApiOperation { + override val operationName = "Stub" + override val inputFields: List = emptyList() + override val responseTypes: Map> = mapOf(200 to StubResponse::class) + override val handlerClass = StubHandler::class + override fun buildRequest(ingredients: Map): Any = Unit + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = StubResponse(200) +} + +class ApiOperationBindingTest { + + @Test + fun `binding produces an ApiOperationInteraction with the operation name`() { + val handler = StubHandler() + val binding = ApiOperationBinding(StubOp, handler, mappers = mapOf(200 to { _ -> "ok" })) + val interaction = binding.toInteractionInstance() + assertEquals("Stub", interaction.name()) + } +} +``` + +- [ ] **Step 2: Run test, confirm fail** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl test -Dtest=ApiOperationBindingTest -q` +Expected: compile error — `ApiOperationBinding` does not exist. + +- [ ] **Step 3: Implement the binding** + +Write `baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt`: + +```kotlin +package com.ing.baker.openapi.dsl + +import com.ing.baker.runtime.javadsl.InteractionInstance +import community.flock.wirespec.kotlin.Wirespec + +/** + * 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 can be empty if the caller intends to share mappers with the recipe + * (which is the usual case — recipe owns the mapping). When `mappers` is empty + * the binding will fail at execute() time; callers normally pass the same mapper + * map they used in the recipe's `api(...) { ... }` block. + */ +class ApiOperationBinding( + private val operation: ApiOperation, + private val handler: Wirespec.Handler, + private val mappers: Map) -> Any>, +) { + fun toInteractionInstance(): InteractionInstance = + ApiOperationInteraction(operation, handler, mappers) +} +``` + +- [ ] **Step 4: Run test, confirm pass** + +Run: `mvn -pl baker-openapi/baker-openapi-dsl test -q` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt \ + baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt +git commit -m "feat: add ApiOperationBinding factory" +``` + +--- + +## Task 7: `baker-openapi-emitter` module skeleton + +**Files:** +- Create: `baker-openapi/baker-openapi-emitter/pom.xml` + +- [ ] **Step 1: Write the pom** + +Write `baker-openapi/baker-openapi-emitter/pom.xml` (mirrors `core/baker-wirespec/pom.xml`): + +```xml + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-emitter + Baker OpenAPI Emitter + Wirespec LanguageEmitter that generates ApiOperation descriptor objects for use with baker-openapi-dsl + + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + org.jetbrains + annotations + 13.0 + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + src/test/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + test-compile + test-compile + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + +``` + +- [ ] **Step 2: Create source dirs** + +```bash +mkdir -p baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter +mkdir -p baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter +``` + +- [ ] **Step 3: Verify build** + +Run: `mvn -pl baker-openapi/baker-openapi-emitter compile -q` +Expected: `BUILD SUCCESS`. + +- [ ] **Step 4: Commit** + +```bash +git add baker-openapi/baker-openapi-emitter/pom.xml +git commit -m "feat: add baker-openapi-emitter module skeleton" +``` + +--- + +## Task 8: `BakerOpenApiEmitter` — emit descriptor objects + +Generates one `object : ApiOperation { ... }` file per endpoint. Models and endpoint classes are emitted by wirespec's standard emitter, not this one. + +**Files:** +- Create: `baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java` +- Create: `baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt` + +- [ ] **Step 1: Write the failing test** + +Write `baker-openapi/baker-openapi-emitter/src/test/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitterTest.kt`: + +```kotlin +package com.ing.baker.openapi.emitter + +import arrow.core.NonEmptyList +import community.flock.wirespec.compiler.core.FileUri +import community.flock.wirespec.compiler.core.emit.PackageName +import community.flock.wirespec.compiler.core.parse.ast.* +import community.flock.wirespec.compiler.core.parse.ast.Reference.Primitive.Type.Precision +import community.flock.wirespec.compiler.utils.Logger +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BakerOpenApiEmitterTest { + + private val emitter = BakerOpenApiEmitter(PackageName("com.example.generated")) + private val logger = object : Logger(Logger.Level.ERROR) { + override fun debug(s: String) = Unit + override fun info(s: String) = Unit + override fun warn(s: String) = Unit + override fun error(s: String) = Unit + } + + private fun field(name: String, ref: Reference) = Field(emptyList(), FieldIdentifier(name), ref) + private fun primString() = Reference.Primitive(Reference.Primitive.Type.String(null), false) + + @Test + fun `emits an object implementing ApiOperation for a POST endpoint with body`() { + val userType = Type( + null, emptyList(), + DefinitionIdentifier("UserDto"), + Type.Shape(listOf(field("firstName", primString()), field("email", primString()))), + emptyList(), + ) + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("CreateUser"), + method = Endpoint.Method.POST, + path = listOf(Endpoint.Segment.Literal("/users")), + queries = emptyList(), + headers = emptyList(), + requests = listOf( + Endpoint.Request(Endpoint.Content("application/json", Reference.Custom("UserDto", false))) + ), + responses = listOf( + Endpoint.Response("201", emptyList(), + Endpoint.Content("application/json", Reference.Custom("UserDto", false)), + emptyList()), + Endpoint.Response("400", emptyList(), + Endpoint.Content("application/json", Reference.Custom("UserDto", false)), + emptyList()), + ), + ) + val module = Module(FileUri("test.ws"), NonEmptyList(userType, listOf(endpoint))) + + val emitted = emitter.emit(endpoint, module, logger) + + val src = emitted.result + // Package and imports + assertTrue(src.contains("package com.example.generated.api")) + assertTrue(src.contains("import com.ing.baker.openapi.dsl.ApiOperation")) + assertTrue(src.contains("import com.ing.baker.openapi.dsl.InputField")) + assertTrue(src.contains("import com.example.generated.endpoint.CreateUser")) + // Object declaration + assertTrue(src.contains("object CreateUser : ApiOperation")) + assertTrue(src.contains("override val operationName = \"CreateUser\"")) + // Input fields flattened from body + assertTrue(src.contains("InputField(\"firstName\", String::class)")) + assertTrue(src.contains("InputField(\"email\", String::class)")) + // Response types + assertTrue(src.contains("201 to CreateUser.Response201::class")) + assertTrue(src.contains("400 to CreateUser.Response400::class")) + // handlerClass + assertTrue(src.contains("override val handlerClass = CreateUser.Handler::class")) + // File path + assertEquals("com/example/generated/api/CreateUser.kt", emitted.file) + } + + @Test + fun `emits InputField for path parameters`() { + val endpoint = Endpoint( + comment = null, + annotations = emptyList(), + identifier = DefinitionIdentifier("GetUser"), + method = Endpoint.Method.GET, + path = listOf( + Endpoint.Segment.Literal("/users/"), + Endpoint.Segment.Param(FieldIdentifier("id"), primString()), + ), + queries = emptyList(), + headers = emptyList(), + requests = listOf(Endpoint.Request(null)), + responses = listOf( + Endpoint.Response("200", emptyList(), null, emptyList()) + ), + ) + val module = Module(FileUri("test.ws"), NonEmptyList(endpoint, emptyList())) + + val src = emitter.emit(endpoint, module, logger).result + assertTrue(src.contains("InputField(\"id\", String::class)")) + } +} +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `mvn -pl baker-openapi/baker-openapi-emitter test -q` +Expected: compile error — `BakerOpenApiEmitter` does not exist. + +- [ ] **Step 3: Implement the emitter** + +Write `baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java`: + +```java +package com.ing.baker.openapi.emitter; + +import community.flock.wirespec.compiler.core.emit.Emitted; +import community.flock.wirespec.compiler.core.emit.FileExtension; +import community.flock.wirespec.compiler.core.emit.LanguageEmitter; +import community.flock.wirespec.compiler.core.emit.PackageName; +import community.flock.wirespec.compiler.core.emit.Shared; +import community.flock.wirespec.compiler.core.parse.ast.Channel; +import community.flock.wirespec.compiler.core.parse.ast.Definition; +import community.flock.wirespec.compiler.core.parse.ast.Endpoint; +import community.flock.wirespec.compiler.core.parse.ast.Enum; +import community.flock.wirespec.compiler.core.parse.ast.Field; +import community.flock.wirespec.compiler.core.parse.ast.Identifier; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import community.flock.wirespec.compiler.core.parse.ast.Reference; +import community.flock.wirespec.compiler.core.parse.ast.Refined; +import community.flock.wirespec.compiler.core.parse.ast.Type; +import community.flock.wirespec.compiler.core.parse.ast.Union; +import community.flock.wirespec.compiler.utils.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BakerOpenApiEmitter extends LanguageEmitter { + + private final PackageName packageName; + private Module currentModule; + + public BakerOpenApiEmitter(PackageName packageName) { + this.packageName = packageName; + } + + public BakerOpenApiEmitter() { + this.packageName = null; + } + + @NotNull @Override public String getSingleLineComment() { return "//"; } + @NotNull @Override public FileExtension getExtension() { return FileExtension.Kotlin; } + @Nullable @Override public Shared getShared() { return null; } + @NotNull @Override public String notYetImplemented() { return ""; } + + @NotNull + @Override + public Emitted emit(@NotNull Definition definition, @NotNull Module module, @NotNull Logger logger) { + this.currentModule = module; + Emitted base = super.emit(definition, module, logger); + if (packageName != null && !packageName.getValue().isEmpty() && definition instanceof Endpoint) { + String dir = packageName.getValue().replace('.', '/') + "/api/"; + return new Emitted(dir + base.getFile(), base.getResult()); + } + return base; + } + + @NotNull @Override public String emit(@NotNull Identifier identifier) { return identifier.getValue(); } + + @NotNull + @Override + public String emit(@NotNull Endpoint endpoint) { + String name = emit(endpoint.getIdentifier()); + + StringBuilder sb = new StringBuilder(); + if (packageName != null && !packageName.getValue().isEmpty()) { + sb.append("package ").append(packageName.getValue()).append(".api\n\n"); + sb.append("import ").append(packageName.getValue()).append(".endpoint.").append(name).append("\n"); + } + sb.append("import com.ing.baker.openapi.dsl.ApiOperation\n"); + sb.append("import com.ing.baker.openapi.dsl.InputField\n"); + sb.append("import community.flock.wirespec.kotlin.Wirespec\n"); + sb.append("import kotlin.reflect.KClass\n\n"); + + sb.append("object ").append(name).append(" : ApiOperation {\n"); + sb.append(" override val operationName = \"").append(name).append("\"\n\n"); + + // Input fields: path + query + headers + flattened body fields + List inputs = collectInputs(endpoint); + sb.append(" override val inputFields = listOf(\n"); + for (String[] f : inputs) { + sb.append(" InputField(\"").append(f[0]).append("\", ").append(f[1]).append("::class),\n"); + } + sb.append(" )\n\n"); + + // Response types + sb.append(" override val responseTypes: Map> = mapOf(\n"); + for (Endpoint.Response resp : endpoint.getResponses()) { + sb.append(" ").append(resp.getStatus()).append(" to ").append(name) + .append(".Response").append(resp.getStatus()).append("::class,\n"); + } + sb.append(" )\n\n"); + + // handlerClass + sb.append(" override val handlerClass = ").append(name).append(".Handler::class\n\n"); + + // buildRequest + sb.append(" override fun buildRequest(ingredients: Map): Any =\n"); + sb.append(" ").append(name).append(".Request(\n"); + // Path params first, then query, headers, body + String bodyTypeName = bodyTypeName(endpoint); + List ctorArgs = new ArrayList<>(); + for (Endpoint.Segment seg : endpoint.getPath()) { + if (seg instanceof Endpoint.Segment.Param p) { + ctorArgs.add(p.getIdentifier().getValue() + " = ingredients[\"" + p.getIdentifier().getValue() + "\"] as " + kotlinType(p.getReference())); + } + } + for (Field q : endpoint.getQueries()) { + ctorArgs.add(q.getIdentifier().getValue() + " = ingredients[\"" + q.getIdentifier().getValue() + "\"] as " + kotlinType(q.getReference())); + } + for (Field h : endpoint.getHeaders()) { + ctorArgs.add(h.getIdentifier().getValue() + " = ingredients[\"" + h.getIdentifier().getValue() + "\"] as " + kotlinType(h.getReference())); + } + if (bodyTypeName != null) { + Type bodyType = findType(bodyTypeName); + StringBuilder bodyCtor = new StringBuilder(bodyTypeName + "("); + if (bodyType != null) { + String fields = bodyType.getShape().getValue().stream() + .map(f -> f.getIdentifier().getValue() + " = ingredients[\"" + f.getIdentifier().getValue() + "\"] as " + kotlinType(f.getReference())) + .collect(Collectors.joining(", ")); + bodyCtor.append(fields); + } + bodyCtor.append(")"); + ctorArgs.add(bodyCtor.toString()); + } + sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); + sb.append(" )\n\n"); + + // invoke + String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); + sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n"); + sb.append(" (handler as ").append(name).append(".Handler).").append(handlerMethod) + .append("(request as ").append(name).append(".Request)\n"); + sb.append("}\n"); + + return sb.toString(); + } + + @NotNull @Override public String emit(@NotNull Type type, @NotNull Module module) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Type.Shape shape) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Field field) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Reference reference) { return kotlinType(reference); } + @NotNull @Override public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Enum anEnum, @NotNull Module module) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Union union) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Refined refined) { return notYetImplemented(); } + @NotNull @Override public String emitValidator(@NotNull Refined refined) { return notYetImplemented(); } + @NotNull @Override public String emit(@NotNull Channel channel) { return notYetImplemented(); } + + private List collectInputs(Endpoint endpoint) { + List out = new ArrayList<>(); + for (Endpoint.Segment seg : endpoint.getPath()) { + if (seg instanceof Endpoint.Segment.Param p) { + out.add(new String[]{p.getIdentifier().getValue(), kotlinType(p.getReference())}); + } + } + for (Field q : endpoint.getQueries()) { + out.add(new String[]{q.getIdentifier().getValue(), kotlinType(q.getReference())}); + } + for (Field h : endpoint.getHeaders()) { + out.add(new String[]{h.getIdentifier().getValue(), kotlinType(h.getReference())}); + } + for (Endpoint.Request req : endpoint.getRequests()) { + if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { + Type bodyType = findType(c.getValue()); + if (bodyType != null) { + for (Field f : bodyType.getShape().getValue()) { + out.add(new String[]{f.getIdentifier().getValue(), kotlinType(f.getReference())}); + } + } + } + } + return out; + } + + private String bodyTypeName(Endpoint endpoint) { + for (Endpoint.Request req : endpoint.getRequests()) { + if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { + return c.getValue(); + } + } + return null; + } + + private Type findType(String name) { + if (currentModule == null) return null; + for (var stmt : currentModule.getStatements()) { + if (stmt instanceof Type t && t.getIdentifier().getValue().equals(name)) return t; + } + return null; + } + + private String kotlinType(Reference ref) { + String base; + if (ref instanceof Reference.Primitive p) { + base = switch (p.getType()) { + case Reference.Primitive.Type.String s -> "String"; + case Reference.Primitive.Type.Integer i -> i.getPrecision().name().equals("P32") ? "Int" : "Long"; + case Reference.Primitive.Type.Number n -> n.getPrecision().name().equals("P32") ? "Float" : "Double"; + case Reference.Primitive.Type.Bytes b -> "ByteArray"; + default -> "Any"; + }; + // Boolean lookup workaround for name collision is not needed in switch since the Java compiler doesn't collide. + if (p.getType().getClass().getSimpleName().equals("Boolean")) base = "Boolean"; + } else if (ref instanceof Reference.Custom c) { + base = c.getValue(); + } else if (ref instanceof Reference.Iterable it) { + base = "List<" + kotlinType(it.getReference()) + ">"; + } else if (ref instanceof Reference.Dict d) { + base = "Map"; + } else if (ref instanceof Reference.Unit) { + base = "Unit"; + } else { + base = "Any"; + } + return ref.isNullable() ? base + "?" : base; + } +} +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `mvn -pl baker-openapi/baker-openapi-emitter test -q` +Expected: `Tests run: 2, Failures: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add baker-openapi/baker-openapi-emitter/src +git commit -m "feat: add BakerOpenApiEmitter generating ApiOperation descriptors" +``` + +--- + +## Task 9: `baker-openapi-plugin` module skeleton + +**Files:** +- Create: `baker-openapi/baker-openapi-plugin/pom.xml` + +- [ ] **Step 1: Write the pom** + +Write `baker-openapi/baker-openapi-plugin/pom.xml`: + +```xml + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-plugin + Baker OpenAPI Maven Plugin + maven-plugin + + + 3.9.6 + + + + + org.apache.maven + maven-plugin-api + ${maven.api.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.10.2 + provided + + + org.apache.maven + maven-core + ${maven.api.version} + provided + + + com.ing.baker + baker-openapi-emitter + ${project.version} + + + community.flock.wirespec.compiler + core-jvm + ${wirespec.version} + + + community.flock.wirespec.converter + openapi-jvm + ${wirespec.version} + + + org.jetbrains.kotlin + kotlin-stdlib + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.10.2 + + + default-descriptor + process-classes + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jvm.target} + ${jvm.target} + + + + + +``` + +- [ ] **Step 2: Create source dirs** + +```bash +mkdir -p baker-openapi/baker-openapi-plugin/src/main/java/com/ing/baker/openapi/plugin +``` + +- [ ] **Step 3: Verify build (will fail until Task 10 adds a Mojo)** + +Run: `mvn -pl baker-openapi/baker-openapi-plugin compile -q` +Expected: `BUILD SUCCESS` (no sources yet; maven-plugin-plugin runs in `process-classes`). + +- [ ] **Step 4: Commit** + +```bash +git add baker-openapi/baker-openapi-plugin/pom.xml +git commit -m "feat: add baker-openapi-plugin module skeleton" +``` + +--- + +## Task 10: `GenerateFromOpenApiMojo` + integration test + +The Mojo invokes wirespec's `convert` entry point with two emitters (the standard Kotlin emitter + `BakerOpenApiEmitter`). + +**Files:** +- Create: `baker-openapi/baker-openapi-plugin/src/main/java/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.java` +- Create: `baker-openapi/baker-openapi-plugin/src/it/settings.xml` +- Create: `baker-openapi/baker-openapi-plugin/src/it/happy-path/pom.xml` +- Create: `baker-openapi/baker-openapi-plugin/src/it/happy-path/src/main/openapi/petstore.json` +- Create: `baker-openapi/baker-openapi-plugin/src/it/happy-path/verify.groovy` +- Modify: `baker-openapi/baker-openapi-plugin/pom.xml` (add `maven-invoker-plugin` to run `src/it/`) + +- [ ] **Step 1: Implement the Mojo** + +Write `baker-openapi/baker-openapi-plugin/src/main/java/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.java`: + +```java +package com.ing.baker.openapi.plugin; + +import arrow.core.NonEmptyList; +import arrow.core.NonEmptySet; +import com.ing.baker.openapi.emitter.BakerOpenApiEmitter; +import community.flock.wirespec.compiler.core.emit.EmitShared; +import community.flock.wirespec.compiler.core.emit.Emitted; +import community.flock.wirespec.compiler.core.emit.Emitter; +import community.flock.wirespec.compiler.core.emit.PackageName; +import community.flock.wirespec.compiler.core.emit.KotlinEmitter; +import community.flock.wirespec.compiler.utils.Logger; +import community.flock.wirespec.openapi.v3.OpenAPIV3Parser; +import community.flock.wirespec.compiler.core.parse.ast.Module; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +@Mojo(name = "generate-from-openapi", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true) +public class GenerateFromOpenApiMojo extends AbstractMojo { + + @Parameter(required = true) + private String input; + + @Parameter(required = true) + private String packageName; + + @Parameter(defaultValue = "${project.build.directory}/generated-sources/baker-openapi") + private String outputDirectory; + + @Parameter(defaultValue = "true") + private boolean addToSources; + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + private final Logger logger = new Logger(Logger.Level.INFO) { + @Override public void debug(String s) { getLog().debug(s); } + @Override public void info(String s) { getLog().info(s); } + @Override public void warn(String s) { getLog().warn(s); } + @Override public void error(String s) { getLog().error(s); } + }; + + @Override + public void execute() throws MojoExecutionException { + try { + Path inputPath = Paths.get(input); + if (!Files.exists(inputPath)) { + throw new MojoExecutionException("OpenAPI file not found: " + input); + } + String json = Files.readString(inputPath); + PackageName pkg = new PackageName(packageName); + + // Parse OpenAPI → wirespec module list + List modules = OpenAPIV3Parser.INSTANCE.parse(json, false); + + // Standard Kotlin emitter for models + endpoint classes + KotlinEmitter kotlinEmitter = new KotlinEmitter(pkg, new EmitShared(false)); + // Baker descriptor emitter + BakerOpenApiEmitter bakerEmitter = new BakerOpenApiEmitter(pkg); + + Path outDir = Paths.get(outputDirectory); + Files.createDirectories(outDir); + + for (Module module : modules) { + emitAll(module, kotlinEmitter, outDir); + emitAll(module, bakerEmitter, outDir); + } + + if (addToSources) { + project.addCompileSourceRoot(outDir.toString()); + getLog().info("Added " + outDir + " as compile source root"); + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to read OpenAPI input", e); + } catch (RuntimeException e) { + throw new MojoExecutionException("Generation failed", e); + } + } + + private void emitAll(Module module, Emitter emitter, Path outDir) throws IOException { + // Wirespec emitters expose emit(Definition, Module, Logger). Iterate the module's statements. + for (var stmt : module.getStatements()) { + Emitted emitted = emitter.emit(stmt, module, logger); + if (emitted.getResult() == null || emitted.getResult().isBlank()) continue; + Path target = outDir.resolve(emitted.getFile()); + Files.createDirectories(target.getParent()); + Files.writeString(target, emitted.getResult()); + } + } +} +``` + +Note: the exact wirespec API surface for emitting an entire module may differ. If `OpenAPIV3Parser.parse(...)` or the iteration shape doesn't match this signature in the imported jar, follow the `ConvertMojo.kt` reference at `/tmp/wirespec-mvn-src/community/flock/wirespec/plugin/maven/mojo/ConvertMojo.kt` and adapt. The plan-level intent is: parse OpenAPI → wirespec AST modules, run both emitters, write `emitted.file → emitted.result` under `outDir`. + +- [ ] **Step 2: Build the plugin** + +Run: `mvn -pl baker-openapi/baker-openapi-plugin -am install -q` +Expected: `BUILD SUCCESS`. The plugin descriptor (`META-INF/maven/plugin.xml`) is generated automatically by `maven-plugin-plugin`. + +- [ ] **Step 3: Add an integration test harness — modify the plugin pom** + +In `baker-openapi/baker-openapi-plugin/pom.xml`, add this plugin entry inside ``: + +```xml + + org.apache.maven.plugins + maven-invoker-plugin + 3.7.0 + + src/it + ${project.build.directory}/it + src/it/settings.xml + verify + true + generate-sources + + + + integration-test + + install + run + + + + +``` + +- [ ] **Step 4: Create the invoker settings file** + +Write `baker-openapi/baker-openapi-plugin/src/it/settings.xml`: + +```xml + + + + + it-repo + true + + + +``` + +- [ ] **Step 5: Add the IT project** + +Write `baker-openapi/baker-openapi-plugin/src/it/happy-path/pom.xml`: + +```xml + + + 4.0.0 + com.ing.baker.it + happy-path + 1.0.0 + jar + + + + + com.ing.baker + baker-openapi-plugin + @project.version@ + + + generate-from-openapi + + ${project.basedir}/src/main/openapi/petstore.json + com.example.it + + + + + + + +``` + +Write `baker-openapi/baker-openapi-plugin/src/it/happy-path/src/main/openapi/petstore.json` — a minimal OpenAPI v3 document with one POST operation: + +```json +{ + "openapi": "3.0.0", + "info": {"title": "Petstore", "version": "1.0.0"}, + "paths": { + "/pets": { + "post": { + "operationId": "addPet", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Pet"} + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Pet"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + } + } + } + } +} +``` + +Write `baker-openapi/baker-openapi-plugin/src/it/happy-path/verify.groovy`: + +```groovy +def base = new File(basedir, "target/generated-sources/baker-openapi/com/example/it") +assert new File(base, "model/Pet.kt").exists() : "Pet model file missing" +assert new File(base, "endpoint/AddPet.kt").exists() : "AddPet endpoint file missing" + +def descriptor = new File(base, "api/AddPet.kt") +assert descriptor.exists() : "Descriptor file missing" +def text = descriptor.text +assert text.contains("object AddPet : ApiOperation") : "Descriptor object declaration missing" +assert text.contains('override val operationName = "AddPet"') : "operationName missing" +assert text.contains("InputField(\"name\", String::class)") : "InputField for name missing" +assert text.contains("201 to AddPet.Response201::class") : "response 201 mapping missing" +return true +``` + +- [ ] **Step 6: Run the integration test** + +Run: `mvn -pl baker-openapi/baker-openapi-plugin -am verify -q` +Expected: `BUILD SUCCESS`. The IT writes the generated files and `verify.groovy` confirms their content. + +If wirespec's OpenAPI parser raises an error on the minimal Petstore JSON (some versions require `info.description`, schemes, etc.), expand the fixture until the parser accepts it. Keep the operation count at one to minimize fixture maintenance. + +- [ ] **Step 7: Commit** + +```bash +git add baker-openapi/baker-openapi-plugin +git commit -m "feat: add baker-openapi Maven plugin with generate-from-openapi goal" +``` + +--- + +## Task 11: End-to-end example — `examples/baker-openapi-example` + +Builds confidence the whole stack works. Mirrors the existing `baker-wirespec-example` structure but uses the new DSL. + +**Files:** +- Create: `examples/baker-openapi-example/pom.xml` +- Create: `examples/baker-openapi-example/src/main/openapi/account-api.json` +- Create: `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt` +- Create: `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Transportation.kt` +- Create: `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt` +- Create: `examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt` +- Modify: root `pom.xml` (register example module near other examples) + +- [ ] **Step 1: Add the example pom** + +Write `examples/baker-openapi-example/pom.xml`: + +```xml + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-openapi-example + Baker OpenAPI Example + + + 0.17.20 + + + + + com.ing.baker + baker-openapi-dsl + ${project.version} + + + com.ing.baker + baker-interface-kotlin + ${project.version} + + + com.ing.baker + baker-recipe-dsl-kotlin + ${project.version} + + + com.ing.baker + baker-compiler + ${project.version} + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + community.flock.wirespec.integration + wirespec-jvm + ${wirespec.version} + + + community.flock.wirespec.integration + jackson-jvm + ${wirespec.version} + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + com.fasterxml.jackson.module + jackson-module-kotlin + 2.18.2 + + + + org.wiremock + wiremock + 3.12.1 + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.jetbrains.kotlin + kotlin-test-junit5 + ${kotlin.version} + test + + + + + + + com.ing.baker + baker-openapi-plugin + ${project.version} + + + account-api + generate-from-openapi + + ${project.basedir}/src/main/openapi/account-api.json + com.ing.baker.examples.account.openapi.generated + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + add-source + + + src/main/kotlin + + + + + add-test-source + generate-test-sources + add-test-source + + + src/test/kotlin + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + ${jvm.target} + + compileprocess-sourcescompile + test-compileprocess-test-sourcestest-compile + + + + org.apache.maven.plugins + maven-deploy-plugin + true + + + + +``` + +- [ ] **Step 2: Register the example in the root pom** + +Find the line `examples/baker-wirespec-example` in root `pom.xml` and add the new example right after it: + +```xml + examples/baker-wirespec-example + examples/baker-openapi-example +``` + +- [ ] **Step 3: Add a minimal OpenAPI doc** + +Write `examples/baker-openapi-example/src/main/openapi/account-api.json` — copy the equivalent operations from the existing `baker-wirespec-example/src/main/wirespec/account.ws` and translate to OpenAPI. At minimum it needs a `POST /accounts` operation returning `201 → AccountDto` / `400 → ErrorResponse`. If unsure of the exact shape, mirror `examples/baker-wirespec-example/src/main/openapi/profile-api.json` (already in repo) as a template. + +- [ ] **Step 4: Add user-defined events** + +Write `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt`: + +```kotlin +package com.ing.baker.examples.account.openapi + +data class CreateAccountCommand( + val userId: String, + val profileId: String, + val accountType: String, + val currency: String, +) + +data class AccountCreated(val accountId: String, val iban: String) +data class AccountCreationFailed(val reason: String) +``` + +- [ ] **Step 5: Copy the transport helper** + +Write `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Transportation.kt` — copy verbatim from `examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Transportation.kt`, changing only the package to `com.ing.baker.examples.account.openapi`. + +- [ ] **Step 6: Write the recipe using the new DSL** + +Write `examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt`: + +```kotlin +package com.ing.baker.examples.account.openapi + +import com.ing.baker.openapi.dsl.api +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.recipe.kotlindsl.recipe +import com.ing.baker.examples.account.openapi.generated.api.CreateAccount +import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint + +@OptIn(ExperimentalDsl::class) +object AccountRecipe { + val recipe = recipe("OpenApiAccount") { + sensoryEvents { event() } + + api(CreateAccount) { + on(201) { resp -> + val body = resp.body + AccountCreated(accountId = body.accountId, iban = body.iban) + } + on(400) { resp -> + AccountCreationFailed(reason = resp.body.message) + } + } + } +} +``` + +- [ ] **Step 7: Write the WireMock end-to-end test** + +Write `examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt` — adapt from `examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountWireMockTest.kt`. Key shape: + +```kotlin +package com.ing.baker.examples.account.openapi + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.examples.account.openapi.generated.api.CreateAccount +import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint +import com.ing.baker.openapi.dsl.ApiOperationBinding +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.runtime.javadsl.EventInstance +import com.ing.baker.runtime.kotlindsl.InMemoryBaker +import community.flock.wirespec.integration.jackson.kotlin.WirespecSerialization +import community.flock.wirespec.kotlin.Wirespec +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.* +import java.util.UUID +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalDsl::class) +class AccountRecipeWireMockTest { + + private lateinit var server: WireMockServer + private lateinit var transport: Transportation + private val objectMapper = ObjectMapper().registerKotlinModule() + private val serialization = WirespecSerialization(objectMapper) + + @BeforeEach fun setUp() { + server = WireMockServer(wireMockConfig().dynamicPort()).also { it.start() } + transport = javaHttpTransportation("http://localhost:${server.port()}") + } + @AfterEach fun tearDown() { server.stop() } + + private fun createAccountHandler(): CreateAccountEndpoint.Handler { + val edge = CreateAccountEndpoint.Handler.client(serialization) + return object : CreateAccountEndpoint.Handler { + override suspend fun createAccount(request: CreateAccountEndpoint.Request): CreateAccountEndpoint.Response<*> = + edge.from(transport(edge.to(request))) + } + } + + @Test + fun `recipe fires AccountCreated on 201`() = runBlocking { + server.stubFor(post(urlEqualTo("/accounts")).willReturn( + aResponse().withStatus(201).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(mapOf( + "accountId" to "a1", "userId" to "u1", "profileId" to "p1", + "iban" to "NL00", "accountType" to "CURRENT", "currency" to "EUR", + ))) + )) + + val handler = createAccountHandler() + val mappers: Map) -> Any> = mapOf( + 201 to { resp -> (resp as CreateAccountEndpoint.Response201).let { AccountCreated(it.body.accountId, it.body.iban) } }, + 400 to { resp -> (resp as CreateAccountEndpoint.Response400).let { AccountCreationFailed(it.body.message) } }, + ) + val binding = ApiOperationBinding(CreateAccount, handler, mappers) + + val baker = InMemoryBaker.kotlin(implementations = listOf(binding.toInteractionInstance())) + val recipeId = baker.addRecipe(RecipeCompiler.compileRecipe(AccountRecipe.recipe), validate = true) + val rid = UUID.randomUUID().toString() + baker.bake(recipeId, rid) + baker.fireSensoryEventAndAwaitReceived(rid, EventInstance.from( + CreateAccountCommand("u1", "p1", "CURRENT", "EUR") + )) + baker.awaitCompleted(rid, timeout = 10.seconds) + + val events = baker.getRecipeInstanceState(rid).events.map { it.name } + assertTrue(events.contains("AccountCreated")) + server.verify(postRequestedFor(urlEqualTo("/accounts"))) + } +} +``` + +- [ ] **Step 8: Run the example end-to-end** + +Run: `mvn -pl examples/baker-openapi-example -am verify -q` +Expected: `BUILD SUCCESS`, one test passes. + +- [ ] **Step 9: Commit** + +```bash +git add examples/baker-openapi-example pom.xml +git commit -m "feat: add baker-openapi-example demonstrating new DSL end to end" +``` + +--- + +## Final verification + +- [ ] **Step 1: Build everything from a clean state** + +Run: `mvn clean install -q -pl baker-openapi,examples/baker-openapi-example -am` +Expected: `BUILD SUCCESS`. + +- [ ] **Step 2: Run full test suite on the new modules** + +Run: `mvn test -pl baker-openapi/baker-openapi-dsl,baker-openapi/baker-openapi-emitter,baker-openapi/baker-openapi-plugin,examples/baker-openapi-example -q` +Expected: all tests pass. + +- [ ] **Step 3: Verify the existing baker-wirespec test suite still passes (regression check)** + +Run: `mvn test -pl core/baker-wirespec,examples/baker-wirespec-example -q` +Expected: `BUILD SUCCESS` — confirms the upstream `KotlinDsl.kt` change in Task 5 didn't break the existing emitter or example. + +- [ ] **Step 4: Final commit if anything was adjusted** + +If any pom or fixture needed tweaking during final verification: + +```bash +git add -p +git commit -m "chore: post-integration adjustments" +``` + +--- + +## Notes for the implementer + +- **Wirespec API drift:** The exact entry points (`OpenAPIV3Parser.parse`, `KotlinEmitter` constructor, `Module.getStatements`) are taken from wirespec 0.17.20 sources extracted into `/tmp/wirespec-mvn-src/` during plan authoring. If a method signature mismatch appears at Task 10, consult `ConvertMojo.kt` and the `core-jvm` jar's public API via `unzip -l ... | grep "\.class$"` — the intent is unchanged: parse → emit modules → write files. +- **`@PublishedApi internal` access:** Task 5 sidesteps this by adding a public `addInteraction` method to `RecipeBuilder`. Do not try to bypass via `inline` — that would force the DSL surface to be all-inline and complicate the implementation. +- **Existing `baker-wirespec` module:** Untouched by this plan. Mark it `@Deprecated` only after Task 11's example is green and reviewed (out of scope for this plan; track as a follow-up). +- **Hand-written tests with fakes (FakeHandler/FakeResponse):** Task 4 and Task 5 tests use local fakes so they don't depend on Task 8's generated descriptors. This keeps tasks independent. diff --git a/docs/superpowers/specs/2026-03-27-wirespec-baker-integration-design.md b/docs/superpowers/specs/2026-03-27-wirespec-baker-integration-design.md new file mode 100644 index 000000000..b7a42de75 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-wirespec-baker-integration-design.md @@ -0,0 +1,268 @@ +# Wirespec-Baker Integration Design + +## Overview + +A custom wirespec emitter module (`baker-wirespec`) that generates Baker Interaction interfaces and implementations from wirespec endpoint definitions. Each API endpoint maps 1:1 to a Baker Interaction, enabling type-safe API consumption within Baker recipes. + +## Architecture + +### Approach + +A new Maven module in the baker repo containing custom wirespec emitters. These plug into wirespec's existing Maven plugin via the `emitterClass` configuration — no custom Maven plugin needed. + +### Module Structure + +``` +baker-wirespec/ +├── pom.xml +└── src/main/java/com/ing/baker/recipe/wirespec/ + ├── BakerKotlinEmitter.java # Kotlin interaction emitter + └── BakerJavaEmitter.java # Java interaction emitter +``` + +### Emitter Classes + +| Class | Extends | File Extension | Output | +|-------|---------|----------------|--------| +| `BakerKotlinEmitter` | `LanguageEmitter` | `.kt` | Kotlin interaction interfaces + implementations | +| `BakerJavaEmitter` | `LanguageEmitter` | `.java` | Java interaction interfaces + implementations | + +Both emitters only process `Endpoint` AST nodes. `Type`, `Enum`, `Refined`, `Union`, and `Channel` return no-op — those are handled by wirespec's standard emitters. + +## Endpoint-to-Interaction Mapping + +### Input (wirespec) + +```wirespec +type TodoDto { + id: Integer, + name: String +} + +type ErrorDto { + message: String +} + +endpoint GetTodo GET /todos/{id: Integer} -> { + 200 -> TodoDto + 404 -> ErrorDto +} + +endpoint CreateTodo POST /todos RequestBody -> { + 201 -> TodoDto + 400 -> ErrorDto +} +``` + +### Mapping Rules + +- **Endpoint name** → `{EndpointName}Interaction` (interface extending `Interaction`) +- **Response per status code** → `{EndpointName}Response{StatusCode}` (event class with response body field) +- **Sealed outcome** → `{EndpointName}Outcome` (sealed interface grouping all response events) +- **Path params, query params, headers** → flat `apply()` parameters (become Baker ingredients) +- **Request body** → additional `apply()` parameter + +### Kotlin Output + +#### Interface + +```kotlin +package com.example.generated + +import com.ing.baker.recipe.javadsl.Interaction + +interface GetTodoInteraction : Interaction { + sealed interface GetTodoOutcome + data class GetTodoResponse200(val body: TodoDto) : GetTodoOutcome + data class GetTodoResponse404(val body: ErrorDto) : GetTodoOutcome + + fun apply(id: Integer): GetTodoOutcome +} + +interface CreateTodoInteraction : Interaction { + sealed interface CreateTodoOutcome + data class CreateTodoResponse201(val body: TodoDto) : CreateTodoOutcome + data class CreateTodoResponse400(val body: ErrorDto) : CreateTodoOutcome + + fun apply(requestBody: RequestBody): CreateTodoOutcome +} +``` + +#### Implementation + +```kotlin +package com.example.generated + +class GetTodoInteractionImpl( + private val client: Wirespec.Client +) : GetTodoInteraction { + + override fun apply(id: Integer): GetTodoInteraction.GetTodoOutcome { + val request = GetTodoEndpoint.Request(id) + val response = client.invoke(request) + return when (response) { + is GetTodoEndpoint.Response200 -> GetTodoInteraction.GetTodoResponse200(response.body) + is GetTodoEndpoint.Response404 -> GetTodoInteraction.GetTodoResponse404(response.body) + } + } +} +``` + +### Java Output + +#### Interface + +```java +package com.example.generated; + +import com.ing.baker.recipe.javadsl.Interaction; +import com.ing.baker.recipe.annotations.FiresEvent; + +public interface GetTodoInteraction extends Interaction { + interface GetTodoOutcome {} + class GetTodoResponse200 implements GetTodoOutcome { + public final TodoDto body; + public GetTodoResponse200(TodoDto body) { this.body = body; } + } + class GetTodoResponse404 implements GetTodoOutcome { + public final ErrorDto body; + public GetTodoResponse404(ErrorDto body) { this.body = body; } + } + + @FiresEvent(oneOf = {GetTodoResponse200.class, GetTodoResponse404.class}) + GetTodoOutcome apply(Integer id); +} +``` + +#### Implementation + +Same pattern as Kotlin — bridges wirespec's generated endpoint client to the Baker interaction interface. + +## Dependencies + +### baker-wirespec pom.xml + +```xml + + + + community.flock.wirespec + wirespec-compiler-core-jvm + ${wirespec.version} + + + + + com.ing.baker + baker-recipe-dsl_2.13 + ${project.version} + provided + + + + + com.ing.baker + baker-annotations_2.13 + ${project.version} + provided + + +``` + +## Consumer Integration + +### Maven Plugin Configuration + +```xml + + community.flock.wirespec.plugin.maven + wirespec-maven-plugin + ${wirespec.version} + + + + wirespec-kotlin + compile + + ${project.basedir}/src/main/wirespec + ${project.build.directory}/generated-sources/wirespec + Kotlin + com.example.generated + + + + + wirespec-baker + compile + + ${project.basedir}/src/main/wirespec + ${project.build.directory}/generated-sources/wirespec + com.ing.baker.recipe.wirespec.BakerKotlinEmitter + com.example.generated + + + + + + com.ing.baker + baker-wirespec + ${baker.version} + + + +``` + +### Using in a Recipe + +```kotlin +@ExperimentalDsl +val recipe = recipe("todo-recipe") { + sensoryEvents { + event() + } + interaction() + interaction { + requiredEvents { + event() + } + } +} +``` + +### Wiring at Runtime + +```kotlin +val wirespecClient: Wirespec.Client<...> = // user provides HTTP client +val baker = AkkaBaker.localDefault(actorSystem) + +baker.addInteractionInstance(GetTodoInteractionImpl(wirespecClient)) +baker.addInteractionInstance(CreateTodoInteractionImpl(wirespecClient)) +``` + +## Compilation Pipeline + +1. Wirespec Maven plugin discovers `.ws` files in `src/main/wirespec` +2. Standard emitter (Kotlin/Java) generates: types (`TodoDto`, `ErrorDto`), endpoint classes (`GetTodoEndpoint` with `Request`, `Response`, `Client`, `Server`) +3. Baker emitter generates: interaction interfaces (`GetTodoInteraction`) + implementations (`GetTodoInteractionImpl`) that bridge to the wirespec endpoint classes +4. All generated sources land in `target/generated-sources/wirespec` +5. Consumer code references interactions in recipes and wires implementations at runtime + +## Testing + +Unit tests feed wirespec AST nodes into the emitters and assert: +- Generated code string matches expected output +- Interaction interfaces follow Baker conventions (sealed outcome, `apply` method, `Interaction` trait) +- Implementation classes correctly bridge wirespec endpoint types to Baker outcome events + +## Scope + +### In scope +- `BakerKotlinEmitter` — generates Kotlin interaction interfaces + implementations +- `BakerJavaEmitter` — generates Java interaction interfaces + implementations +- Unit tests for both emitters +- Maven module configuration + +### Out of scope +- Custom Maven plugin (use wirespec's existing plugin) +- Type/Enum/Refined/Union/Channel emission (handled by standard wirespec emitters) +- HTTP client implementation (user provides via wirespec's `Wirespec.Client`) diff --git a/docs/superpowers/specs/2026-05-22-baker-openapi-plugin-design.md b/docs/superpowers/specs/2026-05-22-baker-openapi-plugin-design.md new file mode 100644 index 000000000..770a0060a --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-baker-openapi-plugin-design.md @@ -0,0 +1,301 @@ +# Baker OpenAPI Plugin Design + +## Overview + +A new family of modules — `baker-openapi-plugin`, `baker-openapi-emitter`, `baker-openapi-dsl` — that lets Baker users turn an OpenAPI operation into a recipe interaction with minimal ceremony, without coupling the recipe to wirespec-generated response classes. + +The goal: replace the four-execution `wirespec-maven-plugin` setup and the response-class-referencing recipe DSL used today (`event()`) with: + +```kotlin +recipe("create-current-account") { + sensoryEvents { event() } + + api(CreateUser) { + on(201) fires { resp -> + UserCreated(id = resp.body.id, email = resp.body.email) + } + on(400) fires { resp -> + UserCreationFailed(reason = resp.body.message) + } + } + + api(CreateProfile) { + requires(UserCreated::class) + on(201) fires { resp -> + ProfileCreated(profileId = resp.body.profileId) + } + } +} +``` + +The wirespec response type appears only inside the mapping lambda. The recipe's event graph (`requires(...)`, downstream interactions) is expressed entirely in user-defined domain events. + +## Relationship to existing `baker-wirespec` + +The existing `core/baker-wirespec` module is an emitter that plugs into `wirespec-maven-plugin` and generates `XxxInteraction` interfaces + `XxxInteractionImpl` implementations per endpoint. Recipes reference the generated response classes directly (e.g. `event()`), which is the coupling this design eliminates. + +`baker-wirespec` stays in place for one release. After `baker-openapi-example` is green (see Testing), it is marked `@Deprecated`. + +## Module layout + +A single parent folder under the root project: + +``` +baker-openapi/ +├── pom.xml # aggregator +├── baker-openapi-dsl/ # runtime DSL + generic interaction +├── baker-openapi-emitter/ # wirespec LanguageEmitter (descriptors only) +└── baker-openapi-plugin/ # maven plugin +``` + +| Module | Purpose | Language | Scope | +|---|---|---|---| +| `baker-openapi-dsl` | Runtime DSL (`api(...) { ... }`, `on(N) fires { ... }`, `requires()`) + generic `ApiOperationInteraction` that builds a Baker `InteractionInstance` from a descriptor + response mappers. Reusable across any operation. | Kotlin | Runtime, consumer dependency | +| `baker-openapi-emitter` | Wirespec `LanguageEmitter` that emits only operation descriptor objects. Models + endpoint classes come from wirespec's standard Kotlin emitter. | Java (matches existing `baker-wirespec` style) | Compile-time, used by the plugin | +| `baker-openapi-plugin` | Maven plugin with a single goal: `generate-from-openapi`. Drives wirespec's OpenAPI converter, then runs the standard Kotlin emitter and the baker-openapi-emitter. | Java | Build-time | + +`baker-openapi-plugin` and `baker-openapi-dsl` do not depend on each other. The plugin is build-time only; the DSL is runtime only. + +## Generated artifacts (per OpenAPI doc) + +The plugin writes into `target/generated-sources/baker-openapi//`. + +### 1. Standard wirespec output (delegated to wirespec's Kotlin emitter) + +- `model/UserDto.kt`, `model/ErrorResponse.kt`, … — DTOs for request/response types +- `endpoint/CreateUserEndpoint.kt` — wirespec endpoint class with `Request`, nested `Response201`/`Response400`, `Handler` + +### 2. Baker operation descriptors (emitted by `baker-openapi-emitter`) + +One file per operation, e.g. `api/CreateUser.kt`: + +```kotlin +package com.example.generated.api + +object CreateUser : ApiOperation { + override val operationName = "CreateUser" + + override val inputFields = listOf( + InputField("firstName", String::class), + InputField("lastName", String::class), + InputField("email", String::class), + InputField("dateOfBirth", String::class), + ) + + override val responseTypes: Map> = mapOf( + 201 to CreateUserEndpoint.Response201::class, + 400 to CreateUserEndpoint.Response400::class, + ) + + override fun buildRequest(ingredients: Map): CreateUserEndpoint.Request = + CreateUserEndpoint.Request( + UserDto( + firstName = ingredients["firstName"] as String, + lastName = ingredients["lastName"] as String, + email = ingredients["email"] as String, + dateOfBirth = ingredients["dateOfBirth"] as String, + ) + ) + + override suspend fun invoke( + handler: Wirespec.Handler, + request: Any, + ): Wirespec.Response<*> = + (handler as CreateUserEndpoint.Handler).createUser(request as CreateUserEndpoint.Request) +} +``` + +Explicitly **not** generated: no `CreateUserInteraction` interface, no `CreateUserInteractionImpl`. Descriptors are data, not interface scaffolding. + +### 3. Nothing else + +No per-operation DSL extensions, no scope classes. The DSL is hand-written in `baker-openapi-dsl` and works for any descriptor. + +## Runtime DSL (`baker-openapi-dsl`) + +### Public surface + +```kotlin +@ExperimentalDsl +fun RecipeBuilder.api( + operation: ApiOperation, + configure: ApiInteractionScope.() -> Unit, +) + +class ApiInteractionScope internal constructor(...) { + // response mapping — required for every status code the recipe cares about + fun on(status: Int): ResponseBinding + infix fun ResponseBinding.fires(mapper: (R) -> Any) + + // optional Baker controls (mirror existing kotlin DSL) + fun requires(vararg eventClasses: KClass<*>) + fun maximumInteractionCount(n: Int) + fun ingredientNameOverrides(block: MutableMap.() -> Unit) +} +``` + +### Internal mechanics of `api(...)` + +1. Construct an `ApiOperationInteraction` (a generic Baker `InteractionInstance` implementation) bound to the operation descriptor and the configured status→event mappers. +2. Use the descriptor's `inputFields` to register `IngredientDescriptor`s with Baker — this is how Baker learns the interaction's inputs without an `apply()` method to reflect on. +3. The set of event classes produced by the mappers becomes the interaction's output event set (registered with Baker via `EventDescriptor`s derived from each `KClass`). +4. Apply `requires`, `maximumInteractionCount`, and `ingredientNameOverrides` to the recipe-level interaction binding. + +### Generic interaction implementation + +```kotlin +class ApiOperationInteraction( + private val operation: ApiOperation, + private val handler: Wirespec.Handler, + private val mappers: Map Any>, +) : InteractionInstance { + override val name = operation.operationName + override val input: List = operation.inputFields.map { ... } + override val output: Map> = /* derived from mapper return classes */ + + override fun execute(input: List): CompletableFuture { + val ingredients = input.associate { it.name to it.value } + val request = operation.buildRequest(ingredients) + val response = runBlocking { operation.invoke(handler, request) } + val mapper = mappers[response.status] + ?: error("No mapping for status ${response.status} on ${operation.operationName}") + val event = mapper(response) + return CompletableFuture.completedFuture(EventInstance.from(event)) + } +} +``` + +### Decoupling property + +The wirespec response type appears only inside the lambda parameter to `fires { ... }`. The recipe's event graph — `requires(...)`, downstream `api(...) { ... }` blocks, `sensoryEvents { ... }`, the sealed set of events Baker reasons about — uses only user-defined classes. Renaming or replacing the OpenAPI document does not require touching `requires(...)` or any downstream interaction binding; only the mapping lambdas need to be updated. + +## Maven plugin (`baker-openapi-plugin`) + +### Goal: `generate-from-openapi` + +Bound to the `generate-sources` phase by default. One execution per OpenAPI document. + +### Consumer pom + +```xml + + com.ing.baker + baker-openapi-plugin + ${baker.version} + + + account-api + generate-from-openapi + + ${project.basedir}/src/main/openapi/account-api.json + com.ing.baker.examples.account.generated + + + + profile-api + generate-from-openapi + + ${project.basedir}/src/main/openapi/profile-api.json + com.ing.baker.examples.account.generated.profile + + + + +``` + +### Configuration parameters + +| Parameter | Default | Notes | +|---|---|---| +| `input` | (required) | Path to OpenAPI v3 file (.json or .yaml) | +| `packageName` | (required) | Target Kotlin package | +| `outputDirectory` | `${project.build.directory}/generated-sources/baker-openapi` | | +| `addToSources` | `true` | Auto-adds output to compile source roots (no `build-helper-maven-plugin` needed) | + +### Internal pipeline + +1. Read the OpenAPI file and parse it via wirespec's `OpenAPIV3Parser` into a wirespec AST. +2. Run wirespec's standard `KotlinEmitter` over the AST; write `model/*.kt` and `endpoint/*.kt`. +3. Run `BakerOpenApiEmitter` (new) over the AST; write `api/.kt` descriptor objects. +4. If `addToSources`, register the output directory as a Maven compile source root via `MavenProject.addCompileSourceRoot(...)`. + +### What disappears from the consumer pom + +- The four wirespec-maven-plugin executions (Kotlin + Baker, ×2 for OpenAPI + .ws). +- The `` plumbing. +- The `build-helper-maven-plugin` execution that adds `generated-sources/wirespec` as a source root. + +## Runtime wiring + +### Consumer runtime dependency + +```xml + + com.ing.baker + baker-openapi-dsl + ${baker.version} + +``` + +`baker-openapi-dsl` transitively pulls in `baker-recipe-dsl-kotlin`, `baker-interface-kotlin`, and the wirespec runtime (`wirespec-jvm`). Consumers add Jackson + `wirespec-jackson-jvm` themselves — HTTP serialization choice is theirs. + +### App startup + +```kotlin +val transport = javaHttpTransportation("https://api.example.com") +val serialization = WirespecSerialization(ObjectMapper().registerKotlinModule()) + +val baker = InMemoryBaker.kotlin( + implementations = listOf( + ApiOperationBinding(CreateUser, transport, serialization), + ApiOperationBinding(CreateProfile, transport, serialization), + ApiOperationBinding(CreateAccount, transport, serialization), + ) +) +``` + +`ApiOperationBinding` is a small factory in `baker-openapi-dsl` that constructs the wirespec `Handler` for an operation (from its descriptor) and pairs it with an `ApiOperationInteraction` instance. The operation list is named once at startup; status→event mappings live in the recipe. + +## Module dependency graph + +``` +baker-openapi-plugin ──depends──▶ baker-openapi-emitter ──depends──▶ wirespec-compiler-core-jvm + wirespec-openapi-jvm +baker-openapi-dsl ──depends──▶ baker-recipe-dsl-kotlin + baker-interface-kotlin + wirespec-jvm +``` + +## Testing + +| Target | Approach | +|---|---| +| `baker-openapi-emitter` | Unit: feed wirespec AST fixtures (path params, query, request body, multiple status codes) into the emitter; assert generated `object CreateUser : ApiOperation { ... }` source matches expected. Mirrors the existing `BakerKotlinEmitterTest`. | +| `baker-openapi-dsl` | Unit (DSL): build a recipe with `api(...) { on(201) fires { ... } }`, compile via `RecipeCompiler`, assert the resulting `CompiledRecipe` has the expected ingredients, events, and interactions wired. Unit (runtime): exercise `ApiOperationInteraction.execute(...)` with a fake `Wirespec.Handler` covering matched status, unmatched status (errors), and exception propagation. | +| `baker-openapi-plugin` | Integration: `maven-invoker-plugin` runs `generate-from-openapi` against a fixture OpenAPI file in `src/it/`; assertions on generated file paths and expected substrings. | +| End-to-end | A new `baker-openapi-example` (or migrated `baker-wirespec-example`) runs the WireMock-based recipe test using the new DSL. Proves the whole stack: plugin generates → DSL configures → Baker executes → WireMock responds → events fire. | + +## Scope + +### In scope (v1) + +- `baker-openapi/` aggregator with three child modules. +- OpenAPI v3 JSON input. +- Single OpenAPI document per plugin execution; multiple executions for multiple APIs. +- Kotlin DSL only. +- The `api(operation) { ... }` recipe DSL with `on(N) fires { ... }`, `requires(...)`, `maximumInteractionCount(...)`, `ingredientNameOverrides { ... }`. +- Unit tests for the emitter and DSL; integration test for the plugin; one end-to-end example. + +### Out of scope (v1) + +- Fallback for unmapped status codes. The runtime errors when a response status has no mapper. A future `onUnmapped { ... }` clause can be added without breaking the DSL. +- `.ws` source input. The plugin handles OpenAPI only. Hand-written wirespec sources are converted to OpenAPI upstream, or a second goal is added later. +- YAML OpenAPI input. Wirespec supports it; allowing `.yaml` in `input` is a follow-up. +- Java DSL. Not generated; not hand-written for v1. +- Custom Maven plugin replacing `wirespec-maven-plugin` for non-Baker projects. This plugin only generates what Baker users need. + +## Migration + +- New work uses `baker-openapi-plugin` + `baker-openapi-dsl`. +- `baker-wirespec` (existing emitter) stays in place for one release. It is marked `@Deprecated` once `baker-openapi-example` is green. +- The existing `baker-wirespec-example` is either migrated to the new approach (preferable — demonstrates parity) or kept as a frozen reference. Decision deferred to execution time. diff --git a/examples/baker-openapi-example/pom.xml b/examples/baker-openapi-example/pom.xml new file mode 100644 index 000000000..bffac08ac --- /dev/null +++ b/examples/baker-openapi-example/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-openapi-example + Baker OpenAPI Example + + + 0.17.20 + + + + + com.ing.baker + baker-openapi-dsl + ${project.version} + + + com.ing.baker + baker-openapi-transportation + ${project.version} + + + com.ing.baker + baker-interface-kotlin + ${project.version} + + + com.ing.baker + baker-recipe-dsl-kotlin + ${project.version} + + + com.ing.baker + baker-compiler + ${project.version} + + + com.ing.baker + baker-test + ${project.version} + + + org.scala-lang + scala-library + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + community.flock.wirespec.integration + wirespec-jvm + ${wirespec.version} + + + community.flock.wirespec.integration + jackson-jvm + ${wirespec.version} + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + com.fasterxml.jackson.module + jackson-module-kotlin + 2.18.2 + + + + org.wiremock + wiremock + 3.12.1 + test + + + 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 + + + + + + + com.ing.baker + baker-openapi-plugin + ${project.version} + + + account-api + generate-from-openapi + + ${project.basedir}/src/main/openapi/account-api.json + com.ing.baker.examples.account.openapi.generated + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + add-source + + + src/main/kotlin + + + + + add-test-source + generate-test-sources + add-test-source + + + src/test/kotlin + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + ${jvm.target} + + compileprocess-sourcescompile + test-compileprocess-test-sourcestest-compile + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + org.apache.maven.plugins + maven-deploy-plugin + true + + + + diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt new file mode 100644 index 000000000..1139bbdf5 --- /dev/null +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -0,0 +1,23 @@ +package com.ing.baker.examples.account.openapi + +import com.ing.baker.openapi.dsl.api +import com.ing.baker.openapi.dsl.apiRecipe +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.examples.account.openapi.generated.api.CreateAccount +import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint + +@OptIn(ExperimentalDsl::class) +object AccountRecipe { + val apiRecipe = apiRecipe("OpenApiAccount") { + sensoryEvents { event() } + + api(CreateAccount) { + on(201) { resp -> + AccountCreated(accountId = resp.body.accountId, iban = resp.body.iban) + } + on(400) { resp -> + AccountCreationFailed(reason = resp.body.message) + } + } + } +} diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt new file mode 100644 index 000000000..ebe6d5bf2 --- /dev/null +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt @@ -0,0 +1,11 @@ +package com.ing.baker.examples.account.openapi + +data class CreateAccountCommand( + val userId: String, + val profileId: String, + val accountType: String, + val currency: String, +) + +data class AccountCreated(val accountId: String, val iban: String) +data class AccountCreationFailed(val reason: String) diff --git a/examples/baker-openapi-example/src/main/openapi/account-api.json b/examples/baker-openapi-example/src/main/openapi/account-api.json new file mode 100644 index 000000000..4db384666 --- /dev/null +++ b/examples/baker-openapi-example/src/main/openapi/account-api.json @@ -0,0 +1,70 @@ +{ + "openapi": "3.0.0", + "info": {"title": "Account API", "version": "1.0.0"}, + "paths": { + "/accounts": { + "post": { + "operationId": "createAccount", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/CreateAccountRequest"} + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/AccountDto"} + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CreateAccountRequest": { + "type": "object", + "required": ["userId", "profileId", "accountType", "currency"], + "properties": { + "userId": {"type": "string"}, + "profileId": {"type": "string"}, + "accountType": {"type": "string"}, + "currency": {"type": "string"} + } + }, + "AccountDto": { + "type": "object", + "required": ["accountId", "userId", "profileId", "iban", "accountType", "currency"], + "properties": { + "accountId": {"type": "string"}, + "userId": {"type": "string"}, + "profileId": {"type": "string"}, + "iban": {"type": "string"}, + "accountType": {"type": "string"}, + "currency": {"type": "string"} + } + }, + "ErrorResponse": { + "type": "object", + "required": ["message"], + "properties": { + "message": {"type": "string"} + } + } + } + } +} diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt new file mode 100644 index 000000000..61669e020 --- /dev/null +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -0,0 +1,84 @@ +package com.ing.baker.examples.account.openapi + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.examples.account.openapi.generated.api.CreateAccount +import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint +import com.ing.baker.openapi.transportation.Transportation +import com.ing.baker.openapi.transportation.javaHttpTransportation +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.runtime.javadsl.EventInstance +import com.ing.baker.runtime.kotlindsl.InMemoryBaker +import community.flock.wirespec.integration.jackson.kotlin.WirespecSerialization +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalDsl::class) +class AccountRecipeWireMockTest { + + private lateinit var server: WireMockServer + private lateinit var transport: Transportation + private val objectMapper = ObjectMapper().registerKotlinModule() + private val serialization = WirespecSerialization(objectMapper) + + @BeforeEach fun setUp() { + server = WireMockServer(wireMockConfig().dynamicPort()).also { it.start() } + transport = javaHttpTransportation("http://localhost:${server.port()}") + } + @AfterEach fun tearDown() { server.stop() } + + private fun createAccountHandler(): CreateAccountEndpoint.Handler { + val edge = CreateAccountEndpoint.Handler.client(serialization) + return object : CreateAccountEndpoint.Handler { + override suspend fun createAccount( + request: CreateAccountEndpoint.Request + ): CreateAccountEndpoint.Response<*> = edge.from(transport(edge.to(request))) + } + } + + @Test + fun `recipe fires AccountCreated on 201`() = runBlocking { + server.stubFor( + post(urlEqualTo("/accounts")).willReturn( + aResponse().withStatus(201).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(mapOf( + "accountId" to "a1", "userId" to "u1", "profileId" to "p1", + "iban" to "NL00", "accountType" to "CURRENT", "currency" to "EUR", + ))) + ) + ) + + val baker = InMemoryBaker.kotlin( + implementations = AccountRecipe.apiRecipe.toInteractionInstances( + handlers = mapOf(CreateAccount to createAccountHandler()), + ), + ) + val recipeId = baker.addRecipe( + compiledRecipe = RecipeCompiler.compileRecipe(AccountRecipe.apiRecipe.recipe), + validate = true, + ) + val rid = UUID.randomUUID().toString() + baker.bake(recipeId, rid) + baker.fireSensoryEventAndAwaitReceived( + rid, + EventInstance.from(CreateAccountCommand("u1", "p1", "CURRENT", "EUR")), + ) + baker.awaitCompleted(rid, timeout = 10.seconds) + + val events = baker.getRecipeInstanceState(rid).events.map { it.name } + assertTrue(events.contains("AccountCreated"), "events were: $events") + server.verify(postRequestedFor(urlEqualTo("/accounts"))) + } +} diff --git a/examples/baker-wirespec-example/pom.xml b/examples/baker-wirespec-example/pom.xml new file mode 100644 index 000000000..27c697757 --- /dev/null +++ b/examples/baker-wirespec-example/pom.xml @@ -0,0 +1,239 @@ + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-wirespec-example + Baker Wirespec Example + Example module demonstrating wirespec-baker integration + + + 0.17.20 + + + + + com.ing.baker + baker-interface-kotlin + ${project.version} + + + com.ing.baker + baker-recipe-dsl-kotlin + ${project.version} + + + com.ing.baker + baker-compiler + ${project.version} + + + com.ing.baker + baker-test + ${project.version} + + + org.scala-lang + scala-library + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + + community.flock.wirespec.integration + wirespec-jvm + ${wirespec.version} + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + com.fasterxml.jackson.module + jackson-module-kotlin + 2.18.2 + + + + community.flock.wirespec.integration + jackson-jvm + ${wirespec.version} + + + + org.wiremock + wiremock + 3.12.1 + test + + + + 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 + + + + + + + community.flock.wirespec.plugin.maven + wirespec-maven-plugin + ${wirespec.version} + + + + compile-wirespec-kotlin + compile + + ${project.basedir}/src/main/wirespec + ${project.build.directory}/generated-sources/wirespec + Kotlin + com.ing.baker.examples.account.generated + + + + + compile-wirespec-baker + compile + + ${project.basedir}/src/main/wirespec + ${project.build.directory}/generated-sources/wirespec + com.ing.baker.recipe.wirespec.BakerKotlinEmitter + com.ing.baker.examples.account.generated + + + + + convert-openapi-kotlin + convert + + ${project.basedir}/src/main/openapi/profile-api.json + ${project.build.directory}/generated-sources/wirespec + OpenAPIV3 + Kotlin + com.ing.baker.examples.account.generated + false + + + + + convert-openapi-baker + convert + + ${project.basedir}/src/main/openapi/profile-api.json + ${project.build.directory}/generated-sources/wirespec + OpenAPIV3 + com.ing.baker.recipe.wirespec.BakerKotlinEmitter + com.ing.baker.examples.account.generated + + + + + + com.ing.baker + baker-wirespec + ${project.version} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + add-source + + + ${project.build.directory}/generated-sources/wirespec + src/main/kotlin + + + + + add-test-source + generate-test-sources + add-test-source + + + 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 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + diff --git a/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/CreateCurrentAccountRecipe.kt b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/CreateCurrentAccountRecipe.kt new file mode 100644 index 000000000..5a3cf8ad4 --- /dev/null +++ b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/CreateCurrentAccountRecipe.kt @@ -0,0 +1,42 @@ +package com.ing.baker.examples.account + +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.recipe.kotlindsl.recipe +import com.ing.baker.examples.account.generated.* + +@ExperimentalDsl +object CreateCurrentAccountRecipe { + val recipe = recipe("CreateCurrentAccount") { + sensoryEvents { + event() + } + + // Step 1: Create user — picks up firstName, lastName, email, dateOfBirth from sensory event + interaction { + maximumInteractionCount = 1 + } + + // Step 2: Create profile — userId comes from CreateUser's "id" ingredient + interaction { + maximumInteractionCount = 1 + ingredientNameOverrides { + "userId" to "id" + } + requiredEvents { + event() + } + } + + // Step 3: Create account — userId from "id", profileId from CreateProfile response + interaction { + maximumInteractionCount = 1 + ingredientNameOverrides { + "userId" to "id" + } + requiredEvents { + event() + event() + } + } + } +} diff --git a/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Events.kt b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Events.kt new file mode 100644 index 000000000..ef4308b53 --- /dev/null +++ b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Events.kt @@ -0,0 +1,12 @@ +package com.ing.baker.examples.account + +data class CreateCurrentAccountEvent( + val firstName: String, + val lastName: String, + val email: String, + val dateOfBirth: String, + val address: String, + val phoneNumber: String, + val accountType: String, + val currency: String +) diff --git a/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/HandlerFactories.kt b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/HandlerFactories.kt new file mode 100644 index 000000000..1be825db8 --- /dev/null +++ b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/HandlerFactories.kt @@ -0,0 +1,37 @@ +package com.ing.baker.examples.account + +import community.flock.wirespec.kotlin.Wirespec +import com.ing.baker.examples.account.generated.endpoint.CreateUser +import com.ing.baker.examples.account.generated.endpoint.CreateProfile +import com.ing.baker.examples.account.generated.endpoint.CreateAccount + + +fun createUserHandler(transport: Transportation, serialization: Wirespec.Serialization): CreateUser.Handler { + val edge = CreateUser.Handler.client(serialization) + return object : CreateUser.Handler { + override suspend fun createUser(request: CreateUser.Request): CreateUser.Response<*> { + val rawResponse = transport(edge.to(request)) + return edge.from(rawResponse) + } + } +} + +fun createProfileHandler(transport: Transportation, serialization: Wirespec.Serialization): CreateProfile.Handler { + val edge = CreateProfile.Handler.client(serialization) + return object : CreateProfile.Handler { + override suspend fun createProfile(request: CreateProfile.Request): CreateProfile.Response<*> { + val rawResponse = transport(edge.to(request)) + return edge.from(rawResponse) + } + } +} + +fun createAccountHandler(transport: Transportation, serialization: Wirespec.Serialization): CreateAccount.Handler { + val edge = CreateAccount.Handler.client(serialization) + return object : CreateAccount.Handler { + override suspend fun createAccount(request: CreateAccount.Request): CreateAccount.Response<*> { + val rawResponse = transport(edge.to(request)) + return edge.from(rawResponse) + } + } +} diff --git a/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Transportation.kt b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Transportation.kt new file mode 100644 index 000000000..d2a812372 --- /dev/null +++ b/examples/baker-wirespec-example/src/main/kotlin/com/ing/baker/examples/account/Transportation.kt @@ -0,0 +1,46 @@ +package com.ing.baker.examples.account + +import community.flock.wirespec.kotlin.Wirespec +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +typealias Transportation = suspend (Wirespec.RawRequest) -> Wirespec.RawResponse + +fun javaHttpTransportation(baseUrl: String, client: HttpClient = HttpClient.newHttpClient()): Transportation = { rawRequest -> + val path = rawRequest.path.joinToString("/") + val queryString = rawRequest.queries + .flatMap { (key, values) -> values.map { "$key=$it" } } + .joinToString("&") + .let { if (it.isNotEmpty()) "?$it" else "" } + + val uri = URI.create("$baseUrl/$path$queryString") + + val bodyPublisher = rawRequest.body + ?.let { HttpRequest.BodyPublishers.ofByteArray(it) } + ?: HttpRequest.BodyPublishers.noBody() + + val builder = HttpRequest.newBuilder() + .uri(uri) + .method(rawRequest.method, bodyPublisher) + + rawRequest.headers.forEach { (name, values) -> + values.forEach { value -> builder.header(name, value) } + } + + if (rawRequest.body != null) { + builder.header("Content-Type", "application/json") + } + + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()) + + val responseHeaders = response.headers().map() + .mapValues { (_, values) -> values.toList() } + + Wirespec.RawResponse( + statusCode = response.statusCode(), + headers = responseHeaders, + body = response.body()?.takeIf { it.isNotEmpty() } + ) +} diff --git a/examples/baker-wirespec-example/src/main/openapi/profile-api.json b/examples/baker-wirespec-example/src/main/openapi/profile-api.json new file mode 100644 index 000000000..ec444b13b --- /dev/null +++ b/examples/baker-wirespec-example/src/main/openapi/profile-api.json @@ -0,0 +1,77 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Profile Service", + "version": "1.0.0" + }, + "paths": { + "/profiles": { + "post": { + "operationId": "CreateProfile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProfileRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Profile created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileDto" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CreateProfileRequest": { + "type": "object", + "required": ["userId", "address", "phoneNumber"], + "properties": { + "userId": { "type": "string" }, + "address": { "type": "string" }, + "phoneNumber": { "type": "string" } + } + }, + "ProfileDto": { + "type": "object", + "required": ["profileId", "userId", "address", "phoneNumber"], + "properties": { + "profileId": { "type": "string" }, + "userId": { "type": "string" }, + "address": { "type": "string" }, + "phoneNumber": { "type": "string" } + } + }, + "ProfileErrorResponse": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/examples/baker-wirespec-example/src/main/wirespec/account.ws b/examples/baker-wirespec-example/src/main/wirespec/account.ws new file mode 100644 index 000000000..6e478f1ce --- /dev/null +++ b/examples/baker-wirespec-example/src/main/wirespec/account.ws @@ -0,0 +1,20 @@ +type CreateAccountRequest { + userId: String, + profileId: String, + accountType: String, + currency: String +} + +type AccountDto { + accountId: String, + userId: String, + profileId: String, + iban: String, + accountType: String, + currency: String +} + +endpoint CreateAccount POST CreateAccountRequest /accounts -> { + 201 -> AccountDto + 400 -> ErrorResponse +} diff --git a/examples/baker-wirespec-example/src/main/wirespec/user.ws b/examples/baker-wirespec-example/src/main/wirespec/user.ws new file mode 100644 index 000000000..3996a9087 --- /dev/null +++ b/examples/baker-wirespec-example/src/main/wirespec/user.ws @@ -0,0 +1,24 @@ +type CreateUserRequest { + firstName: String, + lastName: String, + email: String, + dateOfBirth: String +} + +type UserDto { + id: String, + firstName: String, + lastName: String, + email: String, + dateOfBirth: String +} + +type ErrorResponse { + code: String, + message: String +} + +endpoint CreateUser POST CreateUserRequest /users -> { + 201 -> UserDto + 400 -> ErrorResponse +} diff --git a/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountTest.kt b/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountTest.kt new file mode 100644 index 000000000..3074229b9 --- /dev/null +++ b/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountTest.kt @@ -0,0 +1,105 @@ +package com.ing.baker.examples.account + +import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.runtime.javadsl.EventInstance +import com.ing.baker.runtime.kotlindsl.InMemoryBaker +import com.ing.baker.examples.account.generated.* +import com.ing.baker.examples.account.generated.endpoint.CreateUser +import com.ing.baker.examples.account.generated.endpoint.CreateProfile +import com.ing.baker.examples.account.generated.endpoint.CreateAccount +import com.ing.baker.examples.account.generated.model.* +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.util.* +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@ExperimentalDsl +class CreateCurrentAccountTest { + + private val userHandler = object : CreateUser.Handler { + override suspend fun createUser(request: CreateUser.Request): CreateUser.Response<*> { + return CreateUser.Response201( + body = UserDto( + id = "user-1", + firstName = request.body.firstName, + lastName = request.body.lastName, + email = request.body.email, + dateOfBirth = request.body.dateOfBirth + ) + ) + } + } + + private val profileHandler = object : CreateProfile.Handler { + override suspend fun createProfile(request: CreateProfile.Request): CreateProfile.Response<*> { + return CreateProfile.Response201( + body = ProfileDto( + profileId = "profile-1", + userId = request.body.userId, + address = request.body.address, + phoneNumber = request.body.phoneNumber + ) + ) + } + } + + private val accountHandler = object : CreateAccount.Handler { + override suspend fun createAccount(request: CreateAccount.Request): CreateAccount.Response<*> { + return CreateAccount.Response201( + body = AccountDto( + accountId = "account-1", + userId = request.body.userId, + profileId = request.body.profileId, + iban = "NL00INGB0001234567", + accountType = request.body.accountType, + currency = request.body.currency + ) + ) + } + } + + @Test + fun `should orchestrate full current account creation`() = runBlocking { + val baker = InMemoryBaker.kotlin( + implementations = listOf( + CreateUserInteractionImpl(userHandler), + CreateProfileInteractionImpl(profileHandler), + CreateAccountInteractionImpl(accountHandler) + ) + ) + + val recipeId = baker.addRecipe( + compiledRecipe = RecipeCompiler.compileRecipe(CreateCurrentAccountRecipe.recipe), + validate = true + ) + + val recipeInstanceId = UUID.randomUUID().toString() + baker.bake(recipeId, recipeInstanceId) + + val sensoryEvent = EventInstance.from( + CreateCurrentAccountEvent( + firstName = "John", + lastName = "Doe", + email = "john.doe@example.com", + dateOfBirth = "1990-01-01", + address = "123 Main Street, Amsterdam", + phoneNumber = "+31612345678", + accountType = "CURRENT", + currency = "EUR" + ) + ) + + baker.fireSensoryEventAndAwaitReceived(recipeInstanceId, sensoryEvent) + baker.awaitCompleted(recipeInstanceId, timeout = 10.seconds) + + val state = baker.getRecipeInstanceState(recipeInstanceId) + val eventNames = state.events.map { it.name } + + assertTrue(eventNames.contains("CreateCurrentAccountEvent"), "Should have received sensory event") + assertTrue(eventNames.contains("CreateUserResponse201"), "Should have created user") + assertTrue(eventNames.contains("CreateProfileResponse201"), "Should have created profile") + assertTrue(eventNames.contains("CreateAccountResponse201"), "Should have created account") + } +} diff --git a/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountWireMockTest.kt b/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountWireMockTest.kt new file mode 100644 index 000000000..4e10061f0 --- /dev/null +++ b/examples/baker-wirespec-example/src/test/kotlin/com/ing/baker/examples/account/CreateCurrentAccountWireMockTest.kt @@ -0,0 +1,154 @@ +package com.ing.baker.examples.account + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.examples.account.generated.* +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.runtime.javadsl.EventInstance +import com.ing.baker.runtime.kotlindsl.InMemoryBaker +import community.flock.wirespec.integration.jackson.kotlin.WirespecSerialization +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@ExperimentalDsl +class CreateCurrentAccountWireMockTest { + + private lateinit var wireMockServer: WireMockServer + private lateinit var transport: Transportation + + private val objectMapper = ObjectMapper().registerKotlinModule() + private val serialization = WirespecSerialization(objectMapper) + + @BeforeEach + fun setUp() { + wireMockServer = WireMockServer(wireMockConfig().dynamicPort()) + wireMockServer.start() + transport = javaHttpTransportation("http://localhost:${wireMockServer.port()}") + } + + @AfterEach + fun tearDown() { + wireMockServer.stop() + } + + @Test + fun `should orchestrate full current account creation via HTTP`() = runBlocking { + // Stub POST /users + wireMockServer.stubFor( + post(urlEqualTo("/users")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + objectMapper.writeValueAsString( + mapOf( + "id" to "user-1", + "firstName" to "John", + "lastName" to "Doe", + "email" to "john.doe@example.com", + "dateOfBirth" to "1990-01-01" + ) + ) + ) + ) + ) + + // Stub POST /profiles + wireMockServer.stubFor( + post(urlEqualTo("/profiles")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + objectMapper.writeValueAsString( + mapOf( + "profileId" to "profile-1", + "userId" to "user-1", + "address" to "123 Main Street, Amsterdam", + "phoneNumber" to "+31612345678" + ) + ) + ) + ) + ) + + // Stub POST /accounts + wireMockServer.stubFor( + post(urlEqualTo("/accounts")) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody( + objectMapper.writeValueAsString( + mapOf( + "accountId" to "account-1", + "userId" to "user-1", + "profileId" to "profile-1", + "iban" to "NL00INGB0001234567", + "accountType" to "CURRENT", + "currency" to "EUR" + ) + ) + ) + ) + ) + + val baker = InMemoryBaker.kotlin( + implementations = listOf( + CreateUserInteractionImpl(createUserHandler(transport, serialization)), + CreateProfileInteractionImpl(createProfileHandler(transport, serialization)), + CreateAccountInteractionImpl(createAccountHandler(transport, serialization)) + ) + ) + + val recipeId = baker.addRecipe( + compiledRecipe = RecipeCompiler.compileRecipe(CreateCurrentAccountRecipe.recipe), + validate = true + ) + + val recipeInstanceId = UUID.randomUUID().toString() + baker.bake(recipeId, recipeInstanceId) + + val sensoryEvent = EventInstance.from( + CreateCurrentAccountEvent( + firstName = "John", + lastName = "Doe", + email = "john.doe@example.com", + dateOfBirth = "1990-01-01", + address = "123 Main Street, Amsterdam", + phoneNumber = "+31612345678", + accountType = "CURRENT", + currency = "EUR" + ) + ) + + baker.fireSensoryEventAndAwaitReceived(recipeInstanceId, sensoryEvent) + baker.awaitCompleted(recipeInstanceId, timeout = 10.seconds) + + val state = baker.getRecipeInstanceState(recipeInstanceId) + val eventNames = state.events.map { it.name } + + assertTrue(eventNames.contains("CreateCurrentAccountEvent"), "Should have received sensory event") + assertTrue(eventNames.contains("CreateUserResponse201"), "Should have created user") + assertTrue(eventNames.contains("CreateProfileResponse201"), "Should have created profile") + assertTrue(eventNames.contains("CreateAccountResponse201"), "Should have created account") + assertTrue(eventNames.contains("Finish"), "Should have fired Finish event after all APIs succeeded") + + // Verify HTTP requests were made + wireMockServer.verify(postRequestedFor(urlEqualTo("/users"))) + wireMockServer.verify(postRequestedFor(urlEqualTo("/profiles"))) + wireMockServer.verify(postRequestedFor(urlEqualTo("/accounts"))) + } +} diff --git a/pom.xml b/pom.xml index 9749008b7..3108e247d 100644 --- a/pom.xml +++ b/pom.xml @@ -126,11 +126,16 @@ bakery/bakery-interaction bakery/bakery-interaction-protocol + core/baker-wirespec + core/baker-openapi + examples/baker-example examples/bakery-client-example examples/bakery-kafka-listener-example examples/docs-code-snippets + examples/baker-wirespec-example + examples/baker-openapi-example From 8572e1961c11f801dfe598db55d69c01613cba31 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 22 May 2026 20:55:58 +0200 Subject: [PATCH 02/11] refactor: rename baker-openapi-transportation -> baker-openapi-wirespec The module is the wirespec-integration runtime for baker-openapi (it bridges Wirespec.RawRequest/RawResponse with a default HTTP client and will host the default handler-building behaviour next), so naming it after wirespec rather than 'transportation' reflects what it owns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pom.xml | 6 +++--- .../com/ing/baker/openapi/wirespec}/Transportation.kt | 2 +- core/baker-openapi/pom.xml | 2 +- examples/baker-openapi-example/pom.xml | 2 +- .../examples/account/openapi/AccountRecipeWireMockTest.kt | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename core/baker-openapi/{baker-openapi-transportation => baker-openapi-wirespec}/pom.xml (86%) rename core/baker-openapi/{baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation => baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec}/Transportation.kt (97%) diff --git a/core/baker-openapi/baker-openapi-transportation/pom.xml b/core/baker-openapi/baker-openapi-wirespec/pom.xml similarity index 86% rename from core/baker-openapi/baker-openapi-transportation/pom.xml rename to core/baker-openapi/baker-openapi-wirespec/pom.xml index 6669517b5..e0d27cfad 100644 --- a/core/baker-openapi/baker-openapi-transportation/pom.xml +++ b/core/baker-openapi/baker-openapi-wirespec/pom.xml @@ -11,9 +11,9 @@ ../pom.xml - baker-openapi-transportation - Baker OpenAPI Transportation - Default HTTP transport (java.net.http.HttpClient) bridging wirespec RawRequest/RawResponse + baker-openapi-wirespec + Baker OpenAPI Wirespec + Wirespec-integration runtime for baker-openapi: default HTTP transport (java.net.http.HttpClient) bridging Wirespec.RawRequest/RawResponse diff --git a/core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt b/core/baker-openapi/baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt similarity index 97% rename from core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt rename to core/baker-openapi/baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt index 0aa9527d3..375a9bd00 100644 --- a/core/baker-openapi/baker-openapi-transportation/src/main/kotlin/com/ing/baker/openapi/transportation/Transportation.kt +++ b/core/baker-openapi/baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt @@ -1,4 +1,4 @@ -package com.ing.baker.openapi.transportation +package com.ing.baker.openapi.wirespec import community.flock.wirespec.kotlin.Wirespec import java.net.URI diff --git a/core/baker-openapi/pom.xml b/core/baker-openapi/pom.xml index 7c8b73eef..8dbe1e169 100644 --- a/core/baker-openapi/pom.xml +++ b/core/baker-openapi/pom.xml @@ -23,6 +23,6 @@ baker-openapi-dsl baker-openapi-emitter baker-openapi-plugin - baker-openapi-transportation + baker-openapi-wirespec diff --git a/examples/baker-openapi-example/pom.xml b/examples/baker-openapi-example/pom.xml index bffac08ac..ff0d1b817 100644 --- a/examples/baker-openapi-example/pom.xml +++ b/examples/baker-openapi-example/pom.xml @@ -24,7 +24,7 @@ com.ing.baker - baker-openapi-transportation + baker-openapi-wirespec ${project.version} diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index 61669e020..209f129f1 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -11,8 +11,8 @@ import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import com.ing.baker.compiler.RecipeCompiler import com.ing.baker.examples.account.openapi.generated.api.CreateAccount import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint -import com.ing.baker.openapi.transportation.Transportation -import com.ing.baker.openapi.transportation.javaHttpTransportation +import com.ing.baker.openapi.wirespec.Transportation +import com.ing.baker.openapi.wirespec.javaHttpTransportation import com.ing.baker.recipe.kotlindsl.ExperimentalDsl import com.ing.baker.runtime.javadsl.EventInstance import com.ing.baker.runtime.kotlindsl.InMemoryBaker From fc5eaec1299206b01825c963a483a9ddf8411415 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 22 May 2026 21:03:25 +0200 Subject: [PATCH 03/11] feat: default handler implementation - no per-operation registration Adds ApiOperation.buildHandler(transport, serialization). Each generated descriptor knows how to construct its own wirespec Handler from a generic transport + serialization pair, so users no longer need to write a handler factory per operation. ApiRecipe.toInteractionInstances(transport, serialization) builds the full implementation list with one call. An optional overrides map is kept for tests that need a custom handler for a specific operation. The example test drops the createAccountHandler() factory entirely: val baker = InMemoryBaker.kotlin( implementations = AccountRecipe.apiRecipe.toInteractionInstances( transport = transport, serialization = serialization, ), ) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ing/baker/openapi/dsl/ApiOperation.kt | 11 +++++++ .../com/ing/baker/openapi/dsl/ApiRecipe.kt | 30 ++++++++++++++----- .../com/ing/baker/openapi/dsl/ApiDslTest.kt | 4 +++ .../openapi/dsl/ApiOperationBindingTest.kt | 4 +++ .../dsl/ApiOperationInteractionTest.kt | 4 +++ .../openapi/emitter/BakerOpenApiEmitter.java | 18 ++++++++++- .../openapi/AccountRecipeWireMockTest.kt | 14 ++------- 7 files changed, 65 insertions(+), 20 deletions(-) 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 index d6530d272..ee2874a86 100644 --- 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 @@ -36,4 +36,15 @@ interface ApiOperation { /** 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/ApiRecipe.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt index d9e7bce37..2bb83dd38 100644 --- 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 @@ -16,15 +16,31 @@ class ApiRecipe internal constructor( internal val mappersByOperation: Map) -> Any>>>, ) { /** - * Builds an [InteractionInstance] for every operation declared in the recipe by - * pairing each operation with its handler from [handlers]. Throws if any - * operation lacks a handler. + * 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(handlers: Map): List = + fun toInteractionInstances( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): List = mappersByOperation.values.map { (op, mappers) -> - val handler = handlers[op] - ?: error("No handler provided for operation '${op.operationName}'. " + - "Pass it via handlers = mapOf(${op.operationName} to )") + val handler = op.buildHandler(transport, serialization) + ApiOperationBinding(op, handler, mappers).toInteractionInstance() + } + + /** + * Overload for callers who want to supply a handler explicitly per operation + * (e.g. tests with custom fakes). Operations not in [handlers] 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, + ): List = + mappersByOperation.values.map { (op, mappers) -> + val handler = overrides[op] ?: op.buildHandler(transport, serialization) ApiOperationBinding(op, handler, mappers).toInteractionInstance() } } 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 index a83505220..ffe89f0e0 100644 --- 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 @@ -35,6 +35,10 @@ private object CreateUser : ApiOperation { override fun buildRequest(ingredients: Map): Any = ingredients 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) 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 index 8adb08677..7403948ea 100644 --- 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 @@ -20,6 +20,10 @@ private object StubOp : ApiOperation { override fun buildRequest(ingredients: Map): 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 { 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 index 9d8b35966..1a829c099 100644 --- 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 @@ -43,6 +43,10 @@ private class FakeOperation( return ingredients } override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = nextResponse + override fun buildHandler( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): Wirespec.Handler = FakeHandler() } private val emptyScalaMetadata: scala.collection.immutable.Map = diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java index a0006ffde..63b8f20f0 100644 --- a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java +++ b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java @@ -131,7 +131,23 @@ public String emit(@NotNull Endpoint endpoint) { String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n"); sb.append(" (handler as ").append(name).append(".Handler).").append(handlerMethod) - .append("(request as ").append(name).append(".Request)\n"); + .append("(request as ").append(name).append(".Request)\n\n"); + + // buildHandler — wraps the operation's wirespec ClientEdge in a Handler that + // routes through the supplied transport. Lets callers register no per-operation + // factories — only (transport, serialization) at startup. + sb.append(" override fun buildHandler(\n"); + sb.append(" transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,\n"); + sb.append(" serialization: Wirespec.Serialization,\n"); + sb.append(" ): Wirespec.Handler {\n"); + sb.append(" val edge = ").append(name).append(".Handler.client(serialization)\n"); + sb.append(" return object : ").append(name).append(".Handler {\n"); + sb.append(" override suspend fun ").append(handlerMethod) + .append("(request: ").append(name).append(".Request): ") + .append(name).append(".Response<*> =\n"); + sb.append(" edge.from(transport(edge.to(request)))\n"); + sb.append(" }\n"); + sb.append(" }\n"); sb.append("}\n"); return sb.toString(); diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index 209f129f1..8bad2d6d9 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -9,8 +9,6 @@ import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import com.ing.baker.compiler.RecipeCompiler -import com.ing.baker.examples.account.openapi.generated.api.CreateAccount -import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint import com.ing.baker.openapi.wirespec.Transportation import com.ing.baker.openapi.wirespec.javaHttpTransportation import com.ing.baker.recipe.kotlindsl.ExperimentalDsl @@ -39,15 +37,6 @@ class AccountRecipeWireMockTest { } @AfterEach fun tearDown() { server.stop() } - private fun createAccountHandler(): CreateAccountEndpoint.Handler { - val edge = CreateAccountEndpoint.Handler.client(serialization) - return object : CreateAccountEndpoint.Handler { - override suspend fun createAccount( - request: CreateAccountEndpoint.Request - ): CreateAccountEndpoint.Response<*> = edge.from(transport(edge.to(request))) - } - } - @Test fun `recipe fires AccountCreated on 201`() = runBlocking { server.stubFor( @@ -62,7 +51,8 @@ class AccountRecipeWireMockTest { val baker = InMemoryBaker.kotlin( implementations = AccountRecipe.apiRecipe.toInteractionInstances( - handlers = mapOf(CreateAccount to createAccountHandler()), + transport = transport, + serialization = serialization, ), ) val recipeId = baker.addRecipe( From 358cb606098864f2bc66be7a32d1c492954b5ad5 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 22 May 2026 21:56:09 +0200 Subject: [PATCH 04/11] fix: respect ingredientNameOverrides at runtime + demo in example Baker passes IngredientInstances using their recipe-side names after applying ingredientNameOverrides at compile time, but ApiOperation.buildRequest expects to look up values by their API field names. Thread the override map through ApiInteractionConfig -> ApiOperationBinding -> ApiOperationInteraction; the interaction reverses the map to translate recipe names back to API names before delegating to buildRequest. DSL change: ingredientNameOverrides now uses an 'apiField to ingredient' infix builder matching the upstream Baker style. The example renames CreateAccountCommand.userId -> customerId and adds an override block, making the mapping (and the rule that the request DTO is never an ingredient) visible. The WireMock test verifies the wire-format request body has userId=u1, proving the override actually wired the value through. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ing/baker/openapi/dsl/ApiDsl.kt | 34 +++++++++++++++++-- .../baker/openapi/dsl/ApiOperationBinding.kt | 6 +++- .../openapi/dsl/ApiOperationInteraction.kt | 14 +++++++- .../com/ing/baker/openapi/dsl/ApiRecipe.kt | 34 +++++++++---------- .../examples/account/openapi/AccountRecipe.kt | 16 +++++++++ .../baker/examples/account/openapi/Events.kt | 5 ++- .../openapi/AccountRecipeWireMockTest.kt | 16 +++++++-- 7 files changed, 100 insertions(+), 25 deletions(-) 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 index 4476effd2..b301e31af 100644 --- 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 @@ -25,9 +25,18 @@ fun RecipeBuilder.api( ) { val scope = ApiInteractionScope(operation).apply(configure) addInteraction(scope.buildInteraction()) - apiMappersCollector.get()?.put(operation.operationName, operation to scope.configuredMappers) + apiInteractionConfigCollector.get()?.put( + operation.operationName, + ApiInteractionConfig(operation, scope.configuredMappers, scope.configuredNameOverrides), + ) } +internal data class ApiInteractionConfig( + val operation: ApiOperation, + val mappers: Map) -> Any>, + val nameOverrides: Map, +) + @ApiDslMarker class ApiInteractionScope internal constructor(private val operation: ApiOperation) { @@ -62,13 +71,25 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati maxInteractionCount = n } - fun ingredientNameOverrides(block: MutableMap.() -> Unit) { - ingredientNameOverridesMap.apply(block) + /** + * 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) } /** 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() + internal fun buildInteraction(): Interaction { val inputIngredients: Set = operation.inputFields .map { Ingredient(it.name, it.type.java) } @@ -93,6 +114,13 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati } } +@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) { 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 index 8688175e9..885830e19 100644 --- 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 @@ -9,12 +9,16 @@ import community.flock.wirespec.kotlin.Wirespec * [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]. */ class ApiOperationBinding( private val operation: ApiOperation, private val handler: Wirespec.Handler, private val mappers: Map) -> Any>, + private val nameOverrides: Map = emptyMap(), ) { fun toInteractionInstance(): InteractionInstance = - ApiOperationInteraction(operation, handler, mappers) + ApiOperationInteraction(operation, handler, mappers, nameOverrides) } 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 index 89ec5ce57..bdd2e8118 100644 --- 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 @@ -17,8 +17,17 @@ 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. At runtime Baker passes + * IngredientInstances using the recipe-side names; we reverse the map to + * present the API-named map to [ApiOperation.buildRequest]. + */ + private val nameOverrides: kotlin.collections.Map = emptyMap(), ) : InteractionInstance() { + private val recipeToApi: kotlin.collections.Map = + nameOverrides.entries.associate { (api, recipe) -> recipe to api } + override fun name(): String = operation.operationName override fun input(): MutableList = @@ -43,7 +52,10 @@ class ApiOperationInteraction( override fun run(input: MutableList): CompletableFuture> { return try { val ingredientMap: kotlin.collections.Map = - input.associate { it.name to it.value.`as`(operation.inputFieldType(it.name)) } + input.associate { instance -> + val apiName = recipeToApi[instance.name] ?: instance.name + apiName to instance.value.`as`(operation.inputFieldType(apiName)) + } val request = operation.buildRequest(ingredientMap) val response = runBlocking { operation.invoke(handler, request) } val mapper = mappers[response.status] 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 index 2bb83dd38..b64dfe9ce 100644 --- 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 @@ -7,13 +7,13 @@ import com.ing.baker.runtime.javadsl.InteractionInstance import community.flock.wirespec.kotlin.Wirespec /** - * A [Recipe] paired with the response mappers configured by `api(...) { on(...) { ... } }` - * blocks. The mappers are the recipe's responsibility — startup code only needs to - * supply the underlying wirespec handlers. + * 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 mappersByOperation: Map) -> Any>>>, + internal val configsByOperation: Map, ) { /** * Builds an [InteractionInstance] for every API operation in the recipe using @@ -24,14 +24,14 @@ class ApiRecipe internal constructor( transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, serialization: Wirespec.Serialization, ): List = - mappersByOperation.values.map { (op, mappers) -> - val handler = op.buildHandler(transport, serialization) - ApiOperationBinding(op, handler, mappers).toInteractionInstance() + configsByOperation.values.map { cfg -> + val handler = cfg.operation.buildHandler(transport, serialization) + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides).toInteractionInstance() } /** * Overload for callers who want to supply a handler explicitly per operation - * (e.g. tests with custom fakes). Operations not in [handlers] fall back to + * (e.g. tests with custom fakes). Operations not in [overrides] fall back to * the descriptor's default handler built from (transport, serialization). */ fun toInteractionInstances( @@ -39,28 +39,28 @@ class ApiRecipe internal constructor( serialization: Wirespec.Serialization, overrides: Map, ): List = - mappersByOperation.values.map { (op, mappers) -> - val handler = overrides[op] ?: op.buildHandler(transport, serialization) - ApiOperationBinding(op, handler, mappers).toInteractionInstance() + configsByOperation.values.map { cfg -> + val handler = overrides[cfg.operation] ?: cfg.operation.buildHandler(transport, serialization) + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides).toInteractionInstance() } } /** * Builds an [ApiRecipe] — same shape as `recipe(name) { ... }` but the returned - * wrapper carries the response mappers configured inside `api(...)` blocks so the - * runtime can construct interaction instances without re-declaring them. + * 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) -> Any>>>() - apiMappersCollector.set(collector) + val collector = mutableMapOf() + apiInteractionConfigCollector.set(collector) try { val recipe = com.ing.baker.recipe.kotlindsl.recipe(name, configure) return ApiRecipe(recipe, collector.toMap()) } finally { - apiMappersCollector.remove() + apiInteractionConfigCollector.remove() } } -internal val apiMappersCollector: ThreadLocal) -> Any>>>?> = +internal val apiInteractionConfigCollector: ThreadLocal?> = ThreadLocal.withInitial { null } diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt index 1139bbdf5..31b2f4640 100644 --- a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -12,6 +12,22 @@ object AccountRecipe { sensoryEvents { event() } api(CreateAccount) { + // The wirespec CreateAccountRequest DTO is never an ingredient — only + // flat values (path / query / header / flattened body fields) flow + // through Baker. Each is matched to a recipe ingredient by name: + // + // API field → ingredient + // profileId → profileId (auto, name matches) + // accountType → accountType (auto) + // currency → currency (auto) + // userId → customerId (override declared below) + // + // The descriptor's buildRequest reassembles the DTO inside the + // generic interaction implementation — out of sight from the recipe. + ingredientNameOverrides { + "userId" to "customerId" + } + on(201) { resp -> AccountCreated(accountId = resp.body.accountId, iban = resp.body.iban) } diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt index ebe6d5bf2..3244cab98 100644 --- a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt @@ -1,7 +1,10 @@ package com.ing.baker.examples.account.openapi +// Domain-level command. Note `customerId` — the API contract calls it `userId`, +// so the recipe declares an ingredientNameOverride to bridge the two. The other +// fields share names with the API contract and are auto-wired by Baker. data class CreateAccountCommand( - val userId: String, + val customerId: String, val profileId: String, val accountType: String, val currency: String, diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index 8bad2d6d9..3c8d08c2c 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -63,12 +63,24 @@ class AccountRecipeWireMockTest { baker.bake(recipeId, rid) baker.fireSensoryEventAndAwaitReceived( rid, - EventInstance.from(CreateAccountCommand("u1", "p1", "CURRENT", "EUR")), + EventInstance.from( + CreateAccountCommand( + customerId = "u1", + profileId = "p1", + accountType = "CURRENT", + currency = "EUR", + ) + ), ) baker.awaitCompleted(rid, timeout = 10.seconds) val events = baker.getRecipeInstanceState(rid).events.map { it.name } assertTrue(events.contains("AccountCreated"), "events were: $events") - server.verify(postRequestedFor(urlEqualTo("/accounts"))) + // The customerId ingredient was renamed to userId for the API call — + // proves ingredientNameOverrides wired the value through. + server.verify( + postRequestedFor(urlEqualTo("/accounts")) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath("$.userId", com.github.tomakehurst.wiremock.client.WireMock.equalTo("u1"))) + ) } } From 8ab32bb6b99f413c2e2c302ee886caad4aef8aaf Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 22 May 2026 22:00:34 +0200 Subject: [PATCH 05/11] feat: add inputFrom { ... } for declaring API input events An API operation can now declare its canonical input event with the symmetric counterpart to on(N) { ... }: api(CreateAccount) { inputFrom { "userId" from "customerId" // API field <- event field } on(201) { resp -> ... } } The block adds the event as a required precondition and registers the api-field-name <- event-field-name overrides in one shot. The wirespec request DTO still never appears in the recipe; only the domain event does. Equivalent to requires(T::class) + ingredientNameOverrides { ... } with clearer intent and inverted-arrow infix matching how the input side reads ('userId comes from customerId'). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ing/baker/openapi/dsl/ApiDsl.kt | 35 +++++++++++++++++-- .../examples/account/openapi/AccountRecipe.kt | 21 ++++------- 2 files changed, 40 insertions(+), 16 deletions(-) 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 index b301e31af..1eafee307 100644 --- 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 @@ -46,8 +46,12 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati @PublishedApi internal val outputEventClasses = mutableSetOf>() - private val requiredEvents = mutableSetOf() - private val ingredientNameOverridesMap = mutableMapOf() + @PublishedApi + internal val requiredEvents = mutableSetOf() + + @PublishedApi + internal val ingredientNameOverridesMap = mutableMapOf() + private var maxInteractionCount: Int? = null /** @@ -84,6 +88,26 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati ingredientNameOverridesMap.putAll(scope.entries) } + /** + * Declares that [T] is the canonical input event for this API operation — + * its constructor fields populate the API request. The block lets the + * recipe author rename fields where event names don't match API names: + * + * inputFrom { + * "userId" from "customerId" // API field ← event field + * } + * + * Equivalent to `requires(T::class) + ingredientNameOverrides { ... }` with + * the inverted-arrow `from` infix making the intent clearer (read as: + * "the API's userId comes from the event's customerId field"). The wirespec + * request DTO itself never appears in the recipe — only the event does. + */ + inline fun inputFrom(noinline configure: InputFromScope.() -> Unit = {}) { + requiredEvents.add(T::class.simpleName!!) + val scope = InputFromScope().apply(configure) + ingredientNameOverridesMap.putAll(scope.entries) + } + /** Read-only view of configured mappers, useful for app-startup binding. */ val configuredMappers: Map) -> Any> get() = mappers.toMap() @@ -121,6 +145,13 @@ class IngredientNameOverridesScope internal constructor() { infix fun String.to(other: String) { entries[this] = other } } +@ApiDslMarker +class InputFromScope { + @PublishedApi internal val entries = mutableMapOf() + /** Reads as: API field [receiver] comes from event field [eventField]. */ + infix fun String.from(eventField: String) { entries[this] = eventField } +} + private fun KClass<*>.toEvent(): Event { val ctor = primaryConstructor val ingredients: List = if (ctor != null) { diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt index 31b2f4640..c21d17f64 100644 --- a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -12,20 +12,13 @@ object AccountRecipe { sensoryEvents { event() } api(CreateAccount) { - // The wirespec CreateAccountRequest DTO is never an ingredient — only - // flat values (path / query / header / flattened body fields) flow - // through Baker. Each is matched to a recipe ingredient by name: - // - // API field → ingredient - // profileId → profileId (auto, name matches) - // accountType → accountType (auto) - // currency → currency (auto) - // userId → customerId (override declared below) - // - // The descriptor's buildRequest reassembles the DTO inside the - // generic interaction implementation — out of sight from the recipe. - ingredientNameOverrides { - "userId" to "customerId" + // CreateAccountCommand is this API's input event — its fields populate + // the API request. The wirespec CreateAccountRequest DTO never appears + // in the recipe; only the domain event does. Fields match by name + // (profileId, accountType, currency); the one mismatch is declared + // with the symmetric "apiField from eventField" syntax. + inputFrom { + "userId" from "customerId" } on(201) { resp -> From 35dad32d304e34ef54b837af4425ee02e90264a7 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Fri, 22 May 2026 22:18:53 +0200 Subject: [PATCH 06/11] feat: typed inputFrom(mapper) symmetric to on(N) { ... } MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe author now declares the input mapping with the same shape as the response mappings — two type parameters and a lambda: inputFrom { cmd -> CreateAccountRequest( userId = cmd.customerId, profileId = cmd.profileId, ... ) } The wirespec request DTO appears only inside the mapping lambda body — never as a Baker ingredient. At runtime, ApiOperationInteraction reconstructs the input event from Baker ingredients (E's primary constructor parameters), applies the user mapper to get the body DTO, and lets the descriptor wrap it via the new buildRequestFromBody. Mechanically: - ApiOperation: + buildRequestFromBody(body): Any - emitter: generates buildRequestFromBody for body-only operations; operations with path/query/header params reject the typed flow with a clear error (deferred to a later iteration) - DSL: inline inputFrom(noinline mapper); the prior string-rename overload is dropped (use ingredientNameOverrides if you really need flat ingredients) - runtime: when inputMapper is set, interaction's declared ingredients are E's ctor parameters and Baker fills them from the fired E event; the mapper bridges to the wirespec body, descriptor wraps to Request Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ing/baker/openapi/dsl/ApiDsl.kt | 73 +++++++++++++------ .../com/ing/baker/openapi/dsl/ApiOperation.kt | 13 +++- .../baker/openapi/dsl/ApiOperationBinding.kt | 10 ++- .../openapi/dsl/ApiOperationInteraction.kt | 72 +++++++++++++----- .../com/ing/baker/openapi/dsl/ApiRecipe.kt | 4 +- .../com/ing/baker/openapi/dsl/ApiDslTest.kt | 1 + .../openapi/dsl/ApiOperationBindingTest.kt | 1 + .../dsl/ApiOperationInteractionTest.kt | 1 + .../openapi/emitter/BakerOpenApiEmitter.java | 15 ++++ .../examples/account/openapi/AccountRecipe.kt | 20 +++-- 10 files changed, 159 insertions(+), 51 deletions(-) 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 index 1eafee307..b2515985d 100644 --- 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 @@ -27,7 +27,13 @@ fun RecipeBuilder.api( addInteraction(scope.buildInteraction()) apiInteractionConfigCollector.get()?.put( operation.operationName, - ApiInteractionConfig(operation, scope.configuredMappers, scope.configuredNameOverrides), + ApiInteractionConfig( + operation = operation, + mappers = scope.configuredMappers, + nameOverrides = scope.configuredNameOverrides, + inputEventClass = scope.configuredInputEventClass, + inputMapper = scope.configuredInputMapper, + ), ) } @@ -35,6 +41,10 @@ internal data class ApiInteractionConfig( val operation: ApiOperation, val mappers: Map) -> Any>, val nameOverrides: Map, + /** Set when the recipe declared `inputFrom(mapper)`. */ + val inputEventClass: KClass<*>? = null, + /** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */ + val inputMapper: ((Any) -> Any)? = null, ) @ApiDslMarker @@ -52,6 +62,12 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati @PublishedApi internal val ingredientNameOverridesMap = mutableMapOf() + @PublishedApi + internal var inputEventClass: KClass<*>? = null + + @PublishedApi + internal var inputMapper: ((Any) -> Any)? = null + private var maxInteractionCount: Int? = null /** @@ -89,23 +105,29 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati } /** - * Declares that [T] is the canonical input event for this API operation — - * its constructor fields populate the API request. The block lets the - * recipe author rename fields where event names don't match API names: + * Typed input mapping — symmetric to the response side's `on(N) { ... }`. + * Declares that event [E] populates the API request body [R] via the given + * lambda: * - * inputFrom { - * "userId" from "customerId" // API field ← event field + * inputFrom { cmd -> + * CreateAccountRequest( + * userId = cmd.customerId, + * profileId = cmd.profileId, + * ... + * ) * } * - * Equivalent to `requires(T::class) + ingredientNameOverrides { ... }` with - * the inverted-arrow `from` infix making the intent clearer (read as: - * "the API's userId comes from the event's customerId field"). The wirespec - * request DTO itself never appears in the recipe — only the event does. + * 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 configure: InputFromScope.() -> Unit = {}) { - requiredEvents.add(T::class.simpleName!!) - val scope = InputFromScope().apply(configure) - ingredientNameOverridesMap.putAll(scope.entries) + inline fun inputFrom(noinline mapper: (E) -> R) { + 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. */ @@ -114,8 +136,22 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati /** 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 { - val inputIngredients: Set = operation.inputFields + // 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 @@ -145,13 +181,6 @@ class IngredientNameOverridesScope internal constructor() { infix fun String.to(other: String) { entries[this] = other } } -@ApiDslMarker -class InputFromScope { - @PublishedApi internal val entries = mutableMapOf() - /** Reads as: API field [receiver] comes from event field [eventField]. */ - infix fun String.from(eventField: String) { entries[this] = eventField } -} - private fun KClass<*>.toEvent(): Event { val ctor = primaryConstructor val ingredients: List = if (ctor != null) { 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 index ee2874a86..36b5659f5 100644 --- 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 @@ -31,9 +31,20 @@ interface ApiOperation { /** The wirespec handler class this operation expects. The plugin generates this. */ val handlerClass: KClass - /** Builds the wirespec Request from a name → value ingredient map. */ + /** + * 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 the body + * DTO and the descriptor knows how to put it into the Endpoint.Request. + */ + fun buildRequestFromBody(body: Any): 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<*> 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 index 885830e19..15ad981f1 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -12,13 +13,20 @@ import community.flock.wirespec.kotlin.Wirespec * [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) + 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 index bdd2e8118..c26d721b9 100644 --- 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 @@ -10,6 +10,9 @@ 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 @@ -18,27 +21,42 @@ class ApiOperationInteraction( private val handler: Wirespec.Handler, private val mappers: kotlin.collections.Map, /** - * Map from API field name → recipe ingredient name. At runtime Baker passes - * IngredientInstances using the recipe-side names; we reverse the map to - * present the API-named map to [ApiOperation.buildRequest]. + * 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 } - override fun name(): String = operation.operationName - - override fun input(): MutableList = - operation.inputFields - .map { field -> + /** + * 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(field.name), - Converters.readJavaType(field.type.java), + Optional.of(p.name!!), + Converters.readJavaType(p.type.javaType), ) - } - .toMutableList() + } ?: 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, @@ -51,12 +69,18 @@ class ApiOperationInteraction( override fun run(input: MutableList): CompletableFuture> { return try { - val ingredientMap: kotlin.collections.Map = - input.associate { instance -> - val apiName = recipeToApi[instance.name] ?: instance.name - apiName to instance.value.`as`(operation.inputFieldType(apiName)) - } - val request = operation.buildRequest(ingredientMap) + val request: Any = if (inputEventClass != null && inputMapper != null) { + val event = reconstructInputEvent(inputEventClass, input) + val body = inputMapper.invoke(event) + operation.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}") @@ -68,5 +92,17 @@ class ApiOperationInteraction( } } +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 index b64dfe9ce..c1da3c27b 100644 --- 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 @@ -26,7 +26,7 @@ class ApiRecipe internal constructor( ): List = configsByOperation.values.map { cfg -> val handler = cfg.operation.buildHandler(transport, serialization) - ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides).toInteractionInstance() + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance() } /** @@ -41,7 +41,7 @@ class ApiRecipe internal constructor( ): List = configsByOperation.values.map { cfg -> val handler = overrides[cfg.operation] ?: cfg.operation.buildHandler(transport, serialization) - ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides).toInteractionInstance() + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance() } } 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 index ffe89f0e0..21b8f6343 100644 --- 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 @@ -33,6 +33,7 @@ private object CreateUser : ApiOperation { ) override val handlerClass = DslFakeHandler::class override fun buildRequest(ingredients: Map): Any = ingredients + override fun buildRequestFromBody(body: Any): Any = body override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = DslFakeResponse(201) override fun buildHandler( 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 index 7403948ea..c453806d8 100644 --- 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 @@ -18,6 +18,7 @@ private object StubOp : ApiOperation { 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: Any): Any = Unit override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = BindingStubResponse(200) override fun buildHandler( 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 index 1a829c099..9f6f3e99a 100644 --- 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 @@ -42,6 +42,7 @@ private class FakeOperation( capturedRequest = ingredients return ingredients } + override fun buildRequestFromBody(body: Any): Any = body override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = nextResponse override fun buildHandler( transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java index 63b8f20f0..017df37ae 100644 --- a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java +++ b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java @@ -127,6 +127,21 @@ public String emit(@NotNull Endpoint endpoint) { sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); sb.append(" )\n\n"); + // buildRequestFromBody — used by the typed inputFrom(mapper) DSL. + // The user-provided lambda returns the body DTO; this wraps it in the + // Endpoint.Request envelope. Body-only operations have a trivial wrapper; + // operations with path/query/header params reject the typed flow in v1. + boolean bodyOnly = bodyTypeName != null + && endpoint.getQueries().isEmpty() + && endpoint.getHeaders().isEmpty() + && endpoint.getPath().stream().noneMatch(s -> s instanceof Endpoint.Segment.Param); + sb.append(" override fun buildRequestFromBody(body: Any): Any =\n"); + if (bodyOnly) { + sb.append(" ").append(name).append(".Request(body as ").append(bodyTypeName).append(")\n\n"); + } else { + sb.append(" error(\"inputFrom(mapper) is not supported for operations with path/query/header params; use ingredientNameOverrides with the flat flow instead.\")\n\n"); + } + // invoke String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n"); diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt index c21d17f64..cbdc53c13 100644 --- a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -5,6 +5,7 @@ import com.ing.baker.openapi.dsl.apiRecipe import com.ing.baker.recipe.kotlindsl.ExperimentalDsl import com.ing.baker.examples.account.openapi.generated.api.CreateAccount import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint +import com.ing.baker.examples.account.openapi.generated.model.CreateAccountRequest @OptIn(ExperimentalDsl::class) object AccountRecipe { @@ -12,13 +13,18 @@ object AccountRecipe { sensoryEvents { event() } api(CreateAccount) { - // CreateAccountCommand is this API's input event — its fields populate - // the API request. The wirespec CreateAccountRequest DTO never appears - // in the recipe; only the domain event does. Fields match by name - // (profileId, accountType, currency); the one mismatch is declared - // with the symmetric "apiField from eventField" syntax. - inputFrom { - "userId" from "customerId" + // The DSL is symmetric on both sides — the wirespec request/response + // DTOs appear inside the mapping lambdas only, never as ingredients. + // + // Input : inputFrom(...) + // Output: on(N, ...) + inputFrom { cmd -> + CreateAccountRequest( + userId = cmd.customerId, + profileId = cmd.profileId, + accountType = cmd.accountType, + currency = cmd.currency, + ) } on(201) { resp -> From a909f91529fa7916acea79c37a1c06c7fdac29e8 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Sun, 24 May 2026 14:31:27 +0200 Subject: [PATCH 07/11] feat: parameterize ApiOperation by RequestBody so inputFrom infers the body type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each API operation has exactly one request body type; the recipe author shouldn't have to restate it. ApiOperation is now ApiOperation, and the recipe DSL becomes: inputFrom { cmd -> CreateAccountRequest(userId = cmd.customerId, ...) } The compiler infers RequestBody = CreateAccountRequest from api(CreateAccount) — no second type parameter needed. Operations without a request body use ApiOperation; the generated buildRequestFromBody throws a clear error if called. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ing/baker/openapi/dsl/ApiDsl.kt | 21 ++++++++++--------- .../com/ing/baker/openapi/dsl/ApiOperation.kt | 16 ++++++++------ .../baker/openapi/dsl/ApiOperationBinding.kt | 2 +- .../openapi/dsl/ApiOperationInteraction.kt | 7 ++++--- .../com/ing/baker/openapi/dsl/ApiRecipe.kt | 2 +- .../com/ing/baker/openapi/dsl/ApiDslTest.kt | 4 ++-- .../openapi/dsl/ApiOperationBindingTest.kt | 4 ++-- .../dsl/ApiOperationInteractionTest.kt | 4 ++-- .../openapi/emitter/BakerOpenApiEmitter.java | 14 +++++++++---- .../examples/account/openapi/AccountRecipe.kt | 2 +- 10 files changed, 44 insertions(+), 32 deletions(-) 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 index b2515985d..1176b1575 100644 --- 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 @@ -19,9 +19,9 @@ annotation class ApiDslMarker * 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, +fun RecipeBuilder.api( + operation: ApiOperation, + configure: ApiInteractionScope.() -> Unit, ) { val scope = ApiInteractionScope(operation).apply(configure) addInteraction(scope.buildInteraction()) @@ -38,17 +38,17 @@ fun RecipeBuilder.api( } internal data class ApiInteractionConfig( - val operation: ApiOperation, + val operation: ApiOperation<*>, val mappers: Map) -> Any>, val nameOverrides: Map, - /** Set when the recipe declared `inputFrom(mapper)`. */ + /** 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) { +class ApiInteractionScope internal constructor(private val operation: ApiOperation) { @PublishedApi internal val mappers = mutableMapOf) -> Any>() @@ -106,10 +106,11 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati /** * Typed input mapping — symmetric to the response side's `on(N) { ... }`. - * Declares that event [E] populates the API request body [R] via the given - * lambda: + * 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 -> + * inputFrom { cmd -> * CreateAccountRequest( * userId = cmd.customerId, * profileId = cmd.profileId, @@ -123,7 +124,7 @@ class ApiInteractionScope internal constructor(private val operation: ApiOperati * lets the descriptor wrap the returned body into the operation's Request * envelope. */ - inline fun inputFrom(noinline mapper: (E) -> R) { + inline fun inputFrom(noinline mapper: (E) -> RequestBody) { requiredEvents.add(E::class.simpleName!!) inputEventClass = E::class @Suppress("UNCHECKED_CAST") 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 index 36b5659f5..c9af11bdc 100644 --- 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 @@ -15,10 +15,13 @@ data class InputField( /** * Descriptor for one OpenAPI operation. Implementations are generated by the plugin — - * one `object` per operation — and are pure data plus three callbacks the runtime + * 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 { +interface ApiOperation { /** Stable name for this operation. Used as the Baker interaction name. */ val operationName: String @@ -34,16 +37,17 @@ interface ApiOperation { /** * 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. + * 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 the body - * DTO and the descriptor knows how to put it into the Endpoint.Request. + * 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: Any): Any + 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<*> 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 index 15ad981f1..b9d82951e 100644 --- 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 @@ -20,7 +20,7 @@ import kotlin.reflect.KClass * body DTO, which the descriptor wraps via [ApiOperation.buildRequestFromBody]. */ class ApiOperationBinding( - private val operation: ApiOperation, + private val operation: ApiOperation<*>, private val handler: Wirespec.Handler, private val mappers: Map) -> Any>, private val nameOverrides: Map = emptyMap(), 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 index c26d721b9..b66aefbdf 100644 --- 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 @@ -17,7 +17,7 @@ import kotlin.reflect.jvm.javaType typealias ResponseMapper = (Wirespec.Response<*>) -> Any class ApiOperationInteraction( - private val operation: ApiOperation, + private val operation: ApiOperation<*>, private val handler: Wirespec.Handler, private val mappers: kotlin.collections.Map, /** @@ -72,7 +72,8 @@ class ApiOperationInteraction( val request: Any = if (inputEventClass != null && inputMapper != null) { val event = reconstructInputEvent(inputEventClass, input) val body = inputMapper.invoke(event) - operation.buildRequestFromBody(body) + @Suppress("UNCHECKED_CAST") + (operation as ApiOperation).buildRequestFromBody(body) } else { val ingredientMap: kotlin.collections.Map = input.associate { instance -> @@ -104,5 +105,5 @@ private fun reconstructInputEvent(eventClass: KClass<*>, input: List.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 index c1da3c27b..eeefefdda 100644 --- 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 @@ -37,7 +37,7 @@ class ApiRecipe internal constructor( fun toInteractionInstances( transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, serialization: Wirespec.Serialization, - overrides: Map, + overrides: Map, Wirespec.Handler>, ): List = configsByOperation.values.map { cfg -> val handler = overrides[cfg.operation] ?: cfg.operation.buildHandler(transport, serialization) 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 index 21b8f6343..321ab2ffb 100644 --- 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 @@ -21,7 +21,7 @@ private class DslFakeResponse(override val status: Int) : Wirespec.Response { override val operationName = "CreateUser" override val inputFields = listOf( InputField("firstName", String::class), @@ -33,7 +33,7 @@ private object CreateUser : ApiOperation { ) override val handlerClass = DslFakeHandler::class override fun buildRequest(ingredients: Map): Any = ingredients - override fun buildRequestFromBody(body: Any): Any = body + override fun buildRequestFromBody(body: Unit): Any = body override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = DslFakeResponse(201) override fun buildHandler( 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 index c453806d8..abd74f111 100644 --- 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 @@ -12,13 +12,13 @@ private class BindingStubResponse(override val status: Int) : Wirespec.Response< override val headers: Wirespec.Response.Headers = BindingStubHeaders } -private object StubOp : ApiOperation { +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: Any): 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( 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 index 9f6f3e99a..ffee681a0 100644 --- 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 @@ -26,7 +26,7 @@ private data class UserCreated(val id: String, val email: String) private class FakeOperation( private val nextResponse: Wirespec.Response<*>, -) : ApiOperation { +) : ApiOperation { override val operationName: String = "CreateUser" override val inputFields = listOf( InputField("firstName", String::class), @@ -42,7 +42,7 @@ private class FakeOperation( capturedRequest = ingredients return ingredients } - override fun buildRequestFromBody(body: Any): Any = body + override fun buildRequestFromBody(body: Unit): Any = body override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = nextResponse override fun buildHandler( transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java index 017df37ae..dedcc82d3 100644 --- a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java +++ b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java @@ -72,7 +72,11 @@ public String emit(@NotNull Endpoint endpoint) { sb.append("import community.flock.wirespec.kotlin.Wirespec\n"); sb.append("import kotlin.reflect.KClass\n\n"); - sb.append("object ").append(name).append(" : ApiOperation {\n"); + // Parameterize ApiOperation by the request body type. Operations without + // a body use Unit. + String requestBodyType = bodyTypeName(endpoint); + if (requestBodyType == null) requestBodyType = "Unit"; + sb.append("object ").append(name).append(" : ApiOperation<").append(requestBodyType).append("> {\n"); sb.append(" override val operationName = \"").append(name).append("\"\n\n"); // Input fields: path + query + headers + flattened body fields. @@ -135,11 +139,13 @@ public String emit(@NotNull Endpoint endpoint) { && endpoint.getQueries().isEmpty() && endpoint.getHeaders().isEmpty() && endpoint.getPath().stream().noneMatch(s -> s instanceof Endpoint.Segment.Param); - sb.append(" override fun buildRequestFromBody(body: Any): Any =\n"); + sb.append(" override fun buildRequestFromBody(body: ").append(requestBodyType).append("): Any =\n"); if (bodyOnly) { - sb.append(" ").append(name).append(".Request(body as ").append(bodyTypeName).append(")\n\n"); + sb.append(" ").append(name).append(".Request(body)\n\n"); + } else if (bodyTypeName == null) { + sb.append(" error(\"Operation ").append(name).append(" has no request body; inputFrom is not applicable.\")\n\n"); } else { - sb.append(" error(\"inputFrom(mapper) is not supported for operations with path/query/header params; use ingredientNameOverrides with the flat flow instead.\")\n\n"); + sb.append(" error(\"inputFrom { ... } is not supported for operations with path/query/header params; use ingredientNameOverrides with the flat flow instead.\")\n\n"); } // invoke diff --git a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt index cbdc53c13..692f11730 100644 --- a/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -18,7 +18,7 @@ object AccountRecipe { // // Input : inputFrom(...) // Output: on(N, ...) - inputFrom { cmd -> + inputFrom { cmd -> CreateAccountRequest( userId = cmd.customerId, profileId = cmd.profileId, From be2a0cc5975e7289f2902a9c97816feb6fb187ac Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Sun, 24 May 2026 22:11:16 +0200 Subject: [PATCH 08/11] test: use wirespec 0.18.11 wiremock integration in example Drops the manual map -> Jackson -> withBody plumbing in favour of wirespec().willReturn(EndpointType.Response201(...)) which drives both the WireMock matcher (from the endpoint's method + path template) and the response body (typed Wirespec.Response, serialized via the supplied serialization). Bumps the example's wirespec.version to 0.18.11; the 0.17.20 plugin still generates code that runs against the newer wirespec runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/baker-openapi-example/pom.xml | 8 ++++- .../openapi/AccountRecipeWireMockTest.kt | 32 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/examples/baker-openapi-example/pom.xml b/examples/baker-openapi-example/pom.xml index ff0d1b817..5c970f084 100644 --- a/examples/baker-openapi-example/pom.xml +++ b/examples/baker-openapi-example/pom.xml @@ -13,7 +13,7 @@ Baker OpenAPI Example - 0.17.20 + 0.18.11 @@ -86,6 +86,12 @@ 3.12.1 test + + community.flock.wirespec.integration + wiremock-jvm + ${wirespec.version} + test + org.junit.jupiter junit-jupiter-engine diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index 3c8d08c2c..c08ef7783 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -3,18 +3,21 @@ package com.ing.baker.examples.account.openapi import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.registerKotlinModule import com.github.tomakehurst.wiremock.WireMockServer -import com.github.tomakehurst.wiremock.client.WireMock.aResponse -import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.equalTo +import com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.examples.account.openapi.generated.endpoint.CreateAccount as CreateAccountEndpoint +import com.ing.baker.examples.account.openapi.generated.model.AccountDto import com.ing.baker.openapi.wirespec.Transportation import com.ing.baker.openapi.wirespec.javaHttpTransportation import com.ing.baker.recipe.kotlindsl.ExperimentalDsl import com.ing.baker.runtime.javadsl.EventInstance import com.ing.baker.runtime.kotlindsl.InMemoryBaker import community.flock.wirespec.integration.jackson.kotlin.WirespecSerialization +import community.flock.wirespec.integration.wiremock.kotlin.wirespec import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -39,13 +42,22 @@ class AccountRecipeWireMockTest { @Test fun `recipe fires AccountCreated on 201`() = runBlocking { + // wirespec() drives the WireMock matcher from the endpoint's + // method + path template; willReturn(...) takes the typed Wirespec.Response + // directly — no manual JSON body assembly. server.stubFor( - post(urlEqualTo("/accounts")).willReturn( - aResponse().withStatus(201).withHeader("Content-Type", "application/json") - .withBody(objectMapper.writeValueAsString(mapOf( - "accountId" to "a1", "userId" to "u1", "profileId" to "p1", - "iban" to "NL00", "accountType" to "CURRENT", "currency" to "EUR", - ))) + wirespec().willReturn( + CreateAccountEndpoint.Response201( + AccountDto( + accountId = "a1", + userId = "u1", + profileId = "p1", + iban = "NL00", + accountType = "CURRENT", + currency = "EUR", + ) + ), + serialization, ) ) @@ -77,10 +89,10 @@ class AccountRecipeWireMockTest { val events = baker.getRecipeInstanceState(rid).events.map { it.name } assertTrue(events.contains("AccountCreated"), "events were: $events") // The customerId ingredient was renamed to userId for the API call — - // proves ingredientNameOverrides wired the value through. + // proves the inputFrom mapper wired the value through. server.verify( postRequestedFor(urlEqualTo("/accounts")) - .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath("$.userId", com.github.tomakehurst.wiremock.client.WireMock.equalTo("u1"))) + .withRequestBody(matchingJsonPath("$.userId", equalTo("u1"))) ) } } From c494eeb7b898a4012dadfba50731e915d006b297 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Sun, 24 May 2026 22:16:13 +0200 Subject: [PATCH 09/11] feat: add GET /accounts to example + fix emitter for input-less operations Adds the listAccounts operation (GET /accounts -> 200: [AccountDto]) to the example's OpenAPI doc. Surfaced two emitter bugs for endpoints with no path/query/header/body inputs: 1. inputFields = listOf() lacked a type argument; Kotlin failed to infer the list element type. Emit emptyList() instead. 2. wirespec emits 'object Request' (a singleton) when the endpoint has no inputs. Reference it as Endpoint.Request, not Endpoint.Request(), in the generated buildRequest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../openapi/emitter/BakerOpenApiEmitter.java | 26 +++++++++++++------ .../src/main/openapi/account-api.json | 16 ++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java index dedcc82d3..8cdedc641 100644 --- a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java +++ b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java @@ -82,12 +82,16 @@ public String emit(@NotNull Endpoint endpoint) { // Input fields: path + query + headers + flattened body fields. // For ::class references we strip nullability — KClass has no nullable variant. List inputs = collectInputs(endpoint); - sb.append(" override val inputFields = listOf(\n"); - for (String[] f : inputs) { - String classRef = f[1].endsWith("?") ? f[1].substring(0, f[1].length() - 1) : f[1]; - sb.append(" InputField(\"").append(f[0]).append("\", ").append(classRef).append("::class),\n"); + if (inputs.isEmpty()) { + sb.append(" override val inputFields: List = emptyList()\n\n"); + } else { + sb.append(" override val inputFields = listOf(\n"); + for (String[] f : inputs) { + String classRef = f[1].endsWith("?") ? f[1].substring(0, f[1].length() - 1) : f[1]; + sb.append(" InputField(\"").append(f[0]).append("\", ").append(classRef).append("::class),\n"); + } + sb.append(" )\n\n"); } - sb.append(" )\n\n"); // Response types sb.append(" override val responseTypes: Map> = mapOf(\n"); @@ -127,9 +131,15 @@ public String emit(@NotNull Endpoint endpoint) { ctorArgs.add(bodyCtor.toString()); } sb.append(" override fun buildRequest(ingredients: Map): Any =\n"); - sb.append(" ").append(name).append(".Request(\n"); - sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); - sb.append(" )\n\n"); + if (ctorArgs.isEmpty()) { + // wirespec emits `object Request` for endpoints with no inputs; + // reference it as a singleton, not a constructor call. + sb.append(" ").append(name).append(".Request\n\n"); + } else { + sb.append(" ").append(name).append(".Request(\n"); + sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); + sb.append(" )\n\n"); + } // buildRequestFromBody — used by the typed inputFrom(mapper) DSL. // The user-provided lambda returns the body DTO; this wraps it in the diff --git a/examples/baker-openapi-example/src/main/openapi/account-api.json b/examples/baker-openapi-example/src/main/openapi/account-api.json index 4db384666..8e9f4f749 100644 --- a/examples/baker-openapi-example/src/main/openapi/account-api.json +++ b/examples/baker-openapi-example/src/main/openapi/account-api.json @@ -3,6 +3,22 @@ "info": {"title": "Account API", "version": "1.0.0"}, "paths": { "/accounts": { + "get": { + "operationId": "listAccounts", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/AccountDto"} + } + } + } + } + } + }, "post": { "operationId": "createAccount", "requestBody": { From acec2e6c8c4bd022c03404fe8f14991de10c7a17 Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Mon, 25 May 2026 19:28:43 +0200 Subject: [PATCH 10/11] feat: generate OpenAPI endpoints with wirespec 0.18.13 KotlinIrEmitter Bump baker-openapi and the example to wirespec 0.18.13 and switch the plugin from KotlinEmitter to KotlinIrEmitter, which emits `Endpoint.api` (a Wirespec.Server). The example's WireMock test now drives the stub via the typesafe wirespec(CreateAccountEndpoint.api). arrow 2.x (pulled in by 0.18.13) makes NonEmptyList a value class, so Module.statements is uncallable from Java; rewrite BakerOpenApiEmitter in Kotlin (1:1 logic) and make the emitter module pure-Kotlin-main. ConverterArguments now requires `ir` (ignored by convert(), set to true). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../baker-openapi-emitter/pom.xml | 14 +- .../openapi/emitter/BakerOpenApiEmitter.java | 271 ------------------ .../openapi/emitter/BakerOpenApiEmitter.kt | 249 ++++++++++++++++ .../openapi/plugin/GenerateFromOpenApiMojo.kt | 7 +- core/baker-openapi/pom.xml | 2 +- examples/baker-openapi-example/pom.xml | 2 +- .../openapi/AccountRecipeWireMockTest.kt | 7 +- 7 files changed, 266 insertions(+), 286 deletions(-) delete mode 100644 core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java create mode 100644 core/baker-openapi/baker-openapi-emitter/src/main/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.kt diff --git a/core/baker-openapi/baker-openapi-emitter/pom.xml b/core/baker-openapi/baker-openapi-emitter/pom.xml index f9a9a21db..4e5dd55c4 100644 --- a/core/baker-openapi/baker-openapi-emitter/pom.xml +++ b/core/baker-openapi/baker-openapi-emitter/pom.xml @@ -45,16 +45,9 @@ + src/main/kotlin src/test/kotlin - - org.apache.maven.plugins - maven-compiler-plugin - - ${jvm.target} - ${jvm.target} - - org.jetbrains.kotlin kotlin-maven-plugin @@ -63,6 +56,11 @@ ${jvm.target} + + compile + process-sources + compile + test-compile test-compile diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java b/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java deleted file mode 100644 index 8cdedc641..000000000 --- a/core/baker-openapi/baker-openapi-emitter/src/main/java/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.java +++ /dev/null @@ -1,271 +0,0 @@ -package com.ing.baker.openapi.emitter; - -import community.flock.wirespec.compiler.core.emit.Emitted; -import community.flock.wirespec.compiler.core.emit.FileExtension; -import community.flock.wirespec.compiler.core.emit.LanguageEmitter; -import community.flock.wirespec.compiler.core.emit.PackageName; -import community.flock.wirespec.compiler.core.emit.Shared; -import community.flock.wirespec.compiler.core.parse.ast.Channel; -import community.flock.wirespec.compiler.core.parse.ast.Definition; -import community.flock.wirespec.compiler.core.parse.ast.Endpoint; -import community.flock.wirespec.compiler.core.parse.ast.Enum; -import community.flock.wirespec.compiler.core.parse.ast.Field; -import community.flock.wirespec.compiler.core.parse.ast.Identifier; -import community.flock.wirespec.compiler.core.parse.ast.Module; -import community.flock.wirespec.compiler.core.parse.ast.Reference; -import community.flock.wirespec.compiler.core.parse.ast.Refined; -import community.flock.wirespec.compiler.core.parse.ast.Type; -import community.flock.wirespec.compiler.core.parse.ast.Union; -import community.flock.wirespec.compiler.utils.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -public class BakerOpenApiEmitter extends LanguageEmitter { - - private final PackageName packageName; - private Module currentModule; - - public BakerOpenApiEmitter(PackageName packageName) { - this.packageName = packageName; - } - - public BakerOpenApiEmitter() { - this.packageName = null; - } - - @NotNull @Override public String getSingleLineComment() { return "//"; } - @NotNull @Override public FileExtension getExtension() { return FileExtension.Kotlin; } - @Nullable @Override public Shared getShared() { return null; } - @NotNull @Override public String notYetImplemented() { return ""; } - - @NotNull - @Override - public Emitted emit(@NotNull Definition definition, @NotNull Module module, @NotNull Logger logger) { - this.currentModule = module; - Emitted base = super.emit(definition, module, logger); - if (packageName != null && !packageName.getValue().isEmpty() && definition instanceof Endpoint) { - String dir = packageName.getValue().replace('.', '/') + "/api/"; - return new Emitted(dir + base.getFile(), base.getResult()); - } - return base; - } - - @NotNull @Override public String emit(@NotNull Identifier identifier) { return identifier.getValue(); } - - @NotNull - @Override - public String emit(@NotNull Endpoint endpoint) { - String name = emit(endpoint.getIdentifier()); - - StringBuilder sb = new StringBuilder(); - if (packageName != null && !packageName.getValue().isEmpty()) { - sb.append("package ").append(packageName.getValue()).append(".api\n\n"); - sb.append("import ").append(packageName.getValue()).append(".endpoint.").append(name).append("\n"); - sb.append("import ").append(packageName.getValue()).append(".model.*\n"); - } - sb.append("import com.ing.baker.openapi.dsl.ApiOperation\n"); - sb.append("import com.ing.baker.openapi.dsl.InputField\n"); - sb.append("import community.flock.wirespec.kotlin.Wirespec\n"); - sb.append("import kotlin.reflect.KClass\n\n"); - - // Parameterize ApiOperation by the request body type. Operations without - // a body use Unit. - String requestBodyType = bodyTypeName(endpoint); - if (requestBodyType == null) requestBodyType = "Unit"; - sb.append("object ").append(name).append(" : ApiOperation<").append(requestBodyType).append("> {\n"); - sb.append(" override val operationName = \"").append(name).append("\"\n\n"); - - // Input fields: path + query + headers + flattened body fields. - // For ::class references we strip nullability — KClass has no nullable variant. - List inputs = collectInputs(endpoint); - if (inputs.isEmpty()) { - sb.append(" override val inputFields: List = emptyList()\n\n"); - } else { - sb.append(" override val inputFields = listOf(\n"); - for (String[] f : inputs) { - String classRef = f[1].endsWith("?") ? f[1].substring(0, f[1].length() - 1) : f[1]; - sb.append(" InputField(\"").append(f[0]).append("\", ").append(classRef).append("::class),\n"); - } - sb.append(" )\n\n"); - } - - // Response types - sb.append(" override val responseTypes: Map> = mapOf(\n"); - for (Endpoint.Response resp : endpoint.getResponses()) { - sb.append(" ").append(resp.getStatus()).append(" to ").append(name) - .append(".Response").append(resp.getStatus()).append("::class,\n"); - } - sb.append(" )\n\n"); - - // handlerClass - sb.append(" override val handlerClass = ").append(name).append(".Handler::class\n\n"); - - // buildRequest - String bodyTypeName = bodyTypeName(endpoint); - List ctorArgs = new ArrayList<>(); - for (Endpoint.Segment seg : endpoint.getPath()) { - if (seg instanceof Endpoint.Segment.Param p) { - ctorArgs.add(p.getIdentifier().getValue() + " = ingredients[\"" + p.getIdentifier().getValue() + "\"] as " + kotlinType(p.getReference())); - } - } - for (Field q : endpoint.getQueries()) { - ctorArgs.add(q.getIdentifier().getValue() + " = ingredients[\"" + q.getIdentifier().getValue() + "\"] as " + kotlinType(q.getReference())); - } - for (Field h : endpoint.getHeaders()) { - ctorArgs.add(h.getIdentifier().getValue() + " = ingredients[\"" + h.getIdentifier().getValue() + "\"] as " + kotlinType(h.getReference())); - } - if (bodyTypeName != null) { - Type bodyType = findType(bodyTypeName); - StringBuilder bodyCtor = new StringBuilder(bodyTypeName + "("); - if (bodyType != null) { - String fields = bodyType.getShape().getValue().stream() - .map(f -> f.getIdentifier().getValue() + " = ingredients[\"" + f.getIdentifier().getValue() + "\"] as " + kotlinType(f.getReference())) - .collect(Collectors.joining(", ")); - bodyCtor.append(fields); - } - bodyCtor.append(")"); - ctorArgs.add(bodyCtor.toString()); - } - sb.append(" override fun buildRequest(ingredients: Map): Any =\n"); - if (ctorArgs.isEmpty()) { - // wirespec emits `object Request` for endpoints with no inputs; - // reference it as a singleton, not a constructor call. - sb.append(" ").append(name).append(".Request\n\n"); - } else { - sb.append(" ").append(name).append(".Request(\n"); - sb.append(" ").append(String.join(",\n ", ctorArgs)).append("\n"); - sb.append(" )\n\n"); - } - - // buildRequestFromBody — used by the typed inputFrom(mapper) DSL. - // The user-provided lambda returns the body DTO; this wraps it in the - // Endpoint.Request envelope. Body-only operations have a trivial wrapper; - // operations with path/query/header params reject the typed flow in v1. - boolean bodyOnly = bodyTypeName != null - && endpoint.getQueries().isEmpty() - && endpoint.getHeaders().isEmpty() - && endpoint.getPath().stream().noneMatch(s -> s instanceof Endpoint.Segment.Param); - sb.append(" override fun buildRequestFromBody(body: ").append(requestBodyType).append("): Any =\n"); - if (bodyOnly) { - sb.append(" ").append(name).append(".Request(body)\n\n"); - } else if (bodyTypeName == null) { - sb.append(" error(\"Operation ").append(name).append(" has no request body; inputFrom is not applicable.\")\n\n"); - } else { - sb.append(" error(\"inputFrom { ... } is not supported for operations with path/query/header params; use ingredientNameOverrides with the flat flow instead.\")\n\n"); - } - - // invoke - String handlerMethod = Character.toLowerCase(name.charAt(0)) + name.substring(1); - sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n"); - sb.append(" (handler as ").append(name).append(".Handler).").append(handlerMethod) - .append("(request as ").append(name).append(".Request)\n\n"); - - // buildHandler — wraps the operation's wirespec ClientEdge in a Handler that - // routes through the supplied transport. Lets callers register no per-operation - // factories — only (transport, serialization) at startup. - sb.append(" override fun buildHandler(\n"); - sb.append(" transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,\n"); - sb.append(" serialization: Wirespec.Serialization,\n"); - sb.append(" ): Wirespec.Handler {\n"); - sb.append(" val edge = ").append(name).append(".Handler.client(serialization)\n"); - sb.append(" return object : ").append(name).append(".Handler {\n"); - sb.append(" override suspend fun ").append(handlerMethod) - .append("(request: ").append(name).append(".Request): ") - .append(name).append(".Response<*> =\n"); - sb.append(" edge.from(transport(edge.to(request)))\n"); - sb.append(" }\n"); - sb.append(" }\n"); - sb.append("}\n"); - - return sb.toString(); - } - - @NotNull @Override public String emit(@NotNull Type type, @NotNull Module module) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Type.Shape shape) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Field field) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Reference reference) { return kotlinType(reference); } - @NotNull @Override public String emit(@NotNull Reference.Primitive.Type.Constraint constraint) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Enum anEnum, @NotNull Module module) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Union union) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Refined refined) { return notYetImplemented(); } - @NotNull @Override public String emitValidator(@NotNull Refined refined) { return notYetImplemented(); } - @NotNull @Override public String emit(@NotNull Channel channel) { return notYetImplemented(); } - - private List collectInputs(Endpoint endpoint) { - List out = new ArrayList<>(); - for (Endpoint.Segment seg : endpoint.getPath()) { - if (seg instanceof Endpoint.Segment.Param p) { - out.add(new String[]{p.getIdentifier().getValue(), kotlinType(p.getReference())}); - } - } - for (Field q : endpoint.getQueries()) { - out.add(new String[]{q.getIdentifier().getValue(), kotlinType(q.getReference())}); - } - for (Field h : endpoint.getHeaders()) { - out.add(new String[]{h.getIdentifier().getValue(), kotlinType(h.getReference())}); - } - for (Endpoint.Request req : endpoint.getRequests()) { - if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { - Type bodyType = findType(c.getValue()); - if (bodyType != null) { - for (Field f : bodyType.getShape().getValue()) { - out.add(new String[]{f.getIdentifier().getValue(), kotlinType(f.getReference())}); - } - } - } - } - return out; - } - - private String bodyTypeName(Endpoint endpoint) { - for (Endpoint.Request req : endpoint.getRequests()) { - if (req.getContent() != null && req.getContent().getReference() instanceof Reference.Custom c) { - return c.getValue(); - } - } - return null; - } - - private Type findType(String name) { - if (currentModule == null) return null; - for (var stmt : currentModule.getStatements()) { - if (stmt instanceof Type t && t.getIdentifier().getValue().equals(name)) return t; - } - return null; - } - - private String kotlinType(Reference ref) { - String base; - if (ref instanceof Reference.Primitive primitive) { - base = switch (primitive.getType()) { - case Reference.Primitive.Type.String s -> "String"; - case Reference.Primitive.Type.Integer i -> switch (i.getPrecision()) { - case P32 -> "Int"; - case P64 -> "Long"; - }; - case Reference.Primitive.Type.Number n -> switch (n.getPrecision()) { - case P32 -> "Float"; - case P64 -> "Double"; - }; - case Reference.Primitive.Type.Boolean b -> "Boolean"; - case Reference.Primitive.Type.Bytes b -> "ByteArray"; - default -> "Any"; - }; - } else if (ref instanceof Reference.Custom c) { - base = c.getValue(); - } else if (ref instanceof Reference.Iterable it) { - base = "List<" + kotlinType(it.getReference()) + ">"; - } else if (ref instanceof Reference.Dict d) { - base = "Map"; - } else if (ref instanceof Reference.Unit) { - base = "Unit"; - } else { - base = "Any"; - } - return ref.isNullable() ? base + "?" : base; - } -} diff --git a/core/baker-openapi/baker-openapi-emitter/src/main/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.kt b/core/baker-openapi/baker-openapi-emitter/src/main/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.kt new file mode 100644 index 000000000..917db4652 --- /dev/null +++ b/core/baker-openapi/baker-openapi-emitter/src/main/kotlin/com/ing/baker/openapi/emitter/BakerOpenApiEmitter.kt @@ -0,0 +1,249 @@ +package com.ing.baker.openapi.emitter + +import community.flock.wirespec.compiler.core.emit.Emitted +import community.flock.wirespec.compiler.core.emit.FileExtension +import community.flock.wirespec.compiler.core.emit.LanguageEmitter +import community.flock.wirespec.compiler.core.emit.PackageName +import community.flock.wirespec.compiler.core.emit.Shared +import community.flock.wirespec.compiler.core.parse.ast.Channel +import community.flock.wirespec.compiler.core.parse.ast.Definition +import community.flock.wirespec.compiler.core.parse.ast.Endpoint +import community.flock.wirespec.compiler.core.parse.ast.Enum +import community.flock.wirespec.compiler.core.parse.ast.Field +import community.flock.wirespec.compiler.core.parse.ast.Identifier +import community.flock.wirespec.compiler.core.parse.ast.Module +import community.flock.wirespec.compiler.core.parse.ast.Reference +import community.flock.wirespec.compiler.core.parse.ast.Reference.Primitive.Type.Precision +import community.flock.wirespec.compiler.core.parse.ast.Refined +import community.flock.wirespec.compiler.core.parse.ast.Type +import community.flock.wirespec.compiler.core.parse.ast.Union +import community.flock.wirespec.compiler.utils.Logger + +class BakerOpenApiEmitter( + private val packageName: PackageName? = null, +) : LanguageEmitter() { + + private var currentModule: Module? = null + + override val singleLineComment: String = "//" + override val extension: FileExtension = FileExtension.Kotlin + override val shared: Shared? = null + override fun notYetImplemented(): String = "" + + override fun emit(definition: Definition, module: Module, logger: Logger): Emitted { + currentModule = module + val base = super.emit(definition, module, logger) + if (packageName != null && packageName.value.isNotEmpty() && definition is Endpoint) { + val dir = packageName.value.replace('.', '/') + "/api/" + return Emitted(dir + base.file, base.result) + } + return base + } + + override fun emit(identifier: Identifier): String = identifier.value + + override fun emit(endpoint: Endpoint): String { + val name = emit(endpoint.identifier) + + val sb = StringBuilder() + if (packageName != null && packageName.value.isNotEmpty()) { + sb.append("package ").append(packageName.value).append(".api\n\n") + sb.append("import ").append(packageName.value).append(".endpoint.").append(name).append("\n") + sb.append("import ").append(packageName.value).append(".model.*\n") + } + sb.append("import com.ing.baker.openapi.dsl.ApiOperation\n") + sb.append("import com.ing.baker.openapi.dsl.InputField\n") + sb.append("import community.flock.wirespec.kotlin.Wirespec\n") + sb.append("import kotlin.reflect.KClass\n\n") + + // Parameterize ApiOperation by the request body type. Operations without + // a body use Unit. + val requestBodyType = bodyTypeName(endpoint) ?: "Unit" + sb.append("object ").append(name).append(" : ApiOperation<").append(requestBodyType).append("> {\n") + sb.append(" override val operationName = \"").append(name).append("\"\n\n") + + // Input fields: path + query + headers + flattened body fields. + // For ::class references we strip nullability — KClass has no nullable variant. + val inputs = collectInputs(endpoint) + if (inputs.isEmpty()) { + sb.append(" override val inputFields: List = emptyList()\n\n") + } else { + sb.append(" override val inputFields = listOf(\n") + for (f in inputs) { + val classRef = if (f[1].endsWith("?")) f[1].substring(0, f[1].length - 1) else f[1] + sb.append(" InputField(\"").append(f[0]).append("\", ").append(classRef).append("::class),\n") + } + sb.append(" )\n\n") + } + + // Response types + sb.append(" override val responseTypes: Map> = mapOf(\n") + for (resp in endpoint.responses) { + sb.append(" ").append(resp.status).append(" to ").append(name) + .append(".Response").append(resp.status).append("::class,\n") + } + sb.append(" )\n\n") + + // handlerClass + sb.append(" override val handlerClass = ").append(name).append(".Handler::class\n\n") + + // buildRequest + val bodyTypeName = bodyTypeName(endpoint) + val ctorArgs = ArrayList() + for (seg in endpoint.path) { + if (seg is Endpoint.Segment.Param) { + ctorArgs.add(seg.identifier.value + " = ingredients[\"" + seg.identifier.value + "\"] as " + kotlinType(seg.reference)) + } + } + for (q in endpoint.queries) { + ctorArgs.add(q.identifier.value + " = ingredients[\"" + q.identifier.value + "\"] as " + kotlinType(q.reference)) + } + for (h in endpoint.headers) { + ctorArgs.add(h.identifier.value + " = ingredients[\"" + h.identifier.value + "\"] as " + kotlinType(h.reference)) + } + if (bodyTypeName != null) { + val bodyType = findType(bodyTypeName) + val bodyCtor = StringBuilder("$bodyTypeName(") + if (bodyType != null) { + val fields = bodyType.shape.value.joinToString(", ") { f -> + f.identifier.value + " = ingredients[\"" + f.identifier.value + "\"] as " + kotlinType(f.reference) + } + bodyCtor.append(fields) + } + bodyCtor.append(")") + ctorArgs.add(bodyCtor.toString()) + } + sb.append(" override fun buildRequest(ingredients: Map): Any =\n") + if (ctorArgs.isEmpty()) { + // wirespec emits `object Request` for endpoints with no inputs; + // reference it as a singleton, not a constructor call. + sb.append(" ").append(name).append(".Request\n\n") + } else { + sb.append(" ").append(name).append(".Request(\n") + sb.append(" ").append(ctorArgs.joinToString(",\n ")).append("\n") + sb.append(" )\n\n") + } + + // buildRequestFromBody — used by the typed inputFrom(mapper) DSL. + // The user-provided lambda returns the body DTO; this wraps it in the + // Endpoint.Request envelope. Body-only operations have a trivial wrapper; + // operations with path/query/header params reject the typed flow in v1. + val bodyOnly = bodyTypeName != null && + endpoint.queries.isEmpty() && + endpoint.headers.isEmpty() && + endpoint.path.none { it is Endpoint.Segment.Param } + sb.append(" override fun buildRequestFromBody(body: ").append(requestBodyType).append("): Any =\n") + if (bodyOnly) { + sb.append(" ").append(name).append(".Request(body)\n\n") + } else if (bodyTypeName == null) { + sb.append(" error(\"Operation ").append(name).append(" has no request body; inputFrom is not applicable.\")\n\n") + } else { + sb.append(" error(\"inputFrom { ... } is not supported for operations with path/query/header params; use ingredientNameOverrides with the flat flow instead.\")\n\n") + } + + // invoke + val handlerMethod = name.replaceFirstChar { it.lowercaseChar() } + sb.append(" override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> =\n") + sb.append(" (handler as ").append(name).append(".Handler).").append(handlerMethod) + .append("(request as ").append(name).append(".Request)\n\n") + + // buildHandler — wraps the operation's wirespec ClientEdge in a Handler that + // routes through the supplied transport. Lets callers register no per-operation + // factories — only (transport, serialization) at startup. + sb.append(" override fun buildHandler(\n") + sb.append(" transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse,\n") + sb.append(" serialization: Wirespec.Serialization,\n") + sb.append(" ): Wirespec.Handler {\n") + sb.append(" val edge = ").append(name).append(".Handler.client(serialization)\n") + sb.append(" return object : ").append(name).append(".Handler {\n") + sb.append(" override suspend fun ").append(handlerMethod) + .append("(request: ").append(name).append(".Request): ") + .append(name).append(".Response<*> =\n") + sb.append(" edge.from(transport(edge.to(request)))\n") + sb.append(" }\n") + sb.append(" }\n") + sb.append("}\n") + + return sb.toString() + } + + override fun emit(type: Type, module: Module): String = notYetImplemented() + override fun Type.Shape.emit(): String = notYetImplemented() + override fun Field.emit(): String = notYetImplemented() + override fun Reference.emit(): String = kotlinType(this) + override fun Reference.Primitive.Type.Constraint.emit(): String = notYetImplemented() + override fun emit(enum: Enum, module: Module): String = notYetImplemented() + override fun emit(union: Union): String = notYetImplemented() + override fun emit(refined: Refined): String = notYetImplemented() + override fun Refined.emitValidator(): String = notYetImplemented() + override fun emit(channel: Channel): String = notYetImplemented() + + private fun collectInputs(endpoint: Endpoint): List> { + val out = ArrayList>() + for (seg in endpoint.path) { + if (seg is Endpoint.Segment.Param) { + out.add(arrayOf(seg.identifier.value, kotlinType(seg.reference))) + } + } + for (q in endpoint.queries) { + out.add(arrayOf(q.identifier.value, kotlinType(q.reference))) + } + for (h in endpoint.headers) { + out.add(arrayOf(h.identifier.value, kotlinType(h.reference))) + } + for (req in endpoint.requests) { + val ref = req.content?.reference + if (ref is Reference.Custom) { + val bodyType = findType(ref.value) + if (bodyType != null) { + for (f in bodyType.shape.value) { + out.add(arrayOf(f.identifier.value, kotlinType(f.reference))) + } + } + } + } + return out + } + + private fun bodyTypeName(endpoint: Endpoint): String? { + for (req in endpoint.requests) { + val ref = req.content?.reference + if (ref is Reference.Custom) { + return ref.value + } + } + return null + } + + private fun findType(name: String): Type? { + val module = currentModule ?: return null + for (stmt in module.statements) { + if (stmt is Type && stmt.identifier.value == name) return stmt + } + return null + } + + private fun kotlinType(ref: Reference): String { + val base = when (ref) { + is Reference.Primitive -> when (val type = ref.type) { + is Reference.Primitive.Type.String -> "String" + is Reference.Primitive.Type.Integer -> when (type.precision) { + Precision.P32 -> "Int" + Precision.P64 -> "Long" + } + is Reference.Primitive.Type.Number -> when (type.precision) { + Precision.P32 -> "Float" + Precision.P64 -> "Double" + } + is Reference.Primitive.Type.Boolean -> "Boolean" + is Reference.Primitive.Type.Bytes -> "ByteArray" + } + is Reference.Custom -> ref.value + is Reference.Iterable -> "List<" + kotlinType(ref.reference) + ">" + is Reference.Dict -> "Map" + is Reference.Unit -> "Unit" + is Reference.Any -> "Any" + } + return if (ref.isNullable) "$base?" else base + } +} diff --git a/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt b/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt index eee44d0e4..c907b1dbc 100644 --- a/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt +++ b/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt @@ -4,7 +4,7 @@ import arrow.core.nonEmptySetOf import com.ing.baker.openapi.emitter.BakerOpenApiEmitter import community.flock.wirespec.compiler.core.emit.EmitShared import community.flock.wirespec.compiler.core.emit.PackageName -import community.flock.wirespec.emitters.kotlin.KotlinEmitter +import community.flock.wirespec.emitters.kotlin.KotlinIrEmitter import community.flock.wirespec.compiler.utils.Logger import community.flock.wirespec.plugin.ConverterArguments import community.flock.wirespec.plugin.Format @@ -61,7 +61,7 @@ class GenerateFromOpenApiMojo : AbstractMojo() { ) val emitters = nonEmptySetOf( - KotlinEmitter(pkg, EmitShared(false)) as community.flock.wirespec.compiler.core.emit.Emitter, + KotlinIrEmitter(pkg, EmitShared(false)) as community.flock.wirespec.compiler.core.emit.Emitter, BakerOpenApiEmitter(pkg) as community.flock.wirespec.compiler.core.emit.Emitter, ) @@ -83,6 +83,9 @@ class GenerateFromOpenApiMojo : AbstractMojo() { logger = logger, shared = false, strict = true, + // We supply KotlinIrEmitter explicitly above; convert() ignores this + // flag, but ConverterArguments requires it since wirespec 0.18.x. + ir = true, ) try { diff --git a/core/baker-openapi/pom.xml b/core/baker-openapi/pom.xml index 8dbe1e169..813f94c77 100644 --- a/core/baker-openapi/pom.xml +++ b/core/baker-openapi/pom.xml @@ -16,7 +16,7 @@ pom - 0.17.20 + 0.18.13 diff --git a/examples/baker-openapi-example/pom.xml b/examples/baker-openapi-example/pom.xml index 5c970f084..024981685 100644 --- a/examples/baker-openapi-example/pom.xml +++ b/examples/baker-openapi-example/pom.xml @@ -13,7 +13,7 @@ Baker OpenAPI Example - 0.18.11 + 0.18.13 diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index c08ef7783..6ae7bbafa 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -42,11 +42,12 @@ class AccountRecipeWireMockTest { @Test fun `recipe fires AccountCreated on 201`() = runBlocking { - // wirespec() drives the WireMock matcher from the endpoint's + // wirespec(Endpoint.api) drives the WireMock matcher from the endpoint's // method + path template; willReturn(...) takes the typed Wirespec.Response - // directly — no manual JSON body assembly. + // directly — no manual JSON body assembly. The api server instance makes + // willReturn reject responses from any other endpoint at compile time. server.stubFor( - wirespec().willReturn( + wirespec(CreateAccountEndpoint.api).willReturn( CreateAccountEndpoint.Response201( AccountDto( accountId = "a1", From 36cc8ec8b1f747f4eeeaf1d21ffe044693bf7b2e Mon Sep 17 00:00:00 2001 From: Willem Veelenturf Date: Mon, 25 May 2026 22:07:19 +0200 Subject: [PATCH 11/11] Clean --- .../examples/account/openapi/AccountRecipeWireMockTest.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt index 6ae7bbafa..2aca29ac4 100644 --- a/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -42,10 +42,7 @@ class AccountRecipeWireMockTest { @Test fun `recipe fires AccountCreated on 201`() = runBlocking { - // wirespec(Endpoint.api) drives the WireMock matcher from the endpoint's - // method + path template; willReturn(...) takes the typed Wirespec.Response - // directly — no manual JSON body assembly. The api server instance makes - // willReturn reject responses from any other endpoint at compile time. + server.stubFor( wirespec(CreateAccountEndpoint.api).willReturn( CreateAccountEndpoint.Response201( @@ -57,8 +54,7 @@ class AccountRecipeWireMockTest { accountType = "CURRENT", currency = "EUR", ) - ), - serialization, + ) ) )