Skip to content

Commit 81ef526

Browse files
authored
Idempotency plugin (membrane#1849)
* add Idempotency interceptor * refactor code * add tests * resolve conversations * add docs * resolve conversations * resolve conversations
1 parent 304ea42 commit 81ef526

File tree

2 files changed

+178
-0
lines changed

2 files changed

+178
-0
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/* Copyright 2025 predic8 GmbH, www.predic8.com
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License. */
14+
15+
package com.predic8.membrane.core.interceptor.idempotency;
16+
17+
import com.predic8.membrane.annot.MCAttribute;
18+
import com.predic8.membrane.annot.MCElement;
19+
import com.predic8.membrane.annot.Required;
20+
import com.predic8.membrane.core.exchange.Exchange;
21+
import com.predic8.membrane.core.interceptor.AbstractInterceptor;
22+
import com.predic8.membrane.core.interceptor.Outcome;
23+
import com.predic8.membrane.core.lang.ExchangeExpression;
24+
import com.predic8.membrane.core.lang.ExchangeExpression.Language;
25+
26+
import java.util.Map;
27+
import java.util.concurrent.ConcurrentHashMap;
28+
29+
import static com.predic8.membrane.core.exceptions.ProblemDetails.user;
30+
import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
31+
import static com.predic8.membrane.core.interceptor.Outcome.ABORT;
32+
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
33+
import static com.predic8.membrane.core.lang.ExchangeExpression.Language.SPEL;
34+
35+
/**
36+
* @description <p>Prevents duplicate request processing based on a dynamic idempotency key.</p>
37+
*
38+
* <p>This interceptor evaluates an expression (e.g., from headers or body) to extract an idempotency key.
39+
* If the key has already been processed, it aborts the request with a 400 response.</p>
40+
*
41+
* <p>Useful for handling retries from clients to avoid duplicate side effects like double payment submissions.</p>
42+
* @topic 3. Security and Validation
43+
*/
44+
@MCElement(name = "idempotency")
45+
public class IdempotencyInterceptor extends AbstractInterceptor {
46+
47+
private String key;
48+
private ExchangeExpression exchangeExpression;
49+
private Language language = SPEL;
50+
private final Map<String, Boolean> processedKeys = new ConcurrentHashMap<>();
51+
52+
@Override
53+
public void init() {
54+
super.init();
55+
exchangeExpression = ExchangeExpression.newInstance(router, language, key);
56+
}
57+
58+
@Override
59+
public Outcome handleRequest(Exchange exc) {
60+
String key = normalizeKey(exchangeExpression.evaluate(exc, REQUEST, String.class));
61+
if (key.isEmpty()) {
62+
return CONTINUE;
63+
}
64+
65+
if (processedKeys.containsKey(key)) {
66+
return handleDuplicateKey(exc, key);
67+
}
68+
69+
if (processedKeys.putIfAbsent(key, Boolean.TRUE) != null) {
70+
return handleDuplicateKey(exc, key);
71+
}
72+
return CONTINUE;
73+
}
74+
75+
private String normalizeKey(String key) {
76+
return key == null ? "" : key;
77+
}
78+
79+
private Outcome handleDuplicateKey(Exchange exc, String key) {
80+
user(false, "idempotency")
81+
.statusCode(409)
82+
.detail("key %s has already been processed".formatted(key))
83+
.buildAndSetResponse(exc);
84+
return ABORT;
85+
}
86+
87+
@Override
88+
public String getDisplayName() {
89+
return "Idempotency";
90+
}
91+
92+
/**
93+
* @description Expression used to extract the idempotency key from the exchange.
94+
* Can be an XPath, JSONPath, header, or other supported syntax depending on the language.
95+
* @example ${req.header.idempotency-key}
96+
*/
97+
@MCAttribute
98+
@Required
99+
public void setKey(String key) {
100+
this.key = key;
101+
}
102+
103+
/**
104+
* @description Language used to interpret the expression (e.g., spel, jsonpath, xpath).
105+
* Determines how the key string will be evaluated.
106+
* @default SpEL
107+
* @example jsonpath
108+
*/
109+
@MCAttribute
110+
public void setLanguage(Language language) {
111+
this.language = language;
112+
}
113+
114+
public String getKey() {
115+
return key;
116+
}
117+
118+
public Language getLanguage() {
119+
return language;
120+
}
121+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.predic8.membrane.core.interceptor.idempotency;
2+
3+
import com.predic8.membrane.core.exchange.Exchange;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.Test;
6+
7+
import java.net.URISyntaxException;
8+
9+
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
10+
import static com.predic8.membrane.core.http.Request.put;
11+
import static com.predic8.membrane.core.interceptor.Outcome.*;
12+
import static com.predic8.membrane.core.lang.ExchangeExpression.Language.JSONPATH;
13+
import static com.predic8.membrane.core.lang.ExchangeExpression.Language.SPEL;
14+
import static org.junit.jupiter.api.Assertions.assertEquals;
15+
16+
class IdempotencyInterceptorTest {
17+
18+
private IdempotencyInterceptor i;
19+
20+
@BeforeEach
21+
void setup() {
22+
i = new IdempotencyInterceptor();
23+
i.setLanguage(JSONPATH);
24+
i.setKey("$.id");
25+
i.init();
26+
}
27+
28+
@Test
29+
void newUniqueIdTest() throws URISyntaxException {
30+
assertEquals(CONTINUE, i.handleRequest(put("").body("{\"id\": \"abc123\"}").contentType(APPLICATION_JSON).buildExchange()));
31+
}
32+
33+
@Test
34+
void duplicateIdTest() throws URISyntaxException {
35+
Exchange firstExchange = put("").body("{\"id\": \"abc456\"}").contentType(APPLICATION_JSON).buildExchange();
36+
Exchange secondExchange = put("").body("{\"id\": \"abc456\"}").contentType(APPLICATION_JSON).buildExchange();
37+
assertEquals(CONTINUE, i.handleRequest(firstExchange));
38+
assertEquals(ABORT, i.handleRequest(secondExchange));
39+
assertEquals(400, secondExchange.getResponse().getStatusCode());
40+
}
41+
42+
@Test
43+
void uniqueIdsTest() throws URISyntaxException {
44+
assertEquals(CONTINUE, i.handleRequest(put("").body("{\"id\": \"789\"}").contentType(APPLICATION_JSON).buildExchange()));
45+
assertEquals(CONTINUE, i.handleRequest(put("").body("{\"id\": \"987\"}").contentType(APPLICATION_JSON).buildExchange()));
46+
}
47+
48+
@Test
49+
void whenIdIsMissingTest() throws URISyntaxException {
50+
assertEquals(CONTINUE, i.handleRequest(put("").body("{\"msg\": \"Hello World!\"}").contentType(APPLICATION_JSON).buildExchange()));
51+
}
52+
53+
@Test
54+
void defaultLanguageTest() {
55+
assertEquals(SPEL, new IdempotencyInterceptor().getLanguage());
56+
}
57+
}

0 commit comments

Comments
 (0)