Skip to content

Commit

Permalink
Preserve zero decimal (backport to 2.9.x) (#839)
Browse files Browse the repository at this point in the history
  • Loading branch information
trbogart authored Jan 23, 2023
1 parent 49f620e commit 43f7a61
Show file tree
Hide file tree
Showing 9 changed files with 461 additions and 133 deletions.
26 changes: 25 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import sbt._
import sbt.util._
import sbt.io.Path._

import com.typesafe.tools.mima.core.DirectMissingMethodProblem
import com.typesafe.tools.mima.core.IncompatibleMethTypeProblem
import com.typesafe.tools.mima.core.ProblemFilters
import com.typesafe.tools.mima.core.StaticVirtualMemberProblem
import com.typesafe.tools.mima.plugin.MimaKeys.mimaPreviousArtifacts

import sbtcrossproject.CrossPlugin.autoImport.crossProject
Expand Down Expand Up @@ -46,7 +50,27 @@ def playJsonMimaSettings = Seq(
case (Some(JSPlatform), Some("2.8.1")) => Set.empty
case (_, Some(previousVersion)) => Set(organization.value %%% moduleName.value % previousVersion)
case _ => throw new Error("Unable to determine previous version")
})
}),
mimaBinaryIssueFilters ++= Seq(
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.generateFromJsValue"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.jsValueToBytes"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.jsValueToJsonNode"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.jsonNodeToJsValue"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.jsonNodeToJsValue"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.parseJsValue"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JacksonJson.prettyPrint"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.BigDecimalParser.parse"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.jackson.JsValueDeserializer.this"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.jackson.JsValueSerializer.this"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.jackson.PlayDeserializers.this"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.jackson.PlaySerializers.this"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.generateFromJsValue"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.jsValueToBytes"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.jsValueToJsonNode"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.jsonNodeToJsValue"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.parseJsValue"),
ProblemFilters.exclude[StaticVirtualMemberProblem]("play.api.libs.json.jackson.JacksonJson.prettyPrint"),
)
)

// Workaround for https://github.com/scala-js/scala-js/issues/2378
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ package play.api.libs.json

private[json] object BigDecimalParser {

def parse(input: String, settings: JsonParserSettings): JsResult[java.math.BigDecimal] = {
def parse(input: String, jsonConfig: JsonConfig): JsResult[java.math.BigDecimal] = {

// There is a limit of how large the numbers can be since parsing extremely
// large numbers (think thousand of digits) and operating on the parsed values
// can potentially cause a DDoS.
if (input.length > settings.bigDecimalParseSettings.digitsLimit) {
if (input.length > jsonConfig.bigDecimalParseConfig.digitsLimit) {
JsError("error.expected.numberdigitlimit")
} else {
// Must create the BigDecimal with a MathContext that is consistent with the limits used.
try {
val bigDecimal = new java.math.BigDecimal(input, settings.bigDecimalParseSettings.mathContext)
val bigDecimal = new java.math.BigDecimal(input, jsonConfig.bigDecimalParseConfig.mathContext)

// We should also avoid numbers with scale that are out of a safe limit
val scale = bigDecimal.scale
if (Math.abs(scale) > settings.bigDecimalParseSettings.scaleLimit) {
if (Math.abs(scale) > jsonConfig.bigDecimalParseConfig.scaleLimit) {
JsError(JsonValidationError("error.expected.numberscalelimit", scale))
} else {
JsSuccess(bigDecimal)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ trait EnvReads {
*/
implicit object JsonNodeReads extends Reads[JsonNode] {
def reads(json: JsValue): JsResult[JsonNode] =
JsSuccess(JacksonJson.jsValueToJsonNode(json))
JsSuccess(JacksonJson.get.jsValueToJsonNode(json))
}

/**
* Deserializer for Jackson ObjectNode
*/
implicit object ObjectNodeReads extends Reads[ObjectNode] {
def reads(json: JsValue): JsResult[ObjectNode] = {
json.validate[JsObject].map(jo => JacksonJson.jsValueToJsonNode(jo).asInstanceOf[ObjectNode])
json.validate[JsObject].map(jo => JacksonJson.get.jsValueToJsonNode(jo).asInstanceOf[ObjectNode])
}
}

Expand All @@ -57,7 +57,7 @@ trait EnvReads {
*/
implicit object ArrayNodeReads extends Reads[ArrayNode] {
def reads(json: JsValue): JsResult[ArrayNode] = {
json.validate[JsArray].map(ja => JacksonJson.jsValueToJsonNode(ja).asInstanceOf[ArrayNode])
json.validate[JsArray].map(ja => JacksonJson.get.jsValueToJsonNode(ja).asInstanceOf[ArrayNode])
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ trait EnvWrites {

@deprecated("Use `jsonNodeWrites`", "2.8.0")
object JsonNodeWrites extends Writes[JsonNode] {
def writes(o: JsonNode): JsValue = JacksonJson.jsonNodeToJsValue(o)
def writes(o: JsonNode): JsValue = JacksonJson.get.jsonNodeToJsValue(o)
}

/**
* Serializer for Jackson JsonNode
*/
implicit def jsonNodeWrites[T <: JsonNode]: Writes[T] =
Writes[T](JacksonJson.jsonNodeToJsValue)
Writes[T](JacksonJson.get.jsonNodeToJsValue)

/** Typeclass to implement way of formatting of Java8 temporal types. */
trait TemporalFormatter[T <: Temporal] {
Expand Down
283 changes: 283 additions & 0 deletions play-json/jvm/src/main/scala/play/api/libs/json/JsonConfig.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
/*
* Copyright (C) 2009-2020 Lightbend Inc. <https://www.lightbend.com>
*/

package play.api.libs.json

import play.api.libs.json.JsonConfig.defaultMaxPlain
import play.api.libs.json.JsonConfig.defaultMinPlain
import play.api.libs.json.JsonConfig.defaultDigitsLimit
import play.api.libs.json.JsonConfig.defaultMathContext
import play.api.libs.json.JsonConfig.defaultPreserveZeroDecimal
import play.api.libs.json.JsonConfig.defaultScaleLimit
import play.api.libs.json.JsonConfig.loadDigitsLimit
import play.api.libs.json.JsonConfig.loadMathContext
import play.api.libs.json.JsonConfig.loadMaxPlain
import play.api.libs.json.JsonConfig.loadMinPlain
import play.api.libs.json.JsonConfig.loadScaleLimit

import java.math.MathContext

import scala.util.control.NonFatal

/**
* Parse and serialization settings for BigDecimals. Defines limits that will be used when parsing the BigDecimals,
* like how many digits are accepted.
*/
sealed trait BigDecimalParseConfig {

/**
* The [[MathContext]] used when parsing, which will be "decimal32", "decimal64", "decimal128" (default),
* or "unlimited".
* This can be set using the [[JsonConfig.mathContextProperty]] system property.
*/
def mathContext: MathContext

/**
* Limits the scale, and it is related to the math context used.
* The default value is [[JsonConfig.defaultScaleLimit]].
* This can be set using the [[JsonConfig.scaleLimitProperty]] system property.
*/
def scaleLimit: Int

/**
* How many digits are accepted, also related to the math context used.
* The default value is [[JsonConfig.defaultDigitsLimit]].
* This can be set using the [[JsonConfig.digitsLimitProperty]] system property.
*/
def digitsLimit: Int
}

object BigDecimalParseConfig {
def apply(
mathContext: MathContext = defaultMathContext,
scaleLimit: Int = defaultScaleLimit,
digitsLimit: Int = defaultDigitsLimit
): BigDecimalParseConfig = BigDecimalParseConfigImpl(mathContext, scaleLimit, digitsLimit)
}

private final case class BigDecimalParseConfigImpl(mathContext: MathContext, scaleLimit: Int, digitsLimit: Int)
extends BigDecimalParseConfig

sealed trait BigDecimalSerializerConfig {

/**
* Minimum magnitude of BigDecimal to write out as a plain string.
* Defaults to [[JsonConfig.defaultMinPlain]].
* This can be set using the [[JsonConfig.minPlainProperty]] system property.
*/
def minPlain: BigDecimal

/**
* Maximum magnitude of BigDecimal to write out as a plain string.
* Defaults to [[JsonConfig.defaultMaxPlain]].
* This can be set using the [[JsonConfig.maxPlainProperty]] system property.
*/
def maxPlain: BigDecimal

/**
* True to preserve a zero decimal , or false to drop them (the default).
* For example, 1.00 will be serialized as 1 if false or 1.0 if true (only a single zero is preserved).
* Other trailing zeroes will be dropped regardless of this value.
* For example, 1.1000 will always be serialized as 1.1.
* This can be set using the [[JsonConfig.preserveZeroDecimalProperty]] system property.
*/
def preserveZeroDecimal: Boolean
}

object BigDecimalSerializerConfig {
def apply(
minPlain: BigDecimal = defaultMinPlain,
maxPlain: BigDecimal = defaultMaxPlain,
preserveZeroDecimal: Boolean = defaultPreserveZeroDecimal
): BigDecimalSerializerConfig =
DecimalSerializerSettingsImpl(minPlain, maxPlain, preserveZeroDecimal)
}

private final case class DecimalSerializerSettingsImpl(
minPlain: BigDecimal,
maxPlain: BigDecimal,
preserveZeroDecimal: Boolean
) extends BigDecimalSerializerConfig

sealed trait JsonConfig {
def bigDecimalParseConfig: BigDecimalParseConfig
def bigDecimalSerializerConfig: BigDecimalSerializerConfig
}

object JsonConfig {

/**
* The default math context ("decimal128").
*/
val defaultMathContext: MathContext = MathContext.DECIMAL128

/**
* The default limit for the scale considering the default MathContext of decimal128.
* limit for scale for decimal128: BigDecimal("0." + "0" * 33 + "1e-6143", java.math.MathContext.DECIMAL128).scale + 1
*/
val defaultScaleLimit: Int = 6178

/**
* The default limit for digits considering the default MathContext of decimal128.
* 307 digits should be the correct value for 128 bytes. But we are using 310
* because Play JSON uses BigDecimal to parse any number including Doubles and
* Doubles max value has 309 digits, so we are using 310 here
*/
val defaultDigitsLimit: Int = 310

/**
* Zero decimal values (e.g. .0 or .00) or dropped by default.
* For example, a value of 1.0 or 1.00 will be serialized as 1.
*/
val defaultPreserveZeroDecimal: Boolean = false

/**
* The default maximum magnitude of BigDecimal to write out as a plain string.
*/
val defaultMaxPlain: BigDecimal = 1E20

/**
* The default minimum magnitude of BigDecimal to write out as a plain string.
*/
val defaultMinPlain: BigDecimal = 1E-10

/**
* The system property to override the scale limit.
*/
val scaleLimitProperty: String = "play.json.parser.scaleLimit"

/**
* The system property to override the digits limit
*/
val digitsLimitProperty: String = "play.json.parser.digitsLimit"

/**
* The system property to override the math context. This can be "decimal32", "decimal64", "decimal128" (the default),
* or "unlimited".
*/
val mathContextProperty: String = "play.json.parser.mathContext"

/**
* The system property to override the minimum magnitude of BigDecimal to write out as a plain string
*/
val minPlainProperty: String = "play.json.serializer.minPlain"

/**
* The system property to override the maximum magnitude of BigDecimal to write out as a plain string
*/
val maxPlainProperty: String = "play.json.serializer.maxPlain"

/**
* The system property to override whether zero decimals (e.g. .0 or .00) are written by default. These are dropped by default.
*/
val preserveZeroDecimalProperty: String = "play.json.serializer.preserveZeroDecimal"

private[json] def loadScaleLimit: Int = prop(scaleLimitProperty, defaultScaleLimit)(_.toInt)

private[json] def loadDigitsLimit: Int = prop(digitsLimitProperty, defaultDigitsLimit)(_.toInt)

private[json] def loadMathContext: MathContext = parseMathContext(mathContextProperty)

private[json] def loadMinPlain: BigDecimal = prop(minPlainProperty, defaultMinPlain)(BigDecimal.exact)

private[json] def loadMaxPlain: BigDecimal = prop(maxPlainProperty, defaultMaxPlain)(BigDecimal.exact)

private[json] def loadPreserveZeroDecimal: Boolean =
prop(preserveZeroDecimalProperty, defaultPreserveZeroDecimal)(_.toBoolean)

// Default settings, which can be controlled with system properties.
// To override, call JacksonJson.setConfig()
val settings: JsonConfig =
JsonConfig(
BigDecimalParseConfig(loadMathContext, loadScaleLimit, loadDigitsLimit),
BigDecimalSerializerConfig(loadMinPlain, loadMaxPlain, loadPreserveZeroDecimal)
)

def apply(): JsonConfig = apply(BigDecimalParseConfig(), BigDecimalSerializerConfig())

def apply(
bigDecimalParseConfig: BigDecimalParseConfig,
bigDecimalSerializerConfig: BigDecimalSerializerConfig
): JsonConfig =
JsonConfigImpl(bigDecimalParseConfig, bigDecimalSerializerConfig)

private[json] def parseMathContext(key: String): MathContext = sys.props.get(key).map(_.toLowerCase) match {
case Some("decimal128") => MathContext.DECIMAL128
case Some("decimal64") => MathContext.DECIMAL64
case Some("decimal32") => MathContext.DECIMAL32
case Some("unlimited") => MathContext.UNLIMITED
case _ => defaultMathContext
}

private[json] def prop[T](key: String, default: T)(f: String => T): T =
try {
sys.props.get(key).map(f).getOrElse(default)
} catch {
case NonFatal(_) => default
}
}

private final case class JsonConfigImpl(
bigDecimalParseConfig: BigDecimalParseConfig,
bigDecimalSerializerConfig: BigDecimalSerializerConfig
) extends JsonConfig

@deprecated("Use BigDecimalParseConfig instead", "2.9.4")
final case class BigDecimalParseSettings(
mathContext: MathContext = MathContext.DECIMAL128,
scaleLimit: Int,
digitsLimit: Int
) extends BigDecimalParseConfig

@deprecated("Use BigDecimalSerializerConfig instead", "2.9.4")
final case class BigDecimalSerializerSettings(
minPlain: BigDecimal,
maxPlain: BigDecimal
) extends BigDecimalSerializerConfig {
override def preserveZeroDecimal: Boolean = defaultPreserveZeroDecimal
}

@deprecated("Use JsonConfig instead", "2.9.4")
final case class JsonParserSettings(
bigDecimalParseSettings: BigDecimalParseSettings,
bigDecimalSerializerSettings: BigDecimalSerializerSettings
) extends JsonConfig {
override def bigDecimalParseConfig: BigDecimalParseConfig = bigDecimalParseSettings

override def bigDecimalSerializerConfig: BigDecimalSerializerConfig = bigDecimalSerializerSettings
}

object JsonParserSettings {
val defaultMathContext: MathContext = JsonConfig.defaultMathContext

// Limit for the scale considering the MathContext of 128
// limit for scale for decimal128: BigDecimal("0." + "0" * 33 + "1e-6143", java.math.MathContext.DECIMAL128).scale + 1
val defaultScaleLimit: Int = JsonConfig.defaultScaleLimit

// 307 digits should be the correct value for 128 bytes. But we are using 310
// because Play JSON uses BigDecimal to parse any number including Doubles and
// Doubles max value has 309 digits, so we are using 310 here
val defaultDigitsLimit: Int = JsonConfig.defaultDigitsLimit

// Maximum magnitude of BigDecimal to write out as a plain string
val MaxPlain: BigDecimal = JsonConfig.defaultMaxPlain

// Minimum magnitude of BigDecimal to write out as a plain string
val MinPlain: BigDecimal = JsonConfig.defaultMinPlain

def apply(): JsonParserSettings = JsonParserSettings(
BigDecimalParseSettings(defaultMathContext, defaultScaleLimit, defaultDigitsLimit),
BigDecimalSerializerSettings(minPlain = MinPlain, maxPlain = MaxPlain)
)

/**
* Return the default settings configured from System properties.
*/
val settings: JsonParserSettings = {
JsonParserSettings(
BigDecimalParseSettings(loadMathContext, loadScaleLimit, loadDigitsLimit),
BigDecimalSerializerSettings(loadMinPlain, loadMaxPlain)
)
}
}
Loading

0 comments on commit 43f7a61

Please sign in to comment.