Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unable to map snake_case JSON to Play Form with camelCase fields (Java) #12017

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 jakarta.validation.ValidatorFactory;
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
109 changes: 86 additions & 23 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 jakarta.validation.ConstraintViolation;
Expand All @@ -31,11 +38,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 All @@ -809,13 +805,23 @@ public Form<T> bind(
long maxChars,
int maxDepth,
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, maxDepth)),
allowedFields);
return bind(lang, attrs, 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);
Expand Down Expand Up @@ -1290,6 +1296,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 +1313,68 @@ 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,37 @@
/*
* 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,23 @@
/*
* 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 play.data.validation.Constraints;

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 @@ -7,15 +7,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 +260,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 form = new DynamicForm(jMessagesApi, new Formatters(jMessagesApi), validatorFactory, cfg)
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