diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6ef3bcf3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + build-and-test: + name: Build & Unit Tests (Java 21) + runs-on: ubuntu-latest + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Build and run unit tests + run: mvn --batch-mode --no-transfer-progress verify + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: surefire-reports + path: '**/target/surefire-reports/*.xml' + retention-days: 7 diff --git a/guides/APPENDIX-III.md b/guides/APPENDIX-III.md index 15d55240..418dbe1d 100644 --- a/guides/APPENDIX-III.md +++ b/guides/APPENDIX-III.md @@ -336,6 +336,131 @@ the parameters where parameters.query, body and cookies are optional. | body | HTTP request body for PUT, POST and PATCH | {"hello": "world"} | | cookies | Cookie key-value | cookies.session-id=12345 | +## Calling a public endpoint example + +The following pair of flows demonstrates calling an external public echo endpoint using GET and POST. +They can serve as a starting template for any outbound HTTP integration. + +**GET example** — forwards an optional `message` query parameter and returns the echo response: + +```yaml +flow: + id: 'ping-external' + description: 'Ping a public echo endpoint via AsyncHttpClient and return the response' + ttl: 15s + exception: 'ping.exception' + +first.task: 'ping.call' + +tasks: + - name: 'ping.call' + input: + - 'text(https://postman-echo.com) -> host' + - 'text(/get) -> url' + - 'text(GET) -> method' + - 'text(application/json) -> headers.accept' + - 'input.query_parameter.message -> parameters.query.message' + process: 'async.http.request' + output: + - 'text(application/json) -> output.header.content-type' + - 'result -> output.body' + description: 'GET https://postman-echo.com/get and return the echo response' + execution: end + + - name: 'ping.exception' + input: + - 'error.code -> status' + - 'error.message -> message' + process: 'v1.hello.exception' + output: + - 'result.status -> output.status' + - 'result -> output.body' + description: 'Return error details to the caller' + execution: end +``` + +**POST example** — forwards the caller's request body and returns the echo response: + +```yaml +flow: + id: 'ping-external-post' + description: 'POST a JSON body to a public echo endpoint via AsyncHttpClient and return the response' + ttl: 15s + exception: 'ping.post.exception' + +first.task: 'ping.post.call' + +tasks: + - name: 'ping.post.call' + input: + - 'text(https://postman-echo.com) -> host' + - 'text(/post) -> url' + - 'text(POST) -> method' + - 'text(application/json) -> headers.content-type' + - 'text(application/json) -> headers.accept' + - 'input.body -> body' + process: 'async.http.request' + output: + - 'text(application/json) -> output.header.content-type' + - 'result -> output.body' + description: 'POST request body to https://postman-echo.com/post and return the echo response' + execution: end + + - name: 'ping.post.exception' + input: + - 'error.code -> status' + - 'error.message -> message' + process: 'v1.hello.exception' + output: + - 'result.status -> output.status' + - 'result -> output.body' + description: 'Return error details to the caller' + execution: end +``` + +Note that `host` must be the origin only (scheme + domain, no path), and `url` is the path portion. +Query parameters can be forwarded from the caller via `input.query_parameter.` or set as +constants using `text(value) -> parameters.query.`. + +## HTTP trace logging + +`AsyncHttpClient` emits DEBUG-level log entries for every outbound HTTP request and its corresponding +response. Each direction is a single, complete log entry — the request is logged before the call is +made and the response after the full body has been received and assembled. + +A request log entry looks like: + +``` +DEBUG AsyncHttpClient - +>>> POST https://postman-echo.com/post + content-type: application/json + accept: application/json + body: {"message":"hello","from":"curl"} +``` + +A response log entry looks like: + +``` +DEBUG AsyncHttpClient - +<<< 200 + content-type: application/json; charset=utf-8 + content-length: 418 + body: {"args":{},"data":{"from":"curl","message":"hello"},...,"url":"https://postman-echo.com/post"} +``` + +Logging is controlled by the `AsyncHttpClient` logger level. Add the following to your `log4j2.xml`: + +```xml + + + +``` + +Set the `HTTP_TRACE_LEVEL` environment variable to `DEBUG` to enable tracing, or leave it unset +(defaults to `INFO`) to silence it. No code change or restart of the logging framework is needed — +only the environment variable controls the output. + ## Starting a flow programmatically To start an "event" flow from a unit test, you may use the helper class "FlowExecutor" under the "Event Script" module. diff --git a/guides/CONFIGURATION-REFERENCE.md b/guides/CONFIGURATION-REFERENCE.md index d2c0882b..c8b877b1 100644 --- a/guides/CONFIGURATION-REFERENCE.md +++ b/guides/CONFIGURATION-REFERENCE.md @@ -123,6 +123,7 @@ virtual threads. |-----|------|---------|-------------| | `async.http.temp` | `String` (path) | `/tmp/async-http-temp` | Temporary folder used to buffer large async HTTP response bodies. | | `http.client.connection.timeout` | `int` (ms) | `5000` | Connection timeout in milliseconds for the built-in async HTTP client. | +| `HTTP_TRACE_LEVEL` | `String` (env var) | `INFO` | Set to `DEBUG` to enable full HTTP request and response trace logging (method, URL, headers, body) via the `AsyncHttpClient` logger. Set to `INFO` or leave unset to silence. | --- diff --git a/system/platform-core/src/main/java/org/platformlambda/automation/http/AsyncHttpClient.java b/system/platform-core/src/main/java/org/platformlambda/automation/http/AsyncHttpClient.java index e067cfc6..5e2b9ba3 100644 --- a/system/platform-core/src/main/java/org/platformlambda/automation/http/AsyncHttpClient.java +++ b/system/platform-core/src/main/java/org/platformlambda/automation/http/AsyncHttpClient.java @@ -174,6 +174,9 @@ private void processRequest(Map headers, EventEnvelope input, in validateUrl(request); String uri = request.getFinalizedUrl(); po.annotateTrace(DESTINATION, request.getTargetHost() + getRawUrl(uri)); + if (log.isDebugEnabled()) { + logHttpRequest(request, uri); + } HttpClient client = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout) .headers(h -> updateHttpHeaders(po, request, h)); @@ -453,6 +456,35 @@ private void removeExpiredFiles() { } } + private void logHttpRequest(AsyncHttpRequest request, String uri) { + var sb = new StringBuilder(); + sb.append("\n>>> ").append(request.getMethod()).append(' ') + .append(request.getTargetHost()).append(uri).append('\n'); + request.getHeaders().forEach((k, v) -> + sb.append(" ").append(k).append(": ").append(v).append('\n')); + Object body = request.getBody(); + if (body instanceof byte[] b) { + sb.append(" body: [").append(b.length).append(" bytes]\n"); + } else if (body instanceof Map || body instanceof List) { + sb.append(" body: ") + .append(SimpleMapper.getInstance().getMapper().writeValueAsString(body)).append('\n'); + } else if (body instanceof String text && !text.isEmpty()) { + sb.append(" body: ").append(text).append('\n'); + } + log.debug(sb.toString()); + } + + private void logHttpResponse(EventEnvelope response, byte[] b) { + var sb = new StringBuilder(); + sb.append("\n<<< ").append(response.getStatus()).append('\n'); + response.getHeaders().forEach((k, v) -> + sb.append(" ").append(k).append(": ").append(v).append('\n')); + if (b != null && b.length > 0) { + sb.append(" body: ").append(Utility.getInstance().getUTF(b)).append('\n'); + } + log.debug(sb.toString()); + } + private class HttpResponseHandler { private final Utility util = Utility.getInstance(); private final CustomContentTypeResolver resolver = CustomContentTypeResolver.getInstance(); @@ -535,6 +567,9 @@ private void sendStreamResponse(EventEnvelope response, InputStream stream) { } private void sendFixedLengthResponse(String resContentType, EventEnvelope response, byte[] b) { + if (log.isDebugEnabled()) { + logHttpResponse(response, b); + } if (resContentType != null) { if (resContentType.startsWith(APPLICATION_JSON)) { sendJsonResponse(response, b);