From 2ab64e6c6dcf92a1f38f4fba6acfbd41e415b798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9da=20Housni=20Alaoui?= Date: Thu, 9 Nov 2023 16:34:48 +0100 Subject: [PATCH] Support HAL-Forms options --- .../cosium/hal_mock_mvc/JacksonModule.java | 27 ++++ .../TemplatePropertyRepresentation.java | 12 +- .../hal_mock_mvc/TemplateRepresentation.java | 2 +- .../com/cosium/hal_mock_mvc/Templates.java | 1 + .../options/InlineElementRepresentation.java | 7 ++ ...lineElementRepresentationDeserializer.java | 54 ++++++++ .../MapInlineElementRepresentation.java | 15 +++ .../options/OptionsLinkRepresentation.java | 39 ++++++ .../options/OptionsRepresentation.java | 66 ++++++++++ .../StringInlineElementRepresentation.java | 14 +++ .../com/cosium/hal_mock_mvc/OptionsTest.java | 115 ++++++++++++++++++ 11 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/JacksonModule.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentation.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentationDeserializer.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/MapInlineElementRepresentation.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsLinkRepresentation.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsRepresentation.java create mode 100644 core/src/main/java/com/cosium/hal_mock_mvc/template/options/StringInlineElementRepresentation.java create mode 100644 core/src/test/java/com/cosium/hal_mock_mvc/OptionsTest.java diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/JacksonModule.java b/core/src/main/java/com/cosium/hal_mock_mvc/JacksonModule.java new file mode 100644 index 0000000..38e3b61 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/JacksonModule.java @@ -0,0 +1,27 @@ +package com.cosium.hal_mock_mvc; + +import com.cosium.hal_mock_mvc.template.options.InlineElementRepresentation; +import com.cosium.hal_mock_mvc.template.options.InlineElementRepresentationDeserializer; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.module.SimpleModule; + +/** + * @author Réda Housni Alaoui + */ +class JacksonModule extends SimpleModule { + + public JacksonModule() { + addDeserializer( + InlineElementRepresentation.class, new InlineElementRepresentationDeserializer()); + } + + @Override + public String getModuleName() { + return "com.cosium.hal_mock_mvc"; + } + + @Override + public Version version() { + return Version.unknownVersion(); + } +} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/TemplatePropertyRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/TemplatePropertyRepresentation.java index fcc2aec..1ee963b 100644 --- a/core/src/main/java/com/cosium/hal_mock_mvc/TemplatePropertyRepresentation.java +++ b/core/src/main/java/com/cosium/hal_mock_mvc/TemplatePropertyRepresentation.java @@ -2,6 +2,7 @@ import static java.util.Objects.requireNonNull; +import com.cosium.hal_mock_mvc.template.options.OptionsRepresentation; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Optional; @@ -16,6 +17,7 @@ public class TemplatePropertyRepresentation { private final String prompt; private final String regex; private final boolean templated; + private final OptionsRepresentation options; @JsonCreator TemplatePropertyRepresentation( @@ -24,13 +26,15 @@ public class TemplatePropertyRepresentation { @JsonProperty("value") String value, @JsonProperty("prompt") String prompt, @JsonProperty("regex") String regex, - @JsonProperty("templated") Boolean templated) { - this.name = requireNonNull(name); + @JsonProperty("templated") Boolean templated, + @JsonProperty("options") OptionsRepresentation options) { + this.name = requireNonNull(name, "Attribute 'name' is missing"); this.required = Optional.ofNullable(required).orElse(false); this.value = value; this.prompt = Optional.ofNullable(prompt).orElse(name); this.regex = regex; this.templated = Optional.ofNullable(templated).orElse(false); + this.options = options; } public String name() { @@ -56,4 +60,8 @@ public Optional regex() { public boolean templated() { return templated; } + + public Optional options() { + return Optional.ofNullable(options); + } } diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/TemplateRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/TemplateRepresentation.java index fedd041..df0cc10 100644 --- a/core/src/main/java/com/cosium/hal_mock_mvc/TemplateRepresentation.java +++ b/core/src/main/java/com/cosium/hal_mock_mvc/TemplateRepresentation.java @@ -32,7 +32,7 @@ public class TemplateRepresentation { @JsonProperty("properties") List properties, @JsonProperty("target") String target) { this.title = title; - this.method = requireNonNull(method); + this.method = requireNonNull(method, "Attribute 'method' is missing"); this.contentType = Optional.ofNullable(contentType).orElse(MediaType.APPLICATION_JSON_VALUE); Map mutablePropertyByName = Optional.ofNullable(properties).orElseGet(Collections::emptyList).stream() diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/Templates.java b/core/src/main/java/com/cosium/hal_mock_mvc/Templates.java index 25a540b..23e5f27 100644 --- a/core/src/main/java/com/cosium/hal_mock_mvc/Templates.java +++ b/core/src/main/java/com/cosium/hal_mock_mvc/Templates.java @@ -48,6 +48,7 @@ public class Templates { body = new ObjectMapper() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .registerModule(new JacksonModule()) .readValue(jsonBody, HalFormsBody.class); } diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentation.java new file mode 100644 index 0000000..1ff3109 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentation.java @@ -0,0 +1,7 @@ +package com.cosium.hal_mock_mvc.template.options; + +/** + * @author Réda Housni Alaoui + */ +public sealed interface InlineElementRepresentation + permits MapInlineElementRepresentation, StringInlineElementRepresentation {} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentationDeserializer.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentationDeserializer.java new file mode 100644 index 0000000..8fbf516 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/InlineElementRepresentationDeserializer.java @@ -0,0 +1,54 @@ +package com.cosium.hal_mock_mvc.template.options; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import java.io.IOException; +import java.util.Map; + +/** + * @author Réda Housni Alaoui + */ +public class InlineElementRepresentationDeserializer + extends StdDeserializer { + + public InlineElementRepresentationDeserializer() { + this(null); + } + + protected InlineElementRepresentationDeserializer(Class vc) { + super(vc); + } + + @Override + public InlineElementRepresentation deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + + JsonToken currentToken = p.currentToken(); + if (currentToken == JsonToken.START_OBJECT) { + Map map = p.readValueAs(new StringStringMap()); + if (map == null) { + return null; + } + return new MapInlineElementRepresentation(map); + } + if (currentToken == JsonToken.VALUE_STRING) { + String value = p.readValueAs(String.class); + if (value == null) { + return null; + } + return new StringInlineElementRepresentation(value); + } + + throw MismatchedInputException.from( + p, + InlineElementRepresentation.class, + "%s should have been either %s or %s. But it is not." + .formatted(currentToken, JsonToken.START_OBJECT, JsonToken.VALUE_STRING)); + } + + private static class StringStringMap extends TypeReference> {} +} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/MapInlineElementRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/MapInlineElementRepresentation.java new file mode 100644 index 0000000..9970de3 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/MapInlineElementRepresentation.java @@ -0,0 +1,15 @@ +package com.cosium.hal_mock_mvc.template.options; + +import java.util.Map; +import java.util.Optional; + +/** + * @author Réda Housni Alaoui + */ +public record MapInlineElementRepresentation(Map map) + implements InlineElementRepresentation { + + public MapInlineElementRepresentation(Map map) { + this.map = Optional.ofNullable(map).map(Map::copyOf).orElseGet(Map::of); + } +} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsLinkRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsLinkRepresentation.java new file mode 100644 index 0000000..0f2e218 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsLinkRepresentation.java @@ -0,0 +1,39 @@ +package com.cosium.hal_mock_mvc.template.options; + +import static java.util.Objects.requireNonNull; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Optional; + +/** + * @author Réda Housni Alaoui + */ +public class OptionsLinkRepresentation { + + private final String href; + private final String type; + private final boolean templated; + + @JsonCreator + public OptionsLinkRepresentation( + @JsonProperty("href") String href, + @JsonProperty("type") String type, + @JsonProperty("templated") Boolean templated) { + this.href = requireNonNull(href, "Attribute 'href' is missing"); + this.type = type; + this.templated = Optional.ofNullable(templated).orElse(false); + } + + public String href() { + return href; + } + + public Optional type() { + return Optional.ofNullable(type); + } + + public boolean templated() { + return templated; + } +} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsRepresentation.java new file mode 100644 index 0000000..d1c2c93 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/OptionsRepresentation.java @@ -0,0 +1,66 @@ +package com.cosium.hal_mock_mvc.template.options; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Optional; + +/** + * @author Réda Housni Alaoui + */ +public class OptionsRepresentation { + + private final List inline; + private final OptionsLinkRepresentation link; + private final Long maxItems; + private final long minItems; + private final String promptField; + private final List selectedValues; + private final String valueField; + + @JsonCreator + public OptionsRepresentation( + @JsonProperty("inline") List inline, + @JsonProperty("link") OptionsLinkRepresentation link, + @JsonProperty("maxItems") Long maxItems, + @JsonProperty("minItems") Long minItems, + @JsonProperty("promptField") String promptField, + @JsonProperty("selectedValues") List selectedValues, + @JsonProperty("valueField") String valueField) { + this.inline = Optional.ofNullable(inline).map(List::copyOf).orElseGet(List::of); + this.link = link; + this.maxItems = maxItems; + this.minItems = Optional.ofNullable(minItems).orElse(0L); + this.promptField = promptField; + this.selectedValues = Optional.ofNullable(selectedValues).map(List::copyOf).orElseGet(List::of); + this.valueField = valueField; + } + + public List inline() { + return inline; + } + + public Optional link() { + return Optional.ofNullable(link); + } + + public Optional maxItems() { + return Optional.ofNullable(maxItems); + } + + public long minItems() { + return minItems; + } + + public Optional promptField() { + return Optional.ofNullable(promptField); + } + + public List selectedValues() { + return selectedValues; + } + + public Optional valueField() { + return Optional.ofNullable(valueField); + } +} diff --git a/core/src/main/java/com/cosium/hal_mock_mvc/template/options/StringInlineElementRepresentation.java b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/StringInlineElementRepresentation.java new file mode 100644 index 0000000..49e4901 --- /dev/null +++ b/core/src/main/java/com/cosium/hal_mock_mvc/template/options/StringInlineElementRepresentation.java @@ -0,0 +1,14 @@ +package com.cosium.hal_mock_mvc.template.options; + +import static java.util.Objects.requireNonNull; + +/** + * @author Réda Housni Alaoui + */ +public record StringInlineElementRepresentation(String value) + implements InlineElementRepresentation { + + public StringInlineElementRepresentation { + requireNonNull(value); + } +} diff --git a/core/src/test/java/com/cosium/hal_mock_mvc/OptionsTest.java b/core/src/test/java/com/cosium/hal_mock_mvc/OptionsTest.java new file mode 100644 index 0000000..0cd97c1 --- /dev/null +++ b/core/src/test/java/com/cosium/hal_mock_mvc/OptionsTest.java @@ -0,0 +1,115 @@ +package com.cosium.hal_mock_mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import com.cosium.hal_mock_mvc.template.options.OptionsRepresentation; +import com.cosium.hal_mock_mvc.template.options.StringInlineElementRepresentation; +import com.fasterxml.jackson.jr.ob.JSON; +import jakarta.inject.Inject; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * @author Réda Housni Alaoui + */ +@HalMockMvcBootTest +class OptionsTest { + + @Inject private MockMvc mockMvc; + + @Test + void inline() throws Exception { + + OptionsRepresentation options = + HalMockMvc.builder(mockMvc) + .baseUri(linkTo(methodOn(MyController.class).getInlineOptions()).toUri()) + .build() + .follow() + .templates() + .byKey("default") + .representation() + .propertyByName() + .get("shipping") + .options() + .orElseThrow(); + + assertThat(options.link()).isEmpty(); + assertThat(options.minItems()).isZero(); + assertThat(options.maxItems()).isEmpty(); + assertThat(options.promptField()).isEmpty(); + assertThat(options.valueField()).isEmpty(); + + assertThat(options.selectedValues()).containsExactly("FedEx"); + assertThat(options.inline()) + .allMatch(StringInlineElementRepresentation.class::isInstance) + .map(StringInlineElementRepresentation.class::cast) + .map(StringInlineElementRepresentation::value) + .containsExactly("FedEx", "UPS", "DHL"); + } + + @Controller + public static class MyController { + + @GetMapping("options-test-affordance-with-inline-options") + public ResponseEntity getInlineOptions() throws IOException { + + String json = + JSON.std + .composeString() + .startObject() + .startObjectField("_links") + .startObjectField("self") + .put("href", "http://api.example.org/") + .end() + .end() + .startObjectField("_templates") + .startObjectField("default") + .put("method", "POST") + .startArrayField("properties") + .startObject() + .put("name", "shipping") + .put("prompt", "Select Shipping Method") + .startObjectField("options") + .startArrayField("selectedValues") + .add("FedEx") + .end() + .startArrayField("inline") + .add("FedEx") + .add("UPS") + .add("DHL") + .end() + .end() + .end() + .end() + .end() + .end() + .end() + .finish(); + return ResponseEntity.ok(json); + } + } + + @RequestMapping("/void") + public abstract static class VoidAffordance { + + public static Affordance create() { + return WebMvcLinkBuilder.afford(methodOn(HalMockMvcFormsTest.VoidAffordance.class).post()); + } + + @PostMapping + public ResponseEntity post() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + } +}