Skip to content

Commit

Permalink
Support for iterating over list property (#34)
Browse files Browse the repository at this point in the history
- 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
-->
  • Loading branch information
dirkbolte authored Aug 16, 2023
1 parent ad0c51d commit 7a33613
Show file tree
Hide file tree
Showing 5 changed files with 509 additions and 166 deletions.
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# WireMock State extension

<p align="center">
<a href="https://wiremock.org" target="_blank">
<img width="512px" src="https://wiremock.org/images/logos/wiremock/logo_wide.svg" alt="WireMock Logo"/>
</a>
</p>

Adds support to transport state across different stubs.

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

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

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

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

### List operations

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.

Things to consider:

- this syntax only works with `body`. It DOES NOT work with `jsonBody`
- as this might get ugly, consider using `bodyFileName` / `withBodyFile()` have proper indentation
- 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
- if you still want a different response, consider using a [StateRequestMatcher](#negative-context-exists-match)
- 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
- JSON does not allow trailing commas, so in order to create a valid JSON list, use `{{#unless @last}},{{/unless}` before `{{/each}}`


Example:
```json
{
"request" : {
"urlPathPattern" : "/listing",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "[\n{{# each (state context=list property='list' default='[]') }} {\n \"id\": \"{{id}}\",\n \"firstName\": \"{{firstName}}\",\n \"firstName\": \"{{firstName}}\" }{{#unless @last}},{{/unless}}\n{{/each}}]",
"headers" : {
"content-type" : "application/json"
}
}
}
```

### Error handling

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@
package org.wiremock.extensions.state.extensions;

import com.github.jknack.handlebars.Options;
import com.github.tomakehurst.wiremock.common.Json;
import com.github.tomakehurst.wiremock.common.JsonException;
import com.github.tomakehurst.wiremock.extension.responsetemplating.helpers.HandlebarsHelper;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.PathNotFoundException;
import org.apache.commons.lang3.StringUtils;
import org.wiremock.extensions.state.internal.Context;
import org.wiremock.extensions.state.internal.ContextManager;

import java.util.ArrayList;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;

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

Expand Down Expand Up @@ -57,35 +63,36 @@ public Object apply(Object o, Options options) {
return handleError("Either 'property' or 'list' has to be set");
}
if (StringUtils.isNotBlank(property)) {
return getProperty(contextName, property)
.orElseGet(() ->
Optional
.ofNullable(defaultValue)
.orElseGet(() -> handleError(String.format("No state for context %s, property %s found", contextName, property)))
);
return getProperty(contextName, property, defaultValue)
.orElseGet(() -> handleError(String.format("No state for context %s, property %s found", contextName, property)));
} else {
return getList(contextName, list)
.orElseGet(() ->
Optional
.ofNullable(defaultValue)
.orElseGet(() -> handleError(String.format("No state for context %s, list %s found", contextName, list)))
);
Optional.ofNullable(defaultValue)
.orElseGet(() -> handleError(String.format("No state for context %s, list %s found", contextName, list)))
);

}
}

private Optional<Object> getProperty(String contextName, String property) {
private Optional<Object> getProperty(String contextName, String property, String defaultValue) {
return contextManager.getContext(contextName)
.map(context -> {
if ("updateCount" .equals(property)) {
return context.getUpdateCount();
} else if ("listSize" .equals(property)) {
return context.getList().size();
} else {
return context.getProperties().get(property);
}
}
);
.map(context ->
Stream.of(SpecialProperties.values())
.filter(it -> it.name().equals(property))
.findFirst()
.map(it -> it.getFromContext(context))
.orElseGet(() -> context.getProperties().get(property))
)
.or(() -> convertToPropertySpecificDefault(property, defaultValue));
}

private Optional<Object> convertToPropertySpecificDefault(String property, String defaultValue) {
return Stream.of(SpecialProperties.values())
.filter(it -> it.name().equals(property))
.findFirst()
.map(it -> it.convertDefaultValue(defaultValue))
.or(() -> Optional.ofNullable(defaultValue));
}

private Optional<Object> getList(String contextName, String list) {
Expand All @@ -99,4 +106,41 @@ private Optional<Object> getList(String contextName, String list) {
}
});
}

private enum SpecialProperties {
updateCount(Context::getUpdateCount, it -> it),
listSize((context) -> context.getList().size(), it -> it),
@SuppressWarnings("rawtypes") list(
Context::getList,
(defaultValue) -> Optional.ofNullable(defaultValue)
.map(it -> {
try {
return Json.read(it, ArrayList.class);
} catch (JsonException ex) {
notifier().error("default for list property is not a JSON list - fallback to empty list: " + defaultValue);
return null;
}
})
.or(() -> Optional.of(new ArrayList()))
.map(it -> (Object) it)
.get()
);

private final Function<Context, Object> contextExtractor;
private final Function<String, Object> defaultConverter;

SpecialProperties(Function<Context, Object> contextExtractor, Function<String, Object> defaultConverter) {
this.contextExtractor = contextExtractor;
this.defaultConverter = defaultConverter;
}

public Object getFromContext(Context context) {
return contextExtractor.apply(context);
}

public Object convertDefaultValue(String defaultValue) {
return defaultConverter.apply(defaultValue);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@
import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;

/**
* Sample test for creating a mock for a queue with java.
* Sample test for creating a mock for a listing with java.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Execution(SAME_THREAD)
class StateExtensionListExampleTest {

private static final String TEST_URL = "/test";
private static final String TEST_URL = "/listing";
private static final Store<String, Object> store = new CaffeineStore();
private static final ObjectMapper mapper = new ObjectMapper();

Expand All @@ -77,7 +77,7 @@ public void setup() throws JsonProcessingException {
}

@Test
public void testQueue() {
public void testList() {
var firstNameOne = RandomStringUtils.randomAlphabetic(5);
var lastNameOne = RandomStringUtils.randomAlphabetic(5);
var firstNameTwo = RandomStringUtils.randomAlphabetic(5);
Expand Down Expand Up @@ -113,17 +113,13 @@ public void testQueue() {
.get(assertDoesNotThrow(() -> new URI(wm.getRuntimeInfo().getHttpBaseUrl() + TEST_URL)))
.then()
.statusCode(HttpStatus.SC_OK)
.body("id", Matchers.equalTo(idOne))
.body("firstName", Matchers.equalTo(firstNameOne))
.body("lastName", Matchers.equalTo(lastNameOne));
given()
.accept(ContentType.JSON)
.get(assertDoesNotThrow(() -> new URI(wm.getRuntimeInfo().getHttpBaseUrl() + TEST_URL)))
.then()
.statusCode(HttpStatus.SC_OK)
.body("id", Matchers.equalTo(idTwo))
.body("firstName", Matchers.equalTo(firstNameTwo))
.body("lastName", Matchers.equalTo(lastNameTwo));
.body("$", Matchers.hasSize(2))
.body("[0].id", Matchers.equalTo(idOne))
.body("[0].firstName", Matchers.equalTo(firstNameOne))
.body("[0].lastName", Matchers.equalTo(lastNameOne))
.body("[1].id", Matchers.equalTo(idTwo))
.body("[1].firstName", Matchers.equalTo(firstNameTwo))
.body("[1].lastName", Matchers.equalTo(lastNameTwo));
}


Expand All @@ -149,7 +145,7 @@ private void createPostStub() throws JsonProcessingException {
"recordState",
Parameters.from(
Map.of(
"context", "queue",
"context", "list",
"list", Map.of(
"addLast", Map.of(
"id", "{{jsonPath response.body '$.id'}}",
Expand All @@ -163,33 +159,24 @@ private void createPostStub() throws JsonProcessingException {
);
}

private void createGetStub() throws JsonProcessingException {
private void createGetStub() {
wm.stubFor(
get(urlPathMatching(TEST_URL))
.willReturn(
WireMock.ok()
.withHeader("content-type", "application/json")
.withJsonBody(
mapper.readTree(
mapper.writeValueAsString(Map.of(
"id", "{{state context='queue' list='[0].id'}}",
"firstName", "{{state context='queue' list='[0].firstName'}}",
"lastName", "{{state context='queue' list='[0].lastName'}}"
)
)
)
.withBody(
"[\n" +
"{{#each (state context='list' property='list' default='[]') }}" +
" {\n" +
" \"id\": \"{{id}}\",\n" +
" \"firstName\": \"{{firstName}}\",\n" +
" \"lastName\": \"{{lastName}}\"" +
" }{{#unless @last}},{{/unless}}\n" +
"{{/each}}" +
"]"
)
)
.withServeEventListener(
"deleteState",
Parameters.from(
Map.of(
"context", "queue",
"list", Map.of("deleteFirst", true)
)
)
)

);
}
}
Loading

0 comments on commit 7a33613

Please sign in to comment.