From ab761ffe275e7c18c5e962e5f1a3fb9800a3fc52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:34:09 +0000 Subject: [PATCH 1/2] Add jar publishing, README, controller-layer curl logging, and Gradle 9/Java 25 upgrade - Add GitHub Actions workflows: CI build/test and publish-to-GitHub-Packages on release - Add group/version/maven-publish config to build.gradle.kts for the GitHub Packages target - Add README documenting the library, installation, and configuration - Extract curl-building logic into a shared CurlCommandBuilder, fixing a bug where the request body's Optional wrapper was serialized instead of its contents - Add CurlServerRequestFilter to log curl commands for inbound @Controller requests, independently toggleable from the existing @Client filter via curl-logger.client.enabled and curl-logger.server.enabled - Upgrade Gradle wrapper to 9.6.1, Kotlin to 2.4.0, and target bytecode/toolchain to Java 25 --- .github/workflows/ci.yml | 24 ++++++++ .github/workflows/publish.yml | 34 +++++++++++ README.md | 60 +++++++++++++++++++ build.gradle.kts | 47 +++++++++++---- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 4 ++ .../CurlCommandBuilder.kt | 38 ++++++++++++ .../CurlRequestFilter.kt | 57 ++++-------------- .../CurlServerRequestFilter.kt | 30 ++++++++++ 10 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 README.md create mode 100644 src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlCommandBuilder.kt create mode 100644 src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlServerRequestFilter.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..595ebf2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build and test + run: ./gradlew build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..01460b4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish + +on: + release: + types: [published] + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Resolve version from release tag + id: version + run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Publish to GitHub Packages + run: ./gradlew publish -PpublishVersion=${{ steps.version.outputs.value }} + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..60d89ce --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# micronaut-httpclient2curl + +A small Micronaut library that logs HTTP requests as ready-to-run `curl` commands, so you can copy a failing request straight out of your logs and replay it from a terminal. + +It ships two filters: + +- **`CurlRequestFilter`** — an `HttpClientFilter` that logs *outgoing* requests made through declarative `@Client` interfaces. +- **`CurlServerRequestFilter`** — an `HttpServerFilter` that logs *inbound* requests hitting `@Controller` endpoints. + +Both filters build the curl command from the same logic (method, headers, body, URL) and print it to stdout, wrapped in separator lines, e.g.: + +``` +============================================================== +curl -X POST -H "Content-Type:application/json" -d '{"name":"Ada"}' "http://localhost:8080/api/applications/1" +============================================================== +``` + +Both filters are currently scoped to requests matching `/api/applications/**`. + +## Requirements + +Compiled targeting Java 25 bytecode — consumers need JDK 25+ at runtime. + +## Installation + +Published to GitHub Packages. Add the repository and dependency to your Gradle build: + +```kotlin +repositories { + maven { + url = uri("https://maven.pkg.github.com/codeyogico/micronaut-httpclient2curl") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") // needs read:packages scope + } + } +} + +dependencies { + implementation("com.codeyogico:micronaut-httpclient2curl:") +} +``` + +GitHub Packages requires authentication even for public repositories, so consumers need a token with `read:packages` scope. + +## Configuration + +Both filters are enabled by default and can be turned off independently via `application.yml`: + +```yaml +curl-logger: + client: + enabled: false # disable logging for outgoing @Client requests + server: + enabled: false # disable logging for inbound @Controller requests +``` + +## Releasing + +Publishing runs via the `Publish` GitHub Actions workflow (`.github/workflows/publish.yml`), triggered when a GitHub Release is published. The release tag (e.g. `v1.2.0`) is used as the published artifact version (`1.2.0`). diff --git a/build.gradle.kts b/build.gradle.kts index 7e3863c..dee90d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + buildscript { repositories { mavenLocal() @@ -8,13 +10,22 @@ buildscript { plugins { - kotlin("jvm") version "1.6.10" + kotlin("jvm") version "2.4.0" `maven-publish` jacoco - id("org.jetbrains.dokka") version "1.6.10" apply false + id("org.jetbrains.dokka") version "1.9.20" apply false +} +val targetJvm = 25 + +kotlin { + jvmToolchain(targetJvm) + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(targetJvm.toString())) + } } -val targetJvm = 11 +group = "com.codeyogico" +version = (findProperty("publishVersion") as String?) ?: "0.1.0-SNAPSHOT" repositories { mavenLocal() @@ -46,18 +57,30 @@ dependencies { } tasks { - compileKotlin { - kotlinOptions { - jvmTarget = targetJvm.toString() - } + test { + useJUnitPlatform() } - compileTestKotlin { - kotlinOptions { - jvmTarget = "11" +} + +java { + withSourcesJar() +} + +publishing { + publications { + create("maven") { + from(components["java"]) } } - test { - useJUnitPlatform() + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/codeyogico/micronaut-httpclient2curl") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2eef61e..1eacf9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] junit="5.9.2" -kotlin="1.8.0" +kotlin="2.4.0" micronaut="3.9.1" kotlinCoroutines="1.6.2" jackson="2.13.3" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ebc84d3..f0861f6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,6 +3,6 @@ ## distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index fca29a3..ec804a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + rootProject.name = "micronaut-httpclient2curl" diff --git a/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlCommandBuilder.kt b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlCommandBuilder.kt new file mode 100644 index 0000000..f9f5d3e --- /dev/null +++ b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlCommandBuilder.kt @@ -0,0 +1,38 @@ +package com.codeyogico.micronauthttpclient2curl + +import com.fasterxml.jackson.databind.ObjectMapper +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest + +internal object CurlCommandBuilder { + + private const val FORMAT_HEADER = "-H \"%1\$s:%2\$s\"" + private const val FORMAT_METHOD = "-X %1\$s" + private const val FORMAT_BODY = "-d '%1\$s'" + private const val FORMAT_URL = "\"%1\$s\"" + + fun build(request: HttpRequest<*>, objectMapper: ObjectMapper): String = + listOf( + listOf("curl"), + generateMethod(request.method), + generateHeaders(request.headers), + getBodyAsString(request, objectMapper), + generateUrl(request) + ).flatten().joinToString(" ") + + private fun getBodyAsString(request: HttpRequest<*>, objectMapper: ObjectMapper): List { + val body = request.body + return if (body.isEmpty) + emptyList() + else + listOf(FORMAT_BODY.format(objectMapper.writeValueAsString(body.get()))) + } + + private fun generateUrl(request: HttpRequest<*>): List = listOf(FORMAT_URL.format(request.uri.toString())) + + private fun generateHeaders(headers: HttpHeaders): List = + headers.map { FORMAT_HEADER.format(it.key, it.value.firstOrNull().orEmpty()) } + + private fun generateMethod(method: HttpMethod): List = listOf(FORMAT_METHOD.format(method)) +} diff --git a/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlRequestFilter.kt b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlRequestFilter.kt index d0ce0be..377fa83 100644 --- a/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlRequestFilter.kt +++ b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlRequestFilter.kt @@ -1,61 +1,30 @@ package com.codeyogico.micronauthttpclient2curl import com.fasterxml.jackson.databind.ObjectMapper -import io.micronaut.http.HttpMethod +import io.micronaut.context.annotation.Requires import io.micronaut.http.HttpResponse -import io.micronaut.http.MutableHttpHeaders import io.micronaut.http.MutableHttpRequest import io.micronaut.http.annotation.Filter import io.micronaut.http.filter.ClientFilterChain import io.micronaut.http.filter.HttpClientFilter import org.reactivestreams.Publisher - +/** + * Logs outgoing requests made through declarative `@Client` interfaces as curl commands. + * Disable with `curl-logger.client.enabled: false`. + */ @Filter("/api/applications/**") -class CurlRequestFilter(private val objectMapper: ObjectMapper):HttpClientFilter { - override fun doFilter(request: MutableHttpRequest<*>, chain: ClientFilterChain): Publisher> { +@Requires(property = "curl-logger.client.enabled", value = "true", defaultValue = "true") +class CurlRequestFilter(private val objectMapper: ObjectMapper) : HttpClientFilter { - println("==============================================================") - val command = buildCurlCommand(request) - println(command) - println("==============================================================") + override fun doFilter(request: MutableHttpRequest<*>, chain: ClientFilterChain): Publisher> { + println(SEPARATOR) + println(CurlCommandBuilder.build(request, objectMapper)) + println(SEPARATOR) return chain.proceed(request) } - - - private fun buildCurlCommand(request: MutableHttpRequest<*>) = - - listOf(listOf("curl"),generateMethod(request.method),generateHeaders(request.headers),getBodyAsString(request),generateUrl(request)).flatten() - .joinToString(" ") - - - private fun getBodyAsString(request: MutableHttpRequest<*>):List{ - return if(request.body.isEmpty) - emptyList() - else - listOf(FORMAT_BODY.format( objectMapper.writeValueAsString(request.body))) - } - - private fun generateUrl(request: MutableHttpRequest<*>): List = listOf(FORMAT_URL.format(request.uri.toString())) - - private fun generateHeaders(headers: MutableHttpHeaders): List { - return headers.map { - FORMAT_HEADER.format(it.key, it.value.first()) - } - } - - private fun generateMethod(method: HttpMethod): List { - return listOf(FORMAT_METHOD.format(method)) - } - - private companion object { - const val FORMAT_HEADER = "-H \"%1\$s:%2\$s\"" - const val FORMAT_METHOD = "-X %1\$s" - const val FORMAT_BODY = "-d '%1\$s'" - const val FORMAT_URL = "\"%1\$s\"" - const val CONTENT_TYPE = "Content-Type" + const val SEPARATOR = "==============================================================" } - -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlServerRequestFilter.kt b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlServerRequestFilter.kt new file mode 100644 index 0000000..4c7b9b1 --- /dev/null +++ b/src/main/kotlin/com/codeyogico/micronauthttpclient2curl/CurlServerRequestFilter.kt @@ -0,0 +1,30 @@ +package com.codeyogico.micronauthttpclient2curl + +import com.fasterxml.jackson.databind.ObjectMapper +import io.micronaut.context.annotation.Requires +import io.micronaut.http.HttpRequest +import io.micronaut.http.MutableHttpResponse +import io.micronaut.http.annotation.Filter +import io.micronaut.http.filter.HttpServerFilter +import io.micronaut.http.filter.ServerFilterChain +import org.reactivestreams.Publisher + +/** + * Logs inbound requests hitting `@Controller` endpoints as curl commands. + * Disable with `curl-logger.server.enabled: false`. + */ +@Filter("/api/applications/**") +@Requires(property = "curl-logger.server.enabled", value = "true", defaultValue = "true") +class CurlServerRequestFilter(private val objectMapper: ObjectMapper) : HttpServerFilter { + + override fun doFilter(request: HttpRequest<*>, chain: ServerFilterChain): Publisher> { + println(SEPARATOR) + println(CurlCommandBuilder.build(request, objectMapper)) + println(SEPARATOR) + return chain.proceed(request) + } + + private companion object { + const val SEPARATOR = "==============================================================" + } +} From 9de1c4ac174a787cf11b8d21779db209a934a0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:39:44 +0000 Subject: [PATCH 2/2] Downgrade Kotlin to 2.3.0 and upgrade to Micronaut 4 - Kotlin 2.3.0 (still supports JVM target 25, confirmed by compilation) - Micronaut BOM moved to io.micronaut.platform:micronaut-platform 4.10.16 - micronaut-validation coordinate moved to the io.micronaut.validation group - Add micronaut-jackson-databind explicitly: Micronaut 4 no longer pulls Jackson in transitively via micronaut-http-server-netty (decoupled for pluggable serde), so CurlCommandBuilder's ObjectMapper usage needs it declared directly Verified by temporarily compiling with targetJvm=21 (only JDK available in this sandbox) against the new BOM before restoring targetJvm=25 for the committed config. --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index dee90d6..3ce87cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ buildscript { plugins { - kotlin("jvm") version "2.4.0" + kotlin("jvm") version "2.3.0" `maven-publish` jacoco id("org.jetbrains.dokka") version "1.9.20" apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1eacf9a..da592cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] junit="5.9.2" -kotlin="2.4.0" -micronaut="3.9.1" +kotlin="2.3.0" +micronaut="4.10.16" kotlinCoroutines="1.6.2" jackson="2.13.3" kotest = "5.5.4" @@ -14,9 +14,10 @@ kotest = "5.5.4" # boms junitBom= {module="org.junit:junit-bom", version.ref="junit"} jacksonBom={module="com.fasterxml.jackson:jackson-bom", version.ref="jackson"} -micronautBom={module="io.micronaut:micronaut-bom", version.ref="micronaut"} +micronautBom={module="io.micronaut.platform:micronaut-platform", version.ref="micronaut"} +micronautJacksonDatabind={module="io.micronaut:micronaut-jackson-databind"} micronautHttpClient={module="io.micronaut:micronaut-http-client"} micronautHttp={module="io.micronaut:micronaut-http"} micronautHttpServer={module="io.micronaut:micronaut-http-server"} @@ -29,7 +30,7 @@ micronautRouter={module="io.micronaut:micronaut-router"} micronautMicrometerCore={module="io.micronaut.micrometer:micronaut-micrometer-core"} micronautRxJava2={module="io.micronaut.rxjava2:micronaut-rxjava2"} micronautRxJava3={module="io.micronaut.rxjava3:micronaut-rxjava3"} -micronautValidation={module="io.micronaut:micronaut-validation"} +micronautValidation={module="io.micronaut.validation:micronaut-validation"} kotlinStdlib={module="org.jetbrains.kotlin:kotlin-stdlib-jdk8"} @@ -53,7 +54,7 @@ kotestExtensions = { module = "io.kotest:kotest-extensions", version.ref = "kote [bundles] -micronaut=["micronautHttpClient","micronautHttp","micronautHttpServer","micronautHttpServerNetty", +micronaut=["micronautJacksonDatabind","micronautHttpClient","micronautHttp","micronautHttpServer","micronautHttpServerNetty", "micronautRuntime","micronautManagement","micronautInject","micronautRouter","micronautMicrometerCore","micronautRxJava2"] micronautTest=["micronautTestCore","micronautTestJunit5"] kotest=["kotestAssertionsCore","kotestJunit5","kotestProperty","kotestExtensions","micronautTestKotest"]