Skip to content

Commit

Permalink
Unable to map snake_case JSON to Play Form with camelCase fields (Java)
Browse files Browse the repository at this point in the history
  • Loading branch information
amvanbaren committed Oct 6, 2023
1 parent 6b3d3b8 commit 081e154
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 32 deletions.
14 changes: 14 additions & 0 deletions web/play-java-forms/src/main/java/play/data/DynamicForm.java
Expand Up @@ -4,6 +4,8 @@

package play.data;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.databind.JsonNode;
import com.typesafe.config.Config;
import java.util.ArrayList;
Expand Down Expand Up @@ -485,6 +487,7 @@ public Dynamic(Map<String, Object> data) {
}

/** @return the data. */
@JsonAnyGetter
public Map<String, Object> getData() {
return data;
}
Expand All @@ -498,6 +501,17 @@ public void setData(Map<String, Object> data) {
this.data = data;
}

/**
* Put data property.
*
* @param key the property key.
* @param value the property value.
*/
@JsonAnySetter
public void putData(String key, Object value) {
data.put(key, value);
}

public String toString() {
return "Form.Dynamic(" + data.toString() + ")";
}
Expand Down
90 changes: 71 additions & 19 deletions web/play-java-forms/src/main/java/play/data/Form.java
Expand Up @@ -9,7 +9,14 @@
import static play.api.templates.PlayMagic.translate;
import static play.libs.F.Tuple;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.google.common.collect.ImmutableList;
import com.typesafe.config.Config;
import java.lang.annotation.Annotation;
Expand All @@ -25,11 +32,13 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -594,12 +603,7 @@ protected Map<String, String> requestData(Http.Request request) {

Map<String, String> jsonData = new HashMap<>();
if (request.body().asJson() != null) {
jsonData =
play.libs.Scala.asJava(
play.api.data.FormUtils.fromJson(
play.api.libs.json.Json.parse(play.libs.Json.stringify(request.body().asJson())),
maxJsonChars(),
maxJsonDepth()));
jsonData = convertJsonToFormData(request.body().asJson(), maxJsonChars(), maxJsonDepth());
}

Map<String, String> data = new HashMap<>();
Expand Down Expand Up @@ -775,15 +779,7 @@ public Form<T> bind(Lang lang, TypedMap attrs, JsonNode data, String... allowedF
*/
public Form<T> bind(
Lang lang, TypedMap attrs, JsonNode data, long maxChars, String... allowedFields) {
return bind(
lang,
attrs,
play.libs.Scala.asJava(
play.api.data.FormUtils.fromJson(
play.api.libs.json.Json.parse(play.libs.Json.stringify(data)),
maxChars,
maxJsonDepth())),
allowedFields);
return bind(lang, attrs, data, maxChars, maxJsonDepth(), allowedFields);
}

/**
Expand Down Expand Up @@ -812,12 +808,26 @@ public Form<T> bind(
return bind(
lang,
attrs,
play.libs.Scala.asJava(
play.api.data.FormUtils.fromJson(
play.api.libs.json.Json.parse(play.libs.Json.stringify(data)), maxChars, maxDepth)),
convertJsonToFormData(data, maxChars, maxDepth),
allowedFields);
}

private Map<String, String> convertJsonToFormData(JsonNode json, long maxChars, int maxDepth) {
ObjectMapper noAnnotationsMapper = JsonMapper.builder()
.configure(MapperFeature.USE_ANNOTATIONS, false)
.build();

// ignore Jackson annotations so that property naming strategy
// isn't applied when converting value to JsonNode
JsonNode formData = Optional.of(play.libs.Json.fromJson(json, backedType))
.map(t -> (JsonNode) noAnnotationsMapper.valueToTree(t))
.orElse(json);

return play.libs.Scala.asJava(
play.api.data.FormUtils.fromJson(
play.api.libs.json.Json.parse(play.libs.Json.stringify(formData)), maxChars, maxDepth));
}

private static final Set<String> internalAnnotationAttributes = new HashSet<>(3);

static {
Expand Down Expand Up @@ -1290,6 +1300,7 @@ public JsonNode errorsAsJson() {
* @return the JSON node containing the errors.
*/
public JsonNode errorsAsJson(Lang lang) {
Map<String, String> keyMapping = !errors.isEmpty() ? getJsonKeyMapping() : null;
Map<String, List<String>> allMessages = new HashMap<>();
errors.forEach(
error -> {
Expand All @@ -1306,12 +1317,53 @@ public JsonNode errorsAsJson(Lang lang) {
} else {
messages.add(error.message());
}
allMessages.put(error.key(), messages);
allMessages.put(keyMapping.get(error.key()), messages);
}
});
return play.libs.Json.toJson(allMessages);
}

private Map<String, String> getJsonKeyMapping() {
ObjectMapper mapper = play.libs.Json.mapper();
JavaType type = mapper.getTypeFactory().constructType(backedType);
List<BeanPropertyDefinition> properties = mapper.getSerializationConfig().introspect(type).findProperties();
Map<String, List<BeanPropertyDefinition>> groupedProperties = properties.stream()
.map(prop -> Tuple(prop.getInternalName(), prop))
.collect(Collectors.groupingBy(t -> t._1, Collectors.mapping(t -> t._2, Collectors.toList())));

List<Predicate<BeanPropertyDefinition>> predicates = List.of(
prop -> Optional.of(prop).map(p -> p.getField()).map(f -> f.getAnnotation(JsonProperty.class)).isPresent(),
prop -> Optional.of(prop).map(p -> p.getSetter()).map(s -> s.getAnnotation(JsonSetter.class)).isPresent(),
prop -> Optional.of(prop).map(p -> p.getSetter()).map(s -> s.getAnnotation(JsonProperty.class)).isPresent(),
prop -> true
);
Map<String, String> keyMapping = new HashMap<>();
groupedProperties.forEach((internalName, props) -> {
if(props.size() == 1) {
keyMapping.put(internalName, props.get(0).getName());
} else {
predicates.stream()
.map(predicate -> findJsonPropertyName(props, predicate))
.filter(Objects::nonNull)
.findFirst()
.ifPresent(name -> keyMapping.put(internalName, name));
}
});

return keyMapping;
}

private String findJsonPropertyName(
List<BeanPropertyDefinition> properties,
Predicate<BeanPropertyDefinition> predicate
) {
return properties.stream()
.filter(predicate)
.findFirst()
.map(prop -> prop.getName())
.orElse(null);
}

/**
* Gets the concrete value only if the submission was a success. If the form is invalid because of
* validation errors this method will throw an exception. If you want to retrieve the value even
Expand Down
@@ -0,0 +1,39 @@
/*
* Copyright (C) from 2022 The Play Framework Contributors <https://github.com/playframework>, 2011-2021 Lightbend Inc. <https://www.lightbend.com>
*/

package play.data;

import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import play.data.validation.Constraints;

public class JacksonJsonGetterSetterForm {

@Constraints.Required
private String authorName;

@Constraints.Required
private String bookName;

@JsonGetter("author")
public String getAuthorName() {
return authorName;
}

@JsonSetter("AUTHOR-NAME")
public void setAuthorName(String authorName) {
this.authorName = authorName;
}

@JsonProperty("book_name")
public String getBookName() {
return bookName;
}

@JsonProperty("title")
public void setBookName(String bookName) {
this.bookName = bookName;
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (C) from 2022 The Play Framework Contributors <https://github.com/playframework>, 2011-2021 Lightbend Inc. <https://www.lightbend.com>
*/

package play.data;

import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import play.data.validation.Constraints;

@JsonNaming(SnakeCaseStrategy.class)
public class JacksonJsonNamingForm {

@Constraints.Required
private String authorName;

public String getAuthorName() {
return authorName;
}

public void setAuthorName(String authorName) {
this.authorName = authorName;
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (C) from 2022 The Play Framework Contributors <https://github.com/playframework>, 2011-2021 Lightbend Inc. <https://www.lightbend.com>
*/

package play.data;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import play.data.validation.Constraints;

import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;

public class JacksonJsonPropertyForm {

@Constraints.Required
@JsonProperty("AuthorName")
private String authorName;

public String getAuthorName() {
return authorName;
}

public void setAuthorName(String authorName) {
this.authorName = authorName;
}
}
4 changes: 2 additions & 2 deletions web/play-java-forms/src/test/java/play/mvc/HttpFormsTest.java
Expand Up @@ -228,7 +228,7 @@ public void testLangErrorsAsJson() {
List<Object> args = new ArrayList<>();
args.add("error.customarg");
List<ValidationError> errors = new ArrayList<>();
errors.add(new ValidationError("foo", msgs, args));
errors.add(new ValidationError("amount", msgs, args));

Form<Money> form =
new Form<>(
Expand All @@ -244,7 +244,7 @@ public void testLangErrorsAsJson() {
config,
lang);

assertThat(form.errorsAsJson().get("foo").toString())
assertThat(form.errorsAsJson().get("amount").toString())
.isEqualTo("[\"It looks like something was not correct\"]");
});
}
Expand Down
Expand Up @@ -6,16 +6,14 @@ package play.data

import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters._

import com.fasterxml.jackson.databind.node.TextNode
import com.fasterxml.jackson.databind.JsonNode
import com.typesafe.config.ConfigFactory
import play.api.data.FormJsonExpansionTooLarge
import play.api.i18n.DefaultMessagesApi
import play.api.i18n.Messages
import play.core.j.PlayFormsMagicForJava.javaFieldtoScalaField
import play.data.format.Formatters
import play.libs.Files.SingletonTemporaryFileCreator
import play.libs.Json
import play.mvc.Http.RequestBuilder
import views.html.helper.inputText
import views.html.helper.FieldConstructor.defaultField
Expand Down Expand Up @@ -261,21 +259,22 @@ class DynamicFormSpec extends CommonFormSpec {
sField.errors must_== Nil
}

"fail with exception when the json paylod is bigger than default maxBufferSize" in {
"fail with exception when the json payload is bigger than default maxBufferSize" in {
val cfg = ConfigFactory
.parseString("""
|play.http.parser.maxMemoryBuffer = 32
|""".stripMargin)
.withFallback(config)
val form = new DynamicForm(jMessagesApi, new Formatters(jMessagesApi), validatorFactory, cfg)
val longString =
"012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
val textNode: JsonNode = new TextNode(longString)
val longString = "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
val json = Json.mapper.createObjectNode
json.put("foo", longString)

val req = new RequestBuilder()
.method("POST")
.uri("http://localhost/test")
.header("Content-type", "application/json")
.bodyJson(textNode)
.bodyJson(json)
.build()

form.bindFromRequest(req) must throwA[FormJsonExpansionTooLarge].like {
Expand Down

0 comments on commit 081e154

Please sign in to comment.