diff --git a/.gitignore b/.gitignore index 7fbcde3bd..edced24aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ .imposter bin/ /.vscode/ +/CLAUDE.md diff --git a/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt b/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt index f3bfd8a46..5035054d1 100644 --- a/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt +++ b/core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt @@ -48,7 +48,7 @@ import io.gatehill.imposter.plugin.config.capture.CaptureConfigHolder import io.gatehill.imposter.plugin.config.capture.ItemCaptureConfig import io.gatehill.imposter.plugin.config.security.SecurityConfig import io.gatehill.imposter.plugin.config.security.SecurityConfigHolder -import java.util.UUID +import java.util.* /** * Base configuration for plugins and sub-resources. @@ -68,6 +68,9 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde @JsonProperty("response") override val responseConfig = ResponseConfig() + @JsonProperty("log") + var log: String? = null + @JsonIgnore override var isInterceptor: Boolean = false @@ -78,6 +81,6 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde override val resourceId by lazy { UUID.randomUUID().toString() } override fun toString(): String { - return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, continueToNext=$continueToNext)" + return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, log=$log, continueToNext=$continueToNext)" } } diff --git a/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt b/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt index 1bb950fa7..ea47413c1 100644 --- a/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt +++ b/core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt @@ -50,6 +50,7 @@ import io.gatehill.imposter.lifecycle.EngineLifecycleHooks import io.gatehill.imposter.lifecycle.EngineLifecycleListener import io.gatehill.imposter.plugin.config.ContentTypedConfig import io.gatehill.imposter.plugin.config.PluginConfig +import io.gatehill.imposter.plugin.config.resource.AbstractResourceConfig import io.gatehill.imposter.plugin.config.resource.BasicResourceConfig import io.gatehill.imposter.plugin.config.resource.ResourceConfig import io.gatehill.imposter.script.ResponseBehaviour @@ -227,6 +228,17 @@ class ResponseServiceImpl @Inject constructor( } else { origResponseData } + + // Process custom log message if present + if (resourceConfig is AbstractResourceConfig && resourceConfig.log != null) { + val logMessage = if (template) { + PlaceholderUtil.replace(resourceConfig.log!!, httpExchange, PlaceholderUtil.templateEvaluators) + } else { + resourceConfig.log!! + } + LOGGER.info("Resource log: {}", logMessage) + } + response.end(responseData) } diff --git a/docs/metrics_logs_telemetry.md b/docs/metrics_logs_telemetry.md index ae03375da..f2fff1404 100644 --- a/docs/metrics_logs_telemetry.md +++ b/docs/metrics_logs_telemetry.md @@ -89,6 +89,39 @@ Imposter can log a JSON summary of each request, such as the following: To enable this, set the environment variable `IMPOSTER_LOG_SUMMARY=true`. +### Resource and interceptor logging + +Resources and interceptors can include custom log messages that are processed using the template engine. This allows you to log contextual information about requests that match your resources. + +Add a `log` property to any resource or interceptor: + +```yaml +plugin: rest +resources: + - path: /users/{id} + method: GET + log: "User lookup for ID: ${context.request.pathParams.id} from ${context.request.headers.X-Client-ID:-unknown client}" + response: + content: '{"id": "${context.request.pathParams.id}", "name": "Test User"}' + statusCode: 200 + template: true + +interceptors: + - path: /secured/* + method: GET + log: "Secured endpoint accessed by ${context.request.headers.User-Agent} with trace ID: ${context.request.headers.X-Trace-ID:-none provided}" + response: + statusCode: 401 + content: "Unauthorized" + continue: false +``` + +The log message supports all template features including: +- Path parameters, query parameters, and headers from the request +- Random data generation and date/time functions +- Default values with the `:-` syntax +- JSON and XML processing with JSONPath and XPath + #### Logging request/response headers You can optionally include request and response headers in the JSON summary such as: diff --git a/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt b/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt new file mode 100644 index 000000000..c06da063f --- /dev/null +++ b/server/src/test/java/io/gatehill/imposter/server/ResourceLogTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2024. + * + * This file is part of Imposter. + * + * "Commons Clause" License Condition v1.0 + * + * The Software is provided to you by the Licensor under the License, as + * defined below, subject to the following condition. + * + * Without limiting other conditions in the License, the grant of rights + * under the License will not include, and the License does not grant to + * you, the right to Sell the Software. + * + * For purposes of the foregoing, "Sell" means practicing any or all of + * the rights granted to you under the License to provide to third parties, + * for a fee or other consideration (including without limitation fees for + * hosting or consulting/support services related to the Software), a + * product or service whose value derives, entirely or substantially, from + * the functionality of the Software. Any license notice or attribution + * required by the License must also include this Commons Clause License + * Condition notice. + * + * Software: Imposter + * + * License: GNU Lesser General Public License version 3 + * + * Licensor: Peter Cornish + * + * Imposter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Imposter is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Imposter. If not, see . + */ +package io.gatehill.imposter.server + +import io.gatehill.imposter.plugin.test.TestPluginImpl +import io.gatehill.imposter.service.ResponseServiceImpl +import io.restassured.RestAssured +import io.vertx.core.Vertx +import io.vertx.junit5.VertxTestContext +import org.apache.logging.log4j.Level +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.core.LogEvent +import org.apache.logging.log4j.core.Logger +import org.apache.logging.log4j.core.appender.AbstractAppender +import org.apache.logging.log4j.core.config.Property +import org.apache.logging.log4j.core.layout.PatternLayout +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Tests for resource logging functionality. + */ +class ResourceLogTest : BaseVerticleTest() { + override val pluginClass = TestPluginImpl::class.java + + private lateinit var memoryAppender: MemoryAppender + private lateinit var logger: Logger + + @BeforeEach + override fun setUp(vertx: Vertx, testContext: VertxTestContext) { + super.setUp(vertx, testContext) + RestAssured.baseURI = "http://$host:$listenPort" + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails() + + // Setup logging capture + logger = LogManager.getLogger(ResponseServiceImpl::class.java) as Logger + memoryAppender = MemoryAppender("MemoryAppender-${UUID.randomUUID()}") + memoryAppender.start() + logger.addAppender(memoryAppender) + } + + @AfterEach + fun tearDownLogger() { + logger.removeAppender(memoryAppender) + memoryAppender.stop() + } + + override val testConfigDirs = listOf( + "/resource-log" + ) + + @Test + fun `resource should log message with path parameters`() { + // Clear logs before test + memoryAppender.clear() + + // Make the request + RestAssured.given().`when`() + .get("/resource-log/123") + .then() + .statusCode(200) + .body(equalTo("resource_logged")) + + // Verify log message was captured + val logEvents = memoryAppender.getLogEvents() + assertTrue(logEvents.any { event -> + event.level == Level.INFO && + event.message.formattedMessage.contains("Resource log: Resource log message for ID: 123") + }, "Expected log message with path parameter not found") + } + + @Test + fun `interceptor should log message`() { + // Clear logs before test + memoryAppender.clear() + + // Make the request + RestAssured.given().`when`() + .get("/interceptor-log") + .then() + .statusCode(401) + .body(equalTo("Unauthorized")) + + // Verify log message was captured + val logEvents = memoryAppender.getLogEvents() + assertTrue(logEvents.any { event -> + event.level == Level.INFO && + event.message.formattedMessage.contains("Resource log: Interceptor log message") + }, "Expected interceptor log message not found") + } + + @Test + fun `request without log property should not log custom message`() { + // Clear logs before test + memoryAppender.clear() + + // Make the request to a path without log property + RestAssured.given().`when`() + .get("/simple") + .then() + .statusCode(200) + .body(equalTo("simple")) + + // Verify no custom log message was captured + val logEvents = memoryAppender.getLogEvents() + assertTrue(logEvents.none { event -> + event.level == Level.INFO && + event.message.formattedMessage.contains("Resource log:") + }, "Custom log message found when none was expected") + } + + /** + * Custom in-memory appender for capturing log events during tests. + */ + class MemoryAppender(name: String) : AbstractAppender( + name, + null, + PatternLayout.createDefaultLayout(), + false, + Property.EMPTY_ARRAY + ) { + private val logEvents = CopyOnWriteArrayList() + + override fun append(event: LogEvent) { + logEvents.add(event.toImmutable()) + } + + fun getLogEvents(): List = logEvents.toList() + + fun clear() { + logEvents.clear() + } + } +} \ No newline at end of file diff --git a/server/src/test/resources/resource-log/test-plugin-config.yaml b/server/src/test/resources/resource-log/test-plugin-config.yaml new file mode 100644 index 000000000..9225cd6c9 --- /dev/null +++ b/server/src/test/resources/resource-log/test-plugin-config.yaml @@ -0,0 +1,27 @@ +plugin: "io.gatehill.imposter.plugin.test.TestPluginImpl" + +interceptors: +- path: /interceptor-log + method: GET + log: "Interceptor log message" + continue: false + response: + statusCode: 401 + content: "Unauthorized" + +resources: +- path: /resource-log/{id} + method: GET + log: "Resource log message for ID: ${context.request.pathParams.id}" + response: + content: "resource_logged" + +- path: /simple + method: GET + response: + content: "simple" + +- path: /* + method: GET + response: + content: "default" \ No newline at end of file