diff --git a/core/baker-openapi/baker-openapi-dsl/pom.xml b/core/baker-openapi/baker-openapi-dsl/pom.xml new file mode 100644 index 000000000..174d6f106 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-dsl + Baker OpenAPI DSL + Runtime DSL for building Baker recipes from generated OpenAPI operation descriptors + + + + com.ing.baker + baker-recipe-dsl-kotlin + ${project.version} + + + com.ing.baker + baker-interface-kotlin + ${project.version} + + + com.ing.baker + baker-compiler + ${project.version} + test + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + community.flock.wirespec.integration + wirespec-jvm + ${wirespec.version} + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.platform + junit-platform-launcher + 1.13.2 + test + + + org.jetbrains.kotlin + kotlin-test-junit5 + ${kotlin.version} + test + + + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + compile + process-sources + compile + + + test-compile + process-test-sources + test-compile + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt new file mode 100644 index 000000000..1176b1575 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiDsl.kt @@ -0,0 +1,193 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.recipe.kotlindsl.Event +import com.ing.baker.recipe.kotlindsl.Ingredient +import com.ing.baker.recipe.kotlindsl.Interaction +import com.ing.baker.recipe.kotlindsl.RecipeBuilder +import community.flock.wirespec.kotlin.Wirespec +import java.util.Optional +import kotlin.reflect.KClass +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaType + +@DslMarker +annotation class ApiDslMarker + +/** + * Registers a Baker interaction in this recipe based on an OpenAPI [operation]. + * The [configure] block declares status → user-event mappers and optional Baker + * controls (required events, ingredient name overrides, maximumInteractionCount). + */ +fun RecipeBuilder.api( + operation: ApiOperation, + configure: ApiInteractionScope.() -> Unit, +) { + val scope = ApiInteractionScope(operation).apply(configure) + addInteraction(scope.buildInteraction()) + apiInteractionConfigCollector.get()?.put( + operation.operationName, + ApiInteractionConfig( + operation = operation, + mappers = scope.configuredMappers, + nameOverrides = scope.configuredNameOverrides, + inputEventClass = scope.configuredInputEventClass, + inputMapper = scope.configuredInputMapper, + ), + ) +} + +internal data class ApiInteractionConfig( + val operation: ApiOperation<*>, + val mappers: Map) -> Any>, + val nameOverrides: Map, + /** Set when the recipe declared `inputFrom { ... }`. */ + val inputEventClass: KClass<*>? = null, + /** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */ + val inputMapper: ((Any) -> Any)? = null, +) + +@ApiDslMarker +class ApiInteractionScope internal constructor(private val operation: ApiOperation) { + + @PublishedApi + internal val mappers = mutableMapOf) -> Any>() + + @PublishedApi + internal val outputEventClasses = mutableSetOf>() + + @PublishedApi + internal val requiredEvents = mutableSetOf() + + @PublishedApi + internal val ingredientNameOverridesMap = mutableMapOf() + + @PublishedApi + internal var inputEventClass: KClass<*>? = null + + @PublishedApi + internal var inputMapper: ((Any) -> Any)? = null + + private var maxInteractionCount: Int? = null + + /** + * Maps responses of HTTP [status] to a user-defined event. The [mapper] + * receives the wirespec response and returns a domain event instance. + */ + inline fun , reified E : Any> on( + status: Int, + noinline mapper: (R) -> E, + ) { + @Suppress("UNCHECKED_CAST") + mappers[status] = { resp -> mapper(resp as R) } + outputEventClasses.add(E::class) + } + + fun requires(vararg eventClasses: KClass<*>) { + eventClasses.forEach { requiredEvents.add(it.simpleName!!) } + } + + fun maximumInteractionCount(n: Int) { + maxInteractionCount = n + } + + /** + * Maps API input field names (left) to recipe ingredient names (right). + * Use this when the recipe's ingredient name doesn't match the API contract + * (e.g. domain calls it `customerId`, the OpenAPI doc calls it `userId`). + * + * The wirespec request DTO itself is never an ingredient — only flat values + * (path / query / header / flattened body fields) flow through Baker. + */ + fun ingredientNameOverrides(block: IngredientNameOverridesScope.() -> Unit) { + val scope = IngredientNameOverridesScope().apply(block) + ingredientNameOverridesMap.putAll(scope.entries) + } + + /** + * Typed input mapping — symmetric to the response side's `on(N) { ... }`. + * Declares that event [E] populates the API request body via the given lambda. + * The body type is fixed by the operation (each API operation has exactly one + * request body type), so only [E] needs to be supplied: + * + * inputFrom { cmd -> + * CreateAccountRequest( + * userId = cmd.customerId, + * profileId = cmd.profileId, + * ... + * ) + * } + * + * The wirespec request DTO appears inside the lambda only — never as a + * Baker ingredient. The runtime reconstructs an [E] instance from Baker + * ingredients (E's primary constructor parameters), calls the mapper, and + * lets the descriptor wrap the returned body into the operation's Request + * envelope. + */ + inline fun inputFrom(noinline mapper: (E) -> RequestBody) { + requiredEvents.add(E::class.simpleName!!) + inputEventClass = E::class + @Suppress("UNCHECKED_CAST") + inputMapper = { event -> mapper(event as E) } + } + + /** Read-only view of configured mappers, useful for app-startup binding. */ + val configuredMappers: Map) -> Any> get() = mappers.toMap() + + /** Read-only view of API field → ingredient overrides. */ + val configuredNameOverrides: Map get() = ingredientNameOverridesMap.toMap() + + /** Set when `inputFrom(mapper)` was used. */ + val configuredInputEventClass: KClass<*>? get() = inputEventClass + + /** Set when `inputFrom(mapper)` was used. */ + val configuredInputMapper: ((Any) -> Any)? get() = inputMapper + + internal fun buildInteraction(): Interaction { + // When inputFrom was declared, the interaction's required ingredients + // are E's primary-constructor parameters (each becomes an ingredient that + // Baker fills from the fired E event). Otherwise fall back to the API + // descriptor's flat inputFields. + val inputIngredients: Set = inputEventClass?.let { evt -> + evt.primaryConstructor?.parameters?.map { p -> + Ingredient(p.name!!, p.type.javaType) + }?.toSet() + } ?: operation.inputFields + .map { Ingredient(it.name, it.type.java) } + .toSet() + val events: Set = outputEventClasses + .map { it.toEvent() } + .toSet() + return Interaction.of( + operation.operationName, + operation.operationName, + inputIngredients, + events, + requiredEvents, + emptySet>(), + emptyMap(), + ingredientNameOverridesMap.toMap(), + emptyMap(), + Optional.ofNullable(maxInteractionCount), + Optional.empty(), + false, + ) + } +} + +@ApiDslMarker +class IngredientNameOverridesScope internal constructor() { + internal val entries = mutableMapOf() + /** Maps the API input field name (receiver) to the recipe ingredient name. */ + infix fun String.to(other: String) { entries[this] = other } +} + +private fun KClass<*>.toEvent(): Event { + val ctor = primaryConstructor + val ingredients: List = if (ctor != null) { + ctor.parameters.map { Ingredient(it.name!!, it.type.javaType) } + } else { + memberProperties.map { Ingredient(it.name, it.returnType.javaType) } + } + return Event(simpleName!!, ingredients, Optional.empty()) +} diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt new file mode 100644 index 000000000..c9af11bdc --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperation.kt @@ -0,0 +1,65 @@ +package com.ing.baker.openapi.dsl + +import community.flock.wirespec.kotlin.Wirespec +import kotlin.reflect.KClass + +/** + * A single input ingredient an API operation expects (from path, query, headers, or + * flattened request body fields). The runtime DSL turns each entry into a Baker + * ingredient with the same name. + */ +data class InputField( + val name: String, + val type: KClass<*>, +) + +/** + * Descriptor for one OpenAPI operation. Implementations are generated by the plugin — + * one `object` per operation — and are pure data plus a few callbacks the runtime + * uses to build requests and invoke the wirespec handler. + * + * Each operation has at most one request body type; that type is the [RequestBody] + * parameter on the descriptor. Operations without a body use [Unit]. + */ +interface ApiOperation { + /** Stable name for this operation. Used as the Baker interaction name. */ + val operationName: String + + /** Input ingredients in declaration order. */ + val inputFields: List + + /** Maps HTTP status codes to the wirespec response class for that status. */ + val responseTypes: Map> + + /** The wirespec handler class this operation expects. The plugin generates this. */ + val handlerClass: KClass + + /** + * Builds the wirespec Request from a name → value ingredient map. Used by the + * default flat-ingredient flow when the recipe didn't declare an explicit + * typed `inputFrom { ... }` mapper. + */ + fun buildRequest(ingredients: Map): Any + + /** + * Wraps a wirespec body DTO into the operation's Request envelope. Used by + * the typed `inputFrom { ... }` flow — the user lambda produces a value + * of the operation's specific [RequestBody] type and the descriptor knows + * how to put it into the Endpoint.Request. + */ + fun buildRequestFromBody(body: RequestBody): Any + + /** Invokes the underlying wirespec handler. The handler must be of the operation's expected type. */ + suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> + + /** + * Builds the wirespec handler for this operation from a generic transport and + * serialization. The default implementation lets the runtime construct handlers + * for every API operation in a recipe from just (transport, serialization) — + * the caller doesn't need to register handlers per operation. + */ + fun buildHandler( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): Wirespec.Handler +} diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt new file mode 100644 index 000000000..b9d82951e --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationBinding.kt @@ -0,0 +1,32 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.runtime.javadsl.InteractionInstance +import community.flock.wirespec.kotlin.Wirespec +import kotlin.reflect.KClass + +/** + * Pairs an [ApiOperation] descriptor with the wirespec handler that knows how to + * execute it, plus the status → event mappers from the recipe. Produces an + * [InteractionInstance] for Baker to register at startup. + * + * Mappers normally mirror those configured in the recipe's `api(...)` block. + * [nameOverrides] map API field names to recipe ingredient names; the runtime + * reverses the map to translate IngredientInstance names back to the API names + * before invoking [ApiOperation.buildRequest]. + * + * When the recipe used the typed `inputFrom(mapper)` flow, + * [inputEventClass] and [inputMapper] are set: the runtime reconstructs an [E] + * instance from ingredients and applies [inputMapper] to produce the wirespec + * body DTO, which the descriptor wraps via [ApiOperation.buildRequestFromBody]. + */ +class ApiOperationBinding( + private val operation: ApiOperation<*>, + private val handler: Wirespec.Handler, + private val mappers: Map) -> Any>, + private val nameOverrides: Map = emptyMap(), + private val inputEventClass: KClass<*>? = null, + private val inputMapper: ((Any) -> Any)? = null, +) { + fun toInteractionInstance(): InteractionInstance = + ApiOperationInteraction(operation, handler, mappers, nameOverrides, inputEventClass, inputMapper) +} diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt new file mode 100644 index 000000000..b66aefbdf --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteraction.kt @@ -0,0 +1,109 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.runtime.javadsl.EventInstance +import com.ing.baker.runtime.javadsl.IngredientInstance +import com.ing.baker.runtime.javadsl.InteractionInstance +import com.ing.baker.runtime.javadsl.InteractionInstanceInput +import com.ing.baker.types.Converters +import community.flock.wirespec.kotlin.Wirespec +import kotlinx.coroutines.runBlocking +import scala.collection.immutable.Map +import java.util.Optional +import java.util.concurrent.CompletableFuture +import kotlin.reflect.KClass +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaType + +typealias ResponseMapper = (Wirespec.Response<*>) -> Any + +class ApiOperationInteraction( + private val operation: ApiOperation<*>, + private val handler: Wirespec.Handler, + private val mappers: kotlin.collections.Map, + /** + * Map from API field name → recipe ingredient name. Used only in the flat + * ingredient flow. + */ + private val nameOverrides: kotlin.collections.Map = emptyMap(), + /** Set when the recipe used the typed `inputFrom(mapper)` flow. */ + private val inputEventClass: KClass<*>? = null, + /** Reconstructed-event → wirespec body. Set when [inputEventClass] is set. */ + private val inputMapper: ((Any) -> Any)? = null, +) : InteractionInstance() { + + private val recipeToApi: kotlin.collections.Map = + nameOverrides.entries.associate { (api, recipe) -> recipe to api } + + /** + * Declared ingredients depend on which input flow the recipe chose: + * - Typed flow: the input event's primary-constructor parameters. + * - Flat flow: the API descriptor's inputFields. + */ + private val declaredInputs: List = + inputEventClass?.let { evt -> + evt.primaryConstructor?.parameters?.map { p -> + InteractionInstanceInput( + Optional.of(p.name!!), + Converters.readJavaType(p.type.javaType), + ) + } ?: emptyList() + } ?: operation.inputFields.map { field -> + InteractionInstanceInput( + Optional.of(field.name), + Converters.readJavaType(field.type.java), + ) + } + + override fun name(): String = operation.operationName + + override fun input(): MutableList = declaredInputs.toMutableList() + + override fun execute( + input: MutableList, + metadata: Map, + ): CompletableFuture> = run(input) + + override fun execute(input: Any, metaData: Map): CompletableFuture> { + throw UnsupportedOperationException("ApiOperationInteraction does not support single-input execute()") + } + + override fun run(input: MutableList): CompletableFuture> { + return try { + val request: Any = if (inputEventClass != null && inputMapper != null) { + val event = reconstructInputEvent(inputEventClass, input) + val body = inputMapper.invoke(event) + @Suppress("UNCHECKED_CAST") + (operation as ApiOperation).buildRequestFromBody(body) + } else { + val ingredientMap: kotlin.collections.Map = + input.associate { instance -> + val apiName = recipeToApi[instance.name] ?: instance.name + apiName to instance.value.`as`(operation.inputFieldType(apiName)) + } + operation.buildRequest(ingredientMap) + } + val response = runBlocking { operation.invoke(handler, request) } + val mapper = mappers[response.status] + ?: error("No mapping configured for status ${response.status} on operation ${operation.operationName}") + val event = mapper(response) + CompletableFuture.completedFuture(Optional.ofNullable(EventInstance.from(event))) + } catch (e: Exception) { + CompletableFuture.failedFuture(e) + } + } +} + +private fun reconstructInputEvent(eventClass: KClass<*>, input: List): Any { + val ctor = eventClass.primaryConstructor + ?: error("Input event class ${eventClass.simpleName} has no primary constructor") + val byName = input.associate { it.name to it } + val args = ctor.parameters.map { p -> + val instance = byName[p.name] + ?: error("Missing ingredient '${p.name}' to reconstruct ${eventClass.simpleName}") + instance.value.`as`(p.type.javaType) + } + return ctor.call(*args.toTypedArray()) +} + +private fun ApiOperation<*>.inputFieldType(name: String): java.lang.reflect.Type = + inputFields.first { it.name == name }.type.java diff --git a/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt new file mode 100644 index 000000000..eeefefdda --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/main/kotlin/com/ing/baker/openapi/dsl/ApiRecipe.kt @@ -0,0 +1,66 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.recipe.kotlindsl.Recipe +import com.ing.baker.recipe.kotlindsl.RecipeBuilder +import com.ing.baker.runtime.javadsl.InteractionInstance +import community.flock.wirespec.kotlin.Wirespec + +/** + * A [Recipe] paired with the per-operation configuration declared inside + * `api(...) { ... }` blocks (response mappers, ingredient name overrides). + * Startup code only needs to supply the transport + serialization. + */ +class ApiRecipe internal constructor( + val recipe: Recipe, + internal val configsByOperation: Map, +) { + /** + * Builds an [InteractionInstance] for every API operation in the recipe using + * the supplied [transport] and [serialization]. Every descriptor knows how to + * build its own wirespec handler — callers register nothing per operation. + */ + fun toInteractionInstances( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): List = + configsByOperation.values.map { cfg -> + val handler = cfg.operation.buildHandler(transport, serialization) + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance() + } + + /** + * Overload for callers who want to supply a handler explicitly per operation + * (e.g. tests with custom fakes). Operations not in [overrides] fall back to + * the descriptor's default handler built from (transport, serialization). + */ + fun toInteractionInstances( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + overrides: Map, Wirespec.Handler>, + ): List = + configsByOperation.values.map { cfg -> + val handler = overrides[cfg.operation] ?: cfg.operation.buildHandler(transport, serialization) + ApiOperationBinding(cfg.operation, handler, cfg.mappers, cfg.nameOverrides, cfg.inputEventClass, cfg.inputMapper).toInteractionInstance() + } +} + +/** + * Builds an [ApiRecipe] — same shape as `recipe(name) { ... }` but the returned + * wrapper carries the configuration collected from `api(...)` blocks so the + * runtime can construct interaction instances without re-declaring it. + */ +@OptIn(ExperimentalDsl::class) +fun apiRecipe(name: String, configure: RecipeBuilder.() -> Unit): ApiRecipe { + val collector = mutableMapOf() + apiInteractionConfigCollector.set(collector) + try { + val recipe = com.ing.baker.recipe.kotlindsl.recipe(name, configure) + return ApiRecipe(recipe, collector.toMap()) + } finally { + apiInteractionConfigCollector.remove() + } +} + +internal val apiInteractionConfigCollector: ThreadLocal?> = + ThreadLocal.withInitial { null } diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt new file mode 100644 index 000000000..321ab2ffb --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiDslTest.kt @@ -0,0 +1,97 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.compiler.RecipeCompiler +import com.ing.baker.recipe.kotlindsl.ExperimentalDsl +import com.ing.baker.recipe.kotlindsl.recipe +import community.flock.wirespec.kotlin.Wirespec +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass +import scala.jdk.javaapi.CollectionConverters + +private data class DslSensoryEvent(val firstName: String, val email: String) +private data class DslUserCreated(val id: String, val email: String) +private data class DslUserCreationFailed(val reason: String) + +private class DslFakeHandler : Wirespec.Handler +private object DslFakeHeaders : Wirespec.Response.Headers +private class DslFakeResponse(override val status: Int) : Wirespec.Response { + override val body: Unit = Unit + override val headers: Wirespec.Response.Headers = DslFakeHeaders +} + +private object CreateUser : ApiOperation { + override val operationName = "CreateUser" + override val inputFields = listOf( + InputField("firstName", String::class), + InputField("email", String::class), + ) + override val responseTypes: Map> = mapOf( + 201 to DslFakeResponse::class, + 400 to DslFakeResponse::class, + ) + override val handlerClass = DslFakeHandler::class + override fun buildRequest(ingredients: Map): Any = ingredients + override fun buildRequestFromBody(body: Unit): Any = body + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = + DslFakeResponse(201) + override fun buildHandler( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): Wirespec.Handler = DslFakeHandler() +} + +@OptIn(ExperimentalDsl::class) +class ApiDslTest { + + @Test + fun `api block registers an interaction with the operation name and inputs`() { + val r = recipe("r") { + sensoryEvents { event() } + api(CreateUser) { + on(201) { DslUserCreated("u1", "a@b") } + on(400) { DslUserCreationFailed("nope") } + } + } + val compiled = RecipeCompiler.compileRecipe(r) + val interaction = CollectionConverters.asJava(compiled.interactionTransitions()) + .single { it.interactionName() == "CreateUser" } + val ingredientNames = CollectionConverters.asJava(interaction.requiredIngredients()) + .map { it.name() } + .toSet() + assertEquals(setOf("firstName", "email"), ingredientNames) + } + + @Test + fun `api block exposes user-mapped events as interaction outputs`() { + val r = recipe("r") { + sensoryEvents { event() } + api(CreateUser) { + on(201) { DslUserCreated("u1", "a@b") } + on(400) { DslUserCreationFailed("nope") } + } + } + val compiled = RecipeCompiler.compileRecipe(r) + val outputs = CollectionConverters.asJava(compiled.events()) + .map { it.name() } + .toSet() + assertTrue(outputs.contains("DslUserCreated"), "outputs were: $outputs") + assertTrue(outputs.contains("DslUserCreationFailed"), "outputs were: $outputs") + } + + @Test + fun `requires registers required events on the DSL interaction`() { + val r = recipe("r") { + sensoryEvents { event() } + api(CreateUser) { + requires(DslUserCreated::class) + on(201) { DslUserCreated("u1", "a@b") } + } + } + val interaction = CollectionConverters.asJava(r.interactions()) + .single { it.name() == "CreateUser" } + val reqEvents = CollectionConverters.asJava(interaction.requiredEvents()) + assertTrue(reqEvents.contains("DslUserCreated"), "got: $reqEvents") + } +} diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt new file mode 100644 index 000000000..abd74f111 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationBindingTest.kt @@ -0,0 +1,39 @@ +package com.ing.baker.openapi.dsl + +import community.flock.wirespec.kotlin.Wirespec +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +private class BindingStubHandler : Wirespec.Handler +private object BindingStubHeaders : Wirespec.Response.Headers +private class BindingStubResponse(override val status: Int) : Wirespec.Response { + override val body: Unit = Unit + override val headers: Wirespec.Response.Headers = BindingStubHeaders +} + +private object StubOp : ApiOperation { + override val operationName = "Stub" + override val inputFields: List = emptyList() + override val responseTypes: Map> = mapOf(200 to BindingStubResponse::class) + override val handlerClass = BindingStubHandler::class + override fun buildRequest(ingredients: Map): Any = Unit + override fun buildRequestFromBody(body: Unit): Any = Unit + override suspend fun invoke(handler: Wirespec.Handler, request: Any): Wirespec.Response<*> = + BindingStubResponse(200) + override fun buildHandler( + transport: suspend (Wirespec.RawRequest) -> Wirespec.RawResponse, + serialization: Wirespec.Serialization, + ): Wirespec.Handler = BindingStubHandler() +} + +class ApiOperationBindingTest { + + @Test + fun `binding produces an ApiOperationInteraction with the operation name`() { + val handler = BindingStubHandler() + val binding = ApiOperationBinding(StubOp, handler, mappers = mapOf(200 to { _ -> "ok" })) + val interaction = binding.toInteractionInstance() + assertEquals("Stub", interaction.name()) + } +} diff --git a/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt new file mode 100644 index 000000000..ffee681a0 --- /dev/null +++ b/core/baker-openapi/baker-openapi-dsl/src/test/kotlin/com/ing/baker/openapi/dsl/ApiOperationInteractionTest.kt @@ -0,0 +1,120 @@ +package com.ing.baker.openapi.dsl + +import com.ing.baker.runtime.javadsl.IngredientInstance +import com.ing.baker.types.PrimitiveValue +import community.flock.wirespec.kotlin.Wirespec +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.ExecutionException +import kotlin.reflect.KClass + +private class FakeHandler : Wirespec.Handler + +private object FakeHeaders : Wirespec.Response.Headers + +private class FakeResponse( + override val status: Int, + override val body: Map, +) : Wirespec.Response> { + 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 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, + serialization: Wirespec.Serialization, + ): Wirespec.Handler = FakeHandler() +} + +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..4e5dd55c4 --- /dev/null +++ b/core/baker-openapi/baker-openapi-emitter/pom.xml @@ -0,0 +1,79 @@ + + + 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/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${jvm.target} + + + + compile + process-sources + compile + + + test-compile + test-compile + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + 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-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..c907b1dbc --- /dev/null +++ b/core/baker-openapi/baker-openapi-plugin/src/main/kotlin/com/ing/baker/openapi/plugin/GenerateFromOpenApiMojo.kt @@ -0,0 +1,102 @@ +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.KotlinIrEmitter +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( + KotlinIrEmitter(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, + // We supply KotlinIrEmitter explicitly above; convert() ignores this + // flag, but ConverterArguments requires it since wirespec 0.18.x. + ir = 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-wirespec/pom.xml b/core/baker-openapi/baker-openapi-wirespec/pom.xml new file mode 100644 index 000000000..e0d27cfad --- /dev/null +++ b/core/baker-openapi/baker-openapi-wirespec/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + com.ing.baker + baker-openapi + 5.1.0-SNAPSHOT + ../pom.xml + + + baker-openapi-wirespec + Baker OpenAPI Wirespec + Wirespec-integration runtime for baker-openapi: 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-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt b/core/baker-openapi/baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt new file mode 100644 index 000000000..375a9bd00 --- /dev/null +++ b/core/baker-openapi/baker-openapi-wirespec/src/main/kotlin/com/ing/baker/openapi/wirespec/Transportation.kt @@ -0,0 +1,46 @@ +package com.ing.baker.openapi.wirespec + +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..813f94c77 --- /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.18.13 + + + + baker-openapi-dsl + baker-openapi-emitter + baker-openapi-plugin + baker-openapi-wirespec + + 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..024981685 --- /dev/null +++ b/examples/baker-openapi-example/pom.xml @@ -0,0 +1,181 @@ + + + 4.0.0 + + + com.ing.baker + baker + 5.1.0-SNAPSHOT + ../../pom.xml + + + baker-openapi-example + Baker OpenAPI Example + + + 0.18.13 + + + + + com.ing.baker + baker-openapi-dsl + ${project.version} + + + com.ing.baker + baker-openapi-wirespec + ${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 + + + community.flock.wirespec.integration + wiremock-jvm + ${wirespec.version} + 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..692f11730 --- /dev/null +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/AccountRecipe.kt @@ -0,0 +1,38 @@ +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 +import com.ing.baker.examples.account.openapi.generated.model.CreateAccountRequest + +@OptIn(ExperimentalDsl::class) +object AccountRecipe { + val apiRecipe = apiRecipe("OpenApiAccount") { + sensoryEvents { event() } + + api(CreateAccount) { + // 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 -> + 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..3244cab98 --- /dev/null +++ b/examples/baker-openapi-example/src/main/kotlin/com/ing/baker/examples/account/openapi/Events.kt @@ -0,0 +1,14 @@ +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 customerId: 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..8e9f4f749 --- /dev/null +++ b/examples/baker-openapi-example/src/main/openapi/account-api.json @@ -0,0 +1,86 @@ +{ + "openapi": "3.0.0", + "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": { + "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..2aca29ac4 --- /dev/null +++ b/examples/baker-openapi-example/src/test/kotlin/com/ing/baker/examples/account/openapi/AccountRecipeWireMockTest.kt @@ -0,0 +1,95 @@ +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.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 +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() } + + @Test + fun `recipe fires AccountCreated on 201`() = runBlocking { + + server.stubFor( + wirespec(CreateAccountEndpoint.api).willReturn( + CreateAccountEndpoint.Response201( + AccountDto( + accountId = "a1", + userId = "u1", + profileId = "p1", + iban = "NL00", + accountType = "CURRENT", + currency = "EUR", + ) + ) + ) + ) + + val baker = InMemoryBaker.kotlin( + implementations = AccountRecipe.apiRecipe.toInteractionInstances( + transport = transport, + serialization = serialization, + ), + ) + 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( + 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") + // The customerId ingredient was renamed to userId for the API call — + // proves the inputFrom mapper wired the value through. + server.verify( + postRequestedFor(urlEqualTo("/accounts")) + .withRequestBody(matchingJsonPath("$.userId", equalTo("u1"))) + ) + } +} 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