Skip to content

Commit 1937cc2

Browse files
committed
feat: add custom log property to resources and interceptors.
1 parent f9cd37e commit 1937cc2

File tree

6 files changed

+257
-2
lines changed

6 files changed

+257
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ node_modules/
1212
.imposter
1313
bin/
1414
/.vscode/
15+
/CLAUDE.md

core/api/src/main/java/io/gatehill/imposter/plugin/config/resource/AbstractResourceConfig.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import io.gatehill.imposter.plugin.config.capture.CaptureConfigHolder
4848
import io.gatehill.imposter.plugin.config.capture.ItemCaptureConfig
4949
import io.gatehill.imposter.plugin.config.security.SecurityConfig
5050
import io.gatehill.imposter.plugin.config.security.SecurityConfigHolder
51-
import java.util.UUID
51+
import java.util.*
5252

5353
/**
5454
* Base configuration for plugins and sub-resources.
@@ -68,6 +68,9 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde
6868
@JsonProperty("response")
6969
override val responseConfig = ResponseConfig()
7070

71+
@JsonProperty("log")
72+
var log: String? = null
73+
7174
@JsonIgnore
7275
override var isInterceptor: Boolean = false
7376

@@ -78,6 +81,6 @@ abstract class AbstractResourceConfig : BasicResourceConfig, SecurityConfigHolde
7881
override val resourceId by lazy { UUID.randomUUID().toString() }
7982

8083
override fun toString(): String {
81-
return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, continueToNext=$continueToNext)"
84+
return "AbstractResourceConfig(path=$path, securityConfig=$securityConfig, captureConfig=$captureConfig, responseConfig=$responseConfig, log=$log, continueToNext=$continueToNext)"
8285
}
8386
}

core/engine/src/main/java/io/gatehill/imposter/service/ResponseServiceImpl.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import io.gatehill.imposter.lifecycle.EngineLifecycleHooks
5050
import io.gatehill.imposter.lifecycle.EngineLifecycleListener
5151
import io.gatehill.imposter.plugin.config.ContentTypedConfig
5252
import io.gatehill.imposter.plugin.config.PluginConfig
53+
import io.gatehill.imposter.plugin.config.resource.AbstractResourceConfig
5354
import io.gatehill.imposter.plugin.config.resource.BasicResourceConfig
5455
import io.gatehill.imposter.plugin.config.resource.ResourceConfig
5556
import io.gatehill.imposter.script.ResponseBehaviour
@@ -227,6 +228,17 @@ class ResponseServiceImpl @Inject constructor(
227228
} else {
228229
origResponseData
229230
}
231+
232+
// Process custom log message if present
233+
if (resourceConfig is AbstractResourceConfig && resourceConfig.log != null) {
234+
val logMessage = if (template) {
235+
PlaceholderUtil.replace(resourceConfig.log!!, httpExchange, PlaceholderUtil.templateEvaluators)
236+
} else {
237+
resourceConfig.log!!
238+
}
239+
LOGGER.info("Resource log: {}", logMessage)
240+
}
241+
230242
response.end(responseData)
231243
}
232244

docs/metrics_logs_telemetry.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,39 @@ Imposter can log a JSON summary of each request, such as the following:
8989

9090
To enable this, set the environment variable `IMPOSTER_LOG_SUMMARY=true`.
9191

92+
### Resource and interceptor logging
93+
94+
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.
95+
96+
Add a `log` property to any resource or interceptor:
97+
98+
```yaml
99+
plugin: rest
100+
resources:
101+
- path: /users/{id}
102+
method: GET
103+
log: "User lookup for ID: ${context.request.pathParams.id} from ${context.request.headers.X-Client-ID:-unknown client}"
104+
response:
105+
content: '{"id": "${context.request.pathParams.id}", "name": "Test User"}'
106+
statusCode: 200
107+
template: true
108+
109+
interceptors:
110+
- path: /secured/*
111+
method: GET
112+
log: "Secured endpoint accessed by ${context.request.headers.User-Agent} with trace ID: ${context.request.headers.X-Trace-ID:-none provided}"
113+
response:
114+
statusCode: 401
115+
content: "Unauthorized"
116+
continue: false
117+
```
118+
119+
The log message supports all template features including:
120+
- Path parameters, query parameters, and headers from the request
121+
- Random data generation and date/time functions
122+
- Default values with the `:-` syntax
123+
- JSON and XML processing with JSONPath and XPath
124+
92125
#### Logging request/response headers
93126

94127
You can optionally include request and response headers in the JSON summary such as:
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright (c) 2024.
3+
*
4+
* This file is part of Imposter.
5+
*
6+
* "Commons Clause" License Condition v1.0
7+
*
8+
* The Software is provided to you by the Licensor under the License, as
9+
* defined below, subject to the following condition.
10+
*
11+
* Without limiting other conditions in the License, the grant of rights
12+
* under the License will not include, and the License does not grant to
13+
* you, the right to Sell the Software.
14+
*
15+
* For purposes of the foregoing, "Sell" means practicing any or all of
16+
* the rights granted to you under the License to provide to third parties,
17+
* for a fee or other consideration (including without limitation fees for
18+
* hosting or consulting/support services related to the Software), a
19+
* product or service whose value derives, entirely or substantially, from
20+
* the functionality of the Software. Any license notice or attribution
21+
* required by the License must also include this Commons Clause License
22+
* Condition notice.
23+
*
24+
* Software: Imposter
25+
*
26+
* License: GNU Lesser General Public License version 3
27+
*
28+
* Licensor: Peter Cornish
29+
*
30+
* Imposter is free software: you can redistribute it and/or modify
31+
* it under the terms of the GNU Lesser General Public License as published by
32+
* the Free Software Foundation, either version 3 of the License, or
33+
* (at your option) any later version.
34+
*
35+
* Imposter is distributed in the hope that it will be useful,
36+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
37+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
38+
* GNU Lesser General Public License for more details.
39+
*
40+
* You should have received a copy of the GNU Lesser General Public License
41+
* along with Imposter. If not, see <https://www.gnu.org/licenses/>.
42+
*/
43+
package io.gatehill.imposter.server
44+
45+
import io.gatehill.imposter.plugin.test.TestPluginImpl
46+
import io.gatehill.imposter.service.ResponseServiceImpl
47+
import io.restassured.RestAssured
48+
import io.vertx.core.Vertx
49+
import io.vertx.junit5.VertxTestContext
50+
import org.apache.logging.log4j.Level
51+
import org.apache.logging.log4j.LogManager
52+
import org.apache.logging.log4j.core.LogEvent
53+
import org.apache.logging.log4j.core.Logger
54+
import org.apache.logging.log4j.core.appender.AbstractAppender
55+
import org.apache.logging.log4j.core.config.Property
56+
import org.apache.logging.log4j.core.layout.PatternLayout
57+
import org.hamcrest.Matchers.equalTo
58+
import org.junit.jupiter.api.AfterEach
59+
import org.junit.jupiter.api.Assertions.assertTrue
60+
import org.junit.jupiter.api.BeforeEach
61+
import org.junit.jupiter.api.Test
62+
import java.util.*
63+
import java.util.concurrent.CopyOnWriteArrayList
64+
65+
/**
66+
* Tests for resource logging functionality.
67+
*/
68+
class ResourceLogTest : BaseVerticleTest() {
69+
override val pluginClass = TestPluginImpl::class.java
70+
71+
private lateinit var memoryAppender: MemoryAppender
72+
private lateinit var logger: Logger
73+
74+
@BeforeEach
75+
override fun setUp(vertx: Vertx, testContext: VertxTestContext) {
76+
super.setUp(vertx, testContext)
77+
RestAssured.baseURI = "http://$host:$listenPort"
78+
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails()
79+
80+
// Setup logging capture
81+
logger = LogManager.getLogger(ResponseServiceImpl::class.java) as Logger
82+
memoryAppender = MemoryAppender("MemoryAppender-${UUID.randomUUID()}")
83+
memoryAppender.start()
84+
logger.addAppender(memoryAppender)
85+
}
86+
87+
@AfterEach
88+
fun tearDownLogger() {
89+
logger.removeAppender(memoryAppender)
90+
memoryAppender.stop()
91+
}
92+
93+
override val testConfigDirs = listOf(
94+
"/resource-log"
95+
)
96+
97+
@Test
98+
fun `resource should log message with path parameters`() {
99+
// Clear logs before test
100+
memoryAppender.clear()
101+
102+
// Make the request
103+
RestAssured.given().`when`()
104+
.get("/resource-log/123")
105+
.then()
106+
.statusCode(200)
107+
.body(equalTo("resource_logged"))
108+
109+
// Verify log message was captured
110+
val logEvents = memoryAppender.getLogEvents()
111+
assertTrue(logEvents.any { event ->
112+
event.level == Level.INFO &&
113+
event.message.formattedMessage.contains("Resource log: Resource log message for ID: 123")
114+
}, "Expected log message with path parameter not found")
115+
}
116+
117+
@Test
118+
fun `interceptor should log message`() {
119+
// Clear logs before test
120+
memoryAppender.clear()
121+
122+
// Make the request
123+
RestAssured.given().`when`()
124+
.get("/interceptor-log")
125+
.then()
126+
.statusCode(401)
127+
.body(equalTo("Unauthorized"))
128+
129+
// Verify log message was captured
130+
val logEvents = memoryAppender.getLogEvents()
131+
assertTrue(logEvents.any { event ->
132+
event.level == Level.INFO &&
133+
event.message.formattedMessage.contains("Resource log: Interceptor log message")
134+
}, "Expected interceptor log message not found")
135+
}
136+
137+
@Test
138+
fun `request without log property should not log custom message`() {
139+
// Clear logs before test
140+
memoryAppender.clear()
141+
142+
// Make the request to a path without log property
143+
RestAssured.given().`when`()
144+
.get("/simple")
145+
.then()
146+
.statusCode(200)
147+
.body(equalTo("simple"))
148+
149+
// Verify no custom log message was captured
150+
val logEvents = memoryAppender.getLogEvents()
151+
assertTrue(logEvents.none { event ->
152+
event.level == Level.INFO &&
153+
event.message.formattedMessage.contains("Resource log:")
154+
}, "Custom log message found when none was expected")
155+
}
156+
157+
/**
158+
* Custom in-memory appender for capturing log events during tests.
159+
*/
160+
class MemoryAppender(name: String) : AbstractAppender(
161+
name,
162+
null,
163+
PatternLayout.createDefaultLayout(),
164+
false,
165+
Property.EMPTY_ARRAY
166+
) {
167+
private val logEvents = CopyOnWriteArrayList<LogEvent>()
168+
169+
override fun append(event: LogEvent) {
170+
logEvents.add(event.toImmutable())
171+
}
172+
173+
fun getLogEvents(): List<LogEvent> = logEvents.toList()
174+
175+
fun clear() {
176+
logEvents.clear()
177+
}
178+
}
179+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
plugin: "io.gatehill.imposter.plugin.test.TestPluginImpl"
2+
3+
interceptors:
4+
- path: /interceptor-log
5+
method: GET
6+
log: "Interceptor log message"
7+
continue: false
8+
response:
9+
statusCode: 401
10+
content: "Unauthorized"
11+
12+
resources:
13+
- path: /resource-log/{id}
14+
method: GET
15+
log: "Resource log message for ID: ${context.request.pathParams.id}"
16+
response:
17+
content: "resource_logged"
18+
19+
- path: /simple
20+
method: GET
21+
response:
22+
content: "simple"
23+
24+
- path: /*
25+
method: GET
26+
response:
27+
content: "default"

0 commit comments

Comments
 (0)