Skip to content

Commit 7a33613

Browse files
authored
Support for iterating over list property (#34)
- adding special property `list` - make special property handling more generic - added example test cases - extended README - added wiremock logo to the top <!-- Please describe your pull request here. --> ## References - TODO <!-- References to relevant GitHub issues and pull requests, esp. upstream and downstream changes --> ## Submitter checklist - [ ] The PR request is well described and justified, including the body and the references - [ ] The PR title represents the desired changelog entry - [ ] The repository's code style is followed (see the contributing guide) - [ ] Test coverage that demonstrates that the change works as expected - [ ] For new features, there's necessary documentation in this pull request or in a subsequent PR to [wiremock.org](https://github.com/wiremock/wiremock.org) <!-- Put an `x` into the [ ] to show you have filled the information. The template comes from https://github.com/wiremock/.github/blob/main/.github/pull_request_template.md You can override it by creating .github/pull_request_template.md in your own repository -->
1 parent ad0c51d commit 7a33613

File tree

5 files changed

+509
-166
lines changed

5 files changed

+509
-166
lines changed

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# WireMock State extension
22

3+
<p align="center">
4+
<a href="https://wiremock.org" target="_blank">
5+
<img width="512px" src="https://wiremock.org/images/logos/wiremock/logo_wide.svg" alt="WireMock Logo"/>
6+
</a>
7+
</p>
8+
39
Adds support to transport state across different stubs.
410

511
## Feature summary
@@ -701,6 +707,8 @@ The handler has the following parameters:
701707
- `property='updateCount` retrieves the number of updates to a certain state.
702708
The number matches the one described in [Context update count match](#context-update-count-match)
703709
- `property='listSize` retrieves the number of entries of `list`
710+
- `property='list` get the whole list as array, e.g. to use it with [handlebars #each](https://handlebarsjs.com/guide/builtin-helpers.html#each)
711+
- this property always has a default value (empty list), which can be overwritten with a JSON list
704712
- `list`: Getting an entry of the context's `list`, identified via a JSON path. Examples:
705713
- getting the first state in the list: `list='[0].myProperty`
706714
- getting the last state in the list: `list='[-1].myProperty`
@@ -709,11 +717,42 @@ The handler has the following parameters:
709717

710718
You have to choose either `property` or `list` (otherwise, you will get a configuration error).
711719

712-
To retrieve a full body, use: `{{{state context=request.pathSegments.[1] property='fullBody'}}}` .
720+
To retrieve a full body, use tripple braces: `{{{state context=request.pathSegments.[1] property='fullBody'}}}` .
713721

714722
When registering this extension, this helper is available via WireMock's [response templating](https://wiremock.org/3.x/docs/response-templating/) as well as
715723
in all configuration options of this extension.
716724

725+
### List operations
726+
727+
You can use [handlebars #each](https://handlebarsjs.com/guide/builtin-helpers.html#each) to build a full JSON response with the current list's content.
728+
729+
Things to consider:
730+
731+
- this syntax only works with `body`. It DOES NOT work with `jsonBody`
732+
- as this might get ugly, consider using `bodyFileName` / `withBodyFile()` have proper indentation
733+
- the default response for non-existant context as well as non-existant list in a context is an empty list. These states cannot be differentiated here
734+
- if you still want a different response, consider using a [StateRequestMatcher](#negative-context-exists-match)
735+
- the default value for this property has to be a valid JSON list - otherwise you will get an error log and the empty list response
736+
- JSON does not allow trailing commas, so in order to create a valid JSON list, use `{{#unless @last}},{{/unless}` before `{{/each}}`
737+
738+
739+
Example:
740+
```json
741+
{
742+
"request" : {
743+
"urlPathPattern" : "/listing",
744+
"method" : "GET"
745+
},
746+
"response" : {
747+
"status" : 200,
748+
"body" : "[\n{{# each (state context=list property='list' default='[]') }} {\n \"id\": \"{{id}}\",\n \"firstName\": \"{{firstName}}\",\n \"firstName\": \"{{firstName}}\" }{{#unless @last}},{{/unless}}\n{{/each}}]",
749+
"headers" : {
750+
"content-type" : "application/json"
751+
}
752+
}
753+
}
754+
```
755+
717756
### Error handling
718757

719758
Missing Helper properties as well as unknown context properties are reported as error. Wiremock renders them in the field, itself, so there won't be an

src/main/java/org/wiremock/extensions/state/extensions/StateHandlerbarHelper.java

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@
1616
package org.wiremock.extensions.state.extensions;
1717

1818
import com.github.jknack.handlebars.Options;
19+
import com.github.tomakehurst.wiremock.common.Json;
20+
import com.github.tomakehurst.wiremock.common.JsonException;
1921
import com.github.tomakehurst.wiremock.extension.responsetemplating.helpers.HandlebarsHelper;
2022
import com.jayway.jsonpath.JsonPath;
2123
import com.jayway.jsonpath.PathNotFoundException;
2224
import org.apache.commons.lang3.StringUtils;
25+
import org.wiremock.extensions.state.internal.Context;
2326
import org.wiremock.extensions.state.internal.ContextManager;
2427

28+
import java.util.ArrayList;
2529
import java.util.Optional;
30+
import java.util.function.Function;
31+
import java.util.stream.Stream;
2632

2733
import static com.github.tomakehurst.wiremock.common.LocalNotifier.notifier;
2834

@@ -57,35 +63,36 @@ public Object apply(Object o, Options options) {
5763
return handleError("Either 'property' or 'list' has to be set");
5864
}
5965
if (StringUtils.isNotBlank(property)) {
60-
return getProperty(contextName, property)
61-
.orElseGet(() ->
62-
Optional
63-
.ofNullable(defaultValue)
64-
.orElseGet(() -> handleError(String.format("No state for context %s, property %s found", contextName, property)))
65-
);
66+
return getProperty(contextName, property, defaultValue)
67+
.orElseGet(() -> handleError(String.format("No state for context %s, property %s found", contextName, property)));
6668
} else {
6769
return getList(contextName, list)
6870
.orElseGet(() ->
69-
Optional
70-
.ofNullable(defaultValue)
71-
.orElseGet(() -> handleError(String.format("No state for context %s, list %s found", contextName, list)))
72-
);
71+
Optional.ofNullable(defaultValue)
72+
.orElseGet(() -> handleError(String.format("No state for context %s, list %s found", contextName, list)))
73+
);
7374

7475
}
7576
}
7677

77-
private Optional<Object> getProperty(String contextName, String property) {
78+
private Optional<Object> getProperty(String contextName, String property, String defaultValue) {
7879
return contextManager.getContext(contextName)
79-
.map(context -> {
80-
if ("updateCount" .equals(property)) {
81-
return context.getUpdateCount();
82-
} else if ("listSize" .equals(property)) {
83-
return context.getList().size();
84-
} else {
85-
return context.getProperties().get(property);
86-
}
87-
}
88-
);
80+
.map(context ->
81+
Stream.of(SpecialProperties.values())
82+
.filter(it -> it.name().equals(property))
83+
.findFirst()
84+
.map(it -> it.getFromContext(context))
85+
.orElseGet(() -> context.getProperties().get(property))
86+
)
87+
.or(() -> convertToPropertySpecificDefault(property, defaultValue));
88+
}
89+
90+
private Optional<Object> convertToPropertySpecificDefault(String property, String defaultValue) {
91+
return Stream.of(SpecialProperties.values())
92+
.filter(it -> it.name().equals(property))
93+
.findFirst()
94+
.map(it -> it.convertDefaultValue(defaultValue))
95+
.or(() -> Optional.ofNullable(defaultValue));
8996
}
9097

9198
private Optional<Object> getList(String contextName, String list) {
@@ -99,4 +106,41 @@ private Optional<Object> getList(String contextName, String list) {
99106
}
100107
});
101108
}
109+
110+
private enum SpecialProperties {
111+
updateCount(Context::getUpdateCount, it -> it),
112+
listSize((context) -> context.getList().size(), it -> it),
113+
@SuppressWarnings("rawtypes") list(
114+
Context::getList,
115+
(defaultValue) -> Optional.ofNullable(defaultValue)
116+
.map(it -> {
117+
try {
118+
return Json.read(it, ArrayList.class);
119+
} catch (JsonException ex) {
120+
notifier().error("default for list property is not a JSON list - fallback to empty list: " + defaultValue);
121+
return null;
122+
}
123+
})
124+
.or(() -> Optional.of(new ArrayList()))
125+
.map(it -> (Object) it)
126+
.get()
127+
);
128+
129+
private final Function<Context, Object> contextExtractor;
130+
private final Function<String, Object> defaultConverter;
131+
132+
SpecialProperties(Function<Context, Object> contextExtractor, Function<String, Object> defaultConverter) {
133+
this.contextExtractor = contextExtractor;
134+
this.defaultConverter = defaultConverter;
135+
}
136+
137+
public Object getFromContext(Context context) {
138+
return contextExtractor.apply(context);
139+
}
140+
141+
public Object convertDefaultValue(String defaultValue) {
142+
return defaultConverter.apply(defaultValue);
143+
}
144+
}
145+
102146
}

src/test/java/org/wiremock/extensions/state/examples/StateExtensionListExampleTest.java

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@
4747
import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
4848

4949
/**
50-
* Sample test for creating a mock for a queue with java.
50+
* Sample test for creating a mock for a listing with java.
5151
*/
5252
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
5353
@Execution(SAME_THREAD)
5454
class StateExtensionListExampleTest {
5555

56-
private static final String TEST_URL = "/test";
56+
private static final String TEST_URL = "/listing";
5757
private static final Store<String, Object> store = new CaffeineStore();
5858
private static final ObjectMapper mapper = new ObjectMapper();
5959

@@ -77,7 +77,7 @@ public void setup() throws JsonProcessingException {
7777
}
7878

7979
@Test
80-
public void testQueue() {
80+
public void testList() {
8181
var firstNameOne = RandomStringUtils.randomAlphabetic(5);
8282
var lastNameOne = RandomStringUtils.randomAlphabetic(5);
8383
var firstNameTwo = RandomStringUtils.randomAlphabetic(5);
@@ -113,17 +113,13 @@ public void testQueue() {
113113
.get(assertDoesNotThrow(() -> new URI(wm.getRuntimeInfo().getHttpBaseUrl() + TEST_URL)))
114114
.then()
115115
.statusCode(HttpStatus.SC_OK)
116-
.body("id", Matchers.equalTo(idOne))
117-
.body("firstName", Matchers.equalTo(firstNameOne))
118-
.body("lastName", Matchers.equalTo(lastNameOne));
119-
given()
120-
.accept(ContentType.JSON)
121-
.get(assertDoesNotThrow(() -> new URI(wm.getRuntimeInfo().getHttpBaseUrl() + TEST_URL)))
122-
.then()
123-
.statusCode(HttpStatus.SC_OK)
124-
.body("id", Matchers.equalTo(idTwo))
125-
.body("firstName", Matchers.equalTo(firstNameTwo))
126-
.body("lastName", Matchers.equalTo(lastNameTwo));
116+
.body("$", Matchers.hasSize(2))
117+
.body("[0].id", Matchers.equalTo(idOne))
118+
.body("[0].firstName", Matchers.equalTo(firstNameOne))
119+
.body("[0].lastName", Matchers.equalTo(lastNameOne))
120+
.body("[1].id", Matchers.equalTo(idTwo))
121+
.body("[1].firstName", Matchers.equalTo(firstNameTwo))
122+
.body("[1].lastName", Matchers.equalTo(lastNameTwo));
127123
}
128124

129125

@@ -149,7 +145,7 @@ private void createPostStub() throws JsonProcessingException {
149145
"recordState",
150146
Parameters.from(
151147
Map.of(
152-
"context", "queue",
148+
"context", "list",
153149
"list", Map.of(
154150
"addLast", Map.of(
155151
"id", "{{jsonPath response.body '$.id'}}",
@@ -163,33 +159,24 @@ private void createPostStub() throws JsonProcessingException {
163159
);
164160
}
165161

166-
private void createGetStub() throws JsonProcessingException {
162+
private void createGetStub() {
167163
wm.stubFor(
168164
get(urlPathMatching(TEST_URL))
169165
.willReturn(
170166
WireMock.ok()
171167
.withHeader("content-type", "application/json")
172-
.withJsonBody(
173-
mapper.readTree(
174-
mapper.writeValueAsString(Map.of(
175-
"id", "{{state context='queue' list='[0].id'}}",
176-
"firstName", "{{state context='queue' list='[0].firstName'}}",
177-
"lastName", "{{state context='queue' list='[0].lastName'}}"
178-
)
179-
)
180-
)
168+
.withBody(
169+
"[\n" +
170+
"{{#each (state context='list' property='list' default='[]') }}" +
171+
" {\n" +
172+
" \"id\": \"{{id}}\",\n" +
173+
" \"firstName\": \"{{firstName}}\",\n" +
174+
" \"lastName\": \"{{lastName}}\"" +
175+
" }{{#unless @last}},{{/unless}}\n" +
176+
"{{/each}}" +
177+
"]"
181178
)
182179
)
183-
.withServeEventListener(
184-
"deleteState",
185-
Parameters.from(
186-
Map.of(
187-
"context", "queue",
188-
"list", Map.of("deleteFirst", true)
189-
)
190-
)
191-
)
192-
193180
);
194181
}
195182
}

0 commit comments

Comments
 (0)