Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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 }}
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:<version>")
}
```

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`).
47 changes: 35 additions & 12 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

buildscript {
repositories {
mavenLocal()
Expand All @@ -8,13 +10,22 @@ buildscript {


plugins {
kotlin("jvm") version "1.6.10"
kotlin("jvm") version "2.3.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()
Expand Down Expand Up @@ -46,18 +57,30 @@ dependencies {
}

tasks {
compileKotlin {
kotlinOptions {
jvmTarget = targetJvm.toString()
}
test {
useJUnitPlatform()
}
compileTestKotlin {
kotlinOptions {
jvmTarget = "11"
}

java {
withSourcesJar()
}

publishing {
publications {
create<MavenPublication>("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")
}
}
}
}

11 changes: 6 additions & 5 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[versions]

junit="5.9.2"
kotlin="1.8.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"
Expand All @@ -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"}
Expand All @@ -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"}
Expand All @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

rootProject.name = "micronaut-httpclient2curl"
Original file line number Diff line number Diff line change
@@ -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<String> {
val body = request.body
return if (body.isEmpty)
emptyList()
else
listOf(FORMAT_BODY.format(objectMapper.writeValueAsString(body.get())))
}

private fun generateUrl(request: HttpRequest<*>): List<String> = listOf(FORMAT_URL.format(request.uri.toString()))

private fun generateHeaders(headers: HttpHeaders): List<String> =
headers.map { FORMAT_HEADER.format(it.key, it.value.firstOrNull().orEmpty()) }

private fun generateMethod(method: HttpMethod): List<String> = listOf(FORMAT_METHOD.format(method))
}
Original file line number Diff line number Diff line change
@@ -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<out HttpResponse<*>> {
@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<out HttpResponse<*>> {
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<String>{
return if(request.body.isEmpty)
emptyList()
else
listOf(FORMAT_BODY.format( objectMapper.writeValueAsString(request.body)))
}

private fun generateUrl(request: MutableHttpRequest<*>): List<String> = listOf(FORMAT_URL.format(request.uri.toString()))

private fun generateHeaders(headers: MutableHttpHeaders): List<String> {
return headers.map {
FORMAT_HEADER.format(it.key, it.value.first())
}
}

private fun generateMethod(method: HttpMethod): List<String> {
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 = "=============================================================="
}

}
}
Original file line number Diff line number Diff line change
@@ -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<MutableHttpResponse<*>> {
println(SEPARATOR)
println(CurlCommandBuilder.build(request, objectMapper))
println(SEPARATOR)
return chain.proceed(request)
}

private companion object {
const val SEPARATOR = "=============================================================="
}
}
Loading