|
| 1 | +// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +package com.digitalasset.canton.daml.lf.value.json |
| 5 | + |
| 6 | +import com.digitalasset.canton.daml.lf.value.json.NavigatorModelAliases as Model |
| 7 | +import com.digitalasset.daml.lf.data.ImmArray.ImmArraySeq |
| 8 | +import com.digitalasset.daml.lf.data.ScalazEqual.* |
| 9 | +import com.digitalasset.daml.lf.data.{ |
| 10 | + FrontStack, |
| 11 | + ImmArray, |
| 12 | + Numeric as LfNumeric, |
| 13 | + Ref, |
| 14 | + SortedLookupList, |
| 15 | + Time, |
| 16 | +} |
| 17 | +import com.digitalasset.daml.lf.typesig |
| 18 | +import com.digitalasset.daml.lf.value.Value as V |
| 19 | +import com.digitalasset.daml.lf.value.Value.ContractId |
| 20 | +import scalaz.syntax.std.string.* |
| 21 | +import scalaz.{@@, Tag} |
| 22 | +import spray.json.* |
| 23 | + |
| 24 | +import java.time.Instant |
| 25 | +import scala.annotation.nowarn |
| 26 | +import scala.util.Try |
| 27 | + |
| 28 | +import NavigatorModelAliases.DamlLfIdentifier |
| 29 | +import ApiValueImplicits.* |
| 30 | + |
| 31 | +/** A compressed encoding of API values. |
| 32 | + * |
| 33 | + * The encoded values do not include type information. For example, it is impossible to distinguish |
| 34 | + * party and text values in the encoded format. |
| 35 | + * |
| 36 | + * Therefore, this JSON format can only decode given a target type. |
| 37 | + * |
| 38 | + * `apiValueJsonReader` can create a JSON reader with the necessary type information. |
| 39 | + * |
| 40 | + * @param encodeDecimalAsString |
| 41 | + * Not used yet. |
| 42 | + * @param encodeInt64AsString |
| 43 | + * Not used yet. |
| 44 | + */ |
| 45 | +class ApiCodecCompressed(val encodeDecimalAsString: Boolean, val encodeInt64AsString: Boolean)( |
| 46 | + implicit |
| 47 | + readCid: JsonReader[ContractId], |
| 48 | + writeCid: JsonWriter[ContractId], |
| 49 | +) { self => |
| 50 | + |
| 51 | + // ------------------------------------------------------------------------------------------------------------------ |
| 52 | + // Encoding |
| 53 | + // ------------------------------------------------------------------------------------------------------------------ |
| 54 | + def apiValueToJsValue(value: V): JsValue = value match { |
| 55 | + case v: V.ValueRecord => apiRecordToJsValue(v) |
| 56 | + case v: V.ValueVariant => apiVariantToJsValue(v) |
| 57 | + case v: V.ValueEnum => apiEnumToJsValue(v) |
| 58 | + case v: V.ValueList => apiListToJsValue(v) |
| 59 | + case V.ValueText(v) => JsString(v) |
| 60 | + case V.ValueInt64(v) => if (encodeInt64AsString) JsString((v: Long).toString) else JsNumber(v) |
| 61 | + case V.ValueNumeric(v) => |
| 62 | + if (encodeDecimalAsString) JsString(LfNumeric.toUnscaledString(v)) else JsNumber(v) |
| 63 | + case V.ValueBool(v) => JsBoolean(v) |
| 64 | + case V.ValueContractId(v) => apiContractIdToJsValue(v) |
| 65 | + case t: V.ValueTimestamp => JsString(t.toIso8601) |
| 66 | + case d: V.ValueDate => JsString(d.toIso8601) |
| 67 | + case V.ValueParty(v) => JsString(v) |
| 68 | + case V.ValueUnit => JsObject.empty |
| 69 | + case V.ValueOptional(None) => JsNull |
| 70 | + case V.ValueOptional(Some(v)) => |
| 71 | + v match { |
| 72 | + case V.ValueOptional(None) => JsArray() |
| 73 | + case V.ValueOptional(Some(_)) => JsArray(apiValueToJsValue(v)) |
| 74 | + case _ => apiValueToJsValue(v) |
| 75 | + } |
| 76 | + case textMap: V.ValueTextMap => |
| 77 | + apiMapToJsValue(textMap) |
| 78 | + case genMap: V.ValueGenMap => |
| 79 | + apiGenMapToJsValue(genMap) |
| 80 | + } |
| 81 | + |
| 82 | + @throws[SerializationException] |
| 83 | + private[this] final def apiContractIdToJsValue(v: ContractId): JsValue = v.toJson |
| 84 | + |
| 85 | + private[this] def apiListToJsValue(value: V.ValueList): JsValue = |
| 86 | + JsArray(value.values.map(apiValueToJsValue(_)).toImmArray.toSeq*) |
| 87 | + |
| 88 | + private[this] def apiVariantToJsValue(value: V.ValueVariant): JsValue = |
| 89 | + JsonVariant(value.variant, apiValueToJsValue(value.value)) |
| 90 | + |
| 91 | + private[this] def apiEnumToJsValue(value: V.ValueEnum): JsValue = |
| 92 | + JsString(value.value) |
| 93 | + |
| 94 | + private[ApiCodecCompressed] def apiRecordToJsValue(value: V.ValueRecord): JsValue = { |
| 95 | + val namedOrNoneFields = value.fields.toSeq collect { |
| 96 | + case (Some(k), v) => Some((k, v)) |
| 97 | + case (_, V.ValueOptional(None)) => None |
| 98 | + } |
| 99 | + if (namedOrNoneFields.lengthIs == value.fields.length) |
| 100 | + JsObject(namedOrNoneFields.iterator.collect { case Some((flabel, fvalue)) => |
| 101 | + (flabel: String) -> apiValueToJsValue(fvalue) |
| 102 | + }.toMap) |
| 103 | + else |
| 104 | + JsArray(value.fields.toSeq.map { case (_, fvalue) => |
| 105 | + apiValueToJsValue(fvalue) |
| 106 | + }*) |
| 107 | + } |
| 108 | + |
| 109 | + private[this] def apiMapToJsValue(value: V.ValueTextMap): JsValue = |
| 110 | + JsObject( |
| 111 | + value.value |
| 112 | + .mapValue(apiValueToJsValue) |
| 113 | + .toHashMap |
| 114 | + ) |
| 115 | + |
| 116 | + private[this] def apiGenMapToJsValue(value: V.ValueGenMap): JsValue = |
| 117 | + JsArray( |
| 118 | + value.entries.map { case (key, value) => |
| 119 | + JsArray(apiValueToJsValue(key), apiValueToJsValue(value)) |
| 120 | + }.toSeq* |
| 121 | + ) |
| 122 | + |
| 123 | + // ------------------------------------------------------------------------------------------------------------------ |
| 124 | + // Decoding - this needs access to Daml-LF types |
| 125 | + // ------------------------------------------------------------------------------------------------------------------ |
| 126 | + |
| 127 | + @throws[DeserializationException] |
| 128 | + private[this] final def jsValueToApiContractId(value: JsValue): ContractId = |
| 129 | + value.convertTo[ContractId] |
| 130 | + |
| 131 | + @SuppressWarnings(Array("org.wartremover.warts.IterableOps")) |
| 132 | + private[this] def jsValueToApiPrimitive( |
| 133 | + value: JsValue, |
| 134 | + prim: Model.DamlLfTypePrim, |
| 135 | + defs: Model.DamlLfTypeLookup, |
| 136 | + ): V = |
| 137 | + (prim.typ, value).match2 { |
| 138 | + case Model.DamlLfPrimType.Int64 => { |
| 139 | + case JsString(v) => V.ValueInt64(assertDE(v.parseLong.leftMap(_.getMessage).toEither)) |
| 140 | + case JsNumber(v) if v.isValidLong => V.ValueInt64(v.toLongExact) |
| 141 | + } |
| 142 | + case Model.DamlLfPrimType.Text => { case JsString(v) => V.ValueText(v) } |
| 143 | + case Model.DamlLfPrimType.Party => { case JsString(v) => |
| 144 | + V.ValueParty(assertDE(Ref.Party fromString v)) |
| 145 | + } |
| 146 | + case Model.DamlLfPrimType.ContractId => { case v => |
| 147 | + V.ValueContractId(jsValueToApiContractId(v)) |
| 148 | + } |
| 149 | + case Model.DamlLfPrimType.Unit => { case JsObject(_) => V.ValueUnit } |
| 150 | + case Model.DamlLfPrimType.Timestamp => { case JsString(v) => |
| 151 | + val optTimestamp = for { |
| 152 | + instant <- Try(Instant.parse(v)).toEither.left.map(_.getMessage) |
| 153 | + timestamp <- Time.Timestamp.fromInstant(instant) |
| 154 | + } yield timestamp |
| 155 | + V.ValueTimestamp(assertDE(optTimestamp)) |
| 156 | + } |
| 157 | + case Model.DamlLfPrimType.Date => { case JsString(v) => |
| 158 | + try { |
| 159 | + V.ValueDate.fromIso8601(v) |
| 160 | + } catch { |
| 161 | + case _: java.time.format.DateTimeParseException | _: IllegalArgumentException => |
| 162 | + throw DeserializationException(s"Invalid date: $v") |
| 163 | + } |
| 164 | + } |
| 165 | + case Model.DamlLfPrimType.Bool => { case JsBoolean(v) => V.ValueBool(v) } |
| 166 | + case Model.DamlLfPrimType.List => { case JsArray(v) => |
| 167 | + V.ValueList( |
| 168 | + v.iterator.map(e => jsValueToApiValue(e, prim.typArgs.head, defs)).to(FrontStack) |
| 169 | + ) |
| 170 | + } |
| 171 | + case Model.DamlLfPrimType.Optional => |
| 172 | + val typArg = prim.typArgs.head |
| 173 | + val useArray = nestsOptional(prim) |
| 174 | + |
| 175 | + { |
| 176 | + case JsNull => V.ValueNone |
| 177 | + case JsArray(ov) if useArray => |
| 178 | + ov match { |
| 179 | + case Seq() => V.ValueOptional(Some(V.ValueNone)) |
| 180 | + case Seq(v) => V.ValueOptional(Some(jsValueToApiValue(v, typArg, defs))) |
| 181 | + case _ => |
| 182 | + deserializationError(s"Can't read ${value.prettyPrint} as Optional of Optional") |
| 183 | + } |
| 184 | + case _ if !useArray => V.ValueOptional(Some(jsValueToApiValue(value, typArg, defs))) |
| 185 | + } |
| 186 | + case Model.DamlLfPrimType.TextMap => { case JsObject(a) => |
| 187 | + V.ValueTextMap(SortedLookupList(a.transform { (_, v) => |
| 188 | + jsValueToApiValue(v, prim.typArgs.head, defs) |
| 189 | + })) |
| 190 | + } |
| 191 | + case Model.DamlLfPrimType.GenMap => |
| 192 | + val Seq(kType, vType) = prim.typArgs: @nowarn("msg=match may not be exhaustive") |
| 193 | + |
| 194 | + { case JsArray(entries) => |
| 195 | + type OK[K] = Vector[(K, V)] |
| 196 | + val decEntries: Vector[(V @@ defs.type, V)] = Tag |
| 197 | + .subst[V, OK, defs.type](entries.map { |
| 198 | + case JsArray(Vector(key, value)) => |
| 199 | + jsValueToApiValue(key, kType, defs) -> |
| 200 | + jsValueToApiValue(value, vType, defs) |
| 201 | + case _ => |
| 202 | + deserializationError(s"Can't read ${value.prettyPrint} as key+value of $prim") |
| 203 | + }) |
| 204 | + V.ValueGenMap(Tag.unsubst[V, OK, defs.type](decEntries).to(ImmArray)) |
| 205 | + } |
| 206 | + |
| 207 | + }(fallback = deserializationError(s"Can't read ${value.prettyPrint} as $prim")) |
| 208 | + |
| 209 | + private[this] def nestsOptional(prim: typesig.TypePrim): Boolean = |
| 210 | + prim match { |
| 211 | + case typesig.TypePrim(_, Seq(typesig.TypePrim(typesig.PrimType.Optional, _))) => true |
| 212 | + case _ => false |
| 213 | + } |
| 214 | + |
| 215 | + private[this] def jsValueToApiDataType( |
| 216 | + value: JsValue, |
| 217 | + id: DamlLfIdentifier, |
| 218 | + dt: Model.DamlLfDataType, |
| 219 | + defs: Model.DamlLfTypeLookup, |
| 220 | + ): V = |
| 221 | + (dt, value).match2 { |
| 222 | + case Model.DamlLfRecord(fields) => { |
| 223 | + case JsObject(v) => |
| 224 | + V.ValueRecord( |
| 225 | + Some(id), |
| 226 | + fields.map { case (fName, fTy) => |
| 227 | + val fValue = v |
| 228 | + .get(fName) |
| 229 | + .map(jsValueToApiValue(_, fTy, defs)) |
| 230 | + .getOrElse(fTy match { |
| 231 | + case typesig.TypePrim(typesig.PrimType.Optional, _) => V.ValueNone |
| 232 | + case _ => |
| 233 | + deserializationError( |
| 234 | + s"Can't read ${value.prettyPrint} as DamlLfRecord $id, missing field '$fName'" |
| 235 | + ) |
| 236 | + }) |
| 237 | + (Some(fName), fValue) |
| 238 | + }.toImmArray, |
| 239 | + ) |
| 240 | + case JsArray(fValues) => |
| 241 | + if (fValues.sizeCompare(fields) != 0) |
| 242 | + deserializationError( |
| 243 | + s"Can't read ${value.prettyPrint} as DamlLfRecord $id, wrong number of record fields (expected ${fields.length}, found ${fValues.length})." |
| 244 | + ) |
| 245 | + else |
| 246 | + V.ValueRecord( |
| 247 | + Some(id), |
| 248 | + (fields zip fValues).map { case ((fName, fTy), fValue) => |
| 249 | + (Some(fName), jsValueToApiValue(fValue, fTy, defs)) |
| 250 | + }.toImmArray, |
| 251 | + ) |
| 252 | + } |
| 253 | + case Model.DamlLfVariant(cons) => { |
| 254 | + case JsonVariant(tag, nestedValue) => |
| 255 | + val (constructorName, constructorType) = cons.toList |
| 256 | + .find(_._1 == tag) |
| 257 | + .getOrElse( |
| 258 | + deserializationError( |
| 259 | + s"Can't read ${value.compactPrint} as DamlLfVariant $id, unknown constructor $tag" |
| 260 | + ) |
| 261 | + ) |
| 262 | + |
| 263 | + V.ValueVariant( |
| 264 | + Some(id), |
| 265 | + constructorName, |
| 266 | + jsValueToApiValue(nestedValue, constructorType, defs), |
| 267 | + ) |
| 268 | + case _ => |
| 269 | + deserializationError( |
| 270 | + s"Can't read ${value.prettyPrint} as DamlLfVariant $id, expected JsObject with 'tag' and 'value' fields" |
| 271 | + ) |
| 272 | + } |
| 273 | + case Model.DamlLfEnum(cons) => { case JsString(c) => |
| 274 | + cons |
| 275 | + .collectFirst { case kc if kc == c => kc } |
| 276 | + .map( |
| 277 | + V.ValueEnum( |
| 278 | + Some(id), |
| 279 | + _, |
| 280 | + ) |
| 281 | + ) |
| 282 | + .getOrElse( |
| 283 | + deserializationError( |
| 284 | + s"Can't read ${value.prettyPrint} as DamlLfEnum $id, unknown constructor $c" |
| 285 | + ) |
| 286 | + ) |
| 287 | + } |
| 288 | + }(fallback = deserializationError(s"Can't read ${value.prettyPrint} as $dt")) |
| 289 | + |
| 290 | + /** Deserialize a value, given the type */ |
| 291 | + def jsValueToApiValue( |
| 292 | + value: JsValue, |
| 293 | + typ: Model.DamlLfType, |
| 294 | + defs: Model.DamlLfTypeLookup, |
| 295 | + ): V = |
| 296 | + typ match { |
| 297 | + case prim: Model.DamlLfTypePrim => |
| 298 | + jsValueToApiPrimitive(value, prim, defs) |
| 299 | + case typeCon: Model.DamlLfTypeCon => |
| 300 | + val id = typeCon.tycon.identifier |
| 301 | + val dt = |
| 302 | + typeCon.instantiate(defs(id).getOrElse(deserializationError(s"Type $id not found"))) |
| 303 | + jsValueToApiDataType(value, id, dt, defs) |
| 304 | + case Model.DamlLfTypeNumeric(scale) => |
| 305 | + val numericOrError = value match { |
| 306 | + case JsString(v) => |
| 307 | + LfNumeric.checkWithinBoundsAndRound(scale, BigDecimal(v)) |
| 308 | + case JsNumber(v) => |
| 309 | + LfNumeric.checkWithinBoundsAndRound(scale, v) |
| 310 | + case _ => |
| 311 | + deserializationError(s"Can't read ${value.prettyPrint} as (Numeric $scale)") |
| 312 | + } |
| 313 | + V.ValueNumeric(assertDE(numericOrError)) |
| 314 | + case Model.DamlLfTypeVar(_) => |
| 315 | + deserializationError(s"Can't read ${value.prettyPrint} as DamlLfTypeVar") |
| 316 | + } |
| 317 | + |
| 318 | + /** Deserialize a value, given the ID of the corresponding closed type */ |
| 319 | + def jsValueToApiValue( |
| 320 | + value: JsValue, |
| 321 | + id: Model.DamlLfIdentifier, |
| 322 | + defs: Model.DamlLfTypeLookup, |
| 323 | + ): V = { |
| 324 | + val typeCon = Model.DamlLfTypeCon(Model.DamlLfTypeConId(id), ImmArraySeq()) |
| 325 | + val dt = typeCon.instantiate(defs(id).getOrElse(deserializationError(s"Type $id not found"))) |
| 326 | + jsValueToApiDataType(value, id, dt, defs) |
| 327 | + } |
| 328 | + |
| 329 | + private[this] def assertDE[A](ea: Either[String, A]): A = |
| 330 | + ea.fold(deserializationError(_), identity) |
| 331 | + |
| 332 | + private[json] def copy( |
| 333 | + encodeDecimalAsString: Boolean = this.encodeDecimalAsString, |
| 334 | + encodeInt64AsString: Boolean = this.encodeInt64AsString, |
| 335 | + ): ApiCodecCompressed = |
| 336 | + new ApiCodecCompressed( |
| 337 | + encodeDecimalAsString = encodeDecimalAsString, |
| 338 | + encodeInt64AsString = encodeInt64AsString, |
| 339 | + ) |
| 340 | +} |
| 341 | + |
| 342 | +private[json] object JsonContractIdFormat { |
| 343 | + implicit val ContractIdFormat: JsonFormat[ContractId] = |
| 344 | + new JsonFormat[ContractId] { |
| 345 | + override def write(obj: ContractId) = |
| 346 | + JsString(obj.coid) |
| 347 | + override def read(json: JsValue) = json match { |
| 348 | + case JsString(s) => |
| 349 | + ContractId.fromString(s).fold(deserializationError(_), identity) |
| 350 | + case _ => deserializationError("ContractId must be a string") |
| 351 | + } |
| 352 | + } |
| 353 | +} |
| 354 | +import JsonContractIdFormat.* |
| 355 | + |
| 356 | +object ApiCodecCompressed |
| 357 | + extends ApiCodecCompressed(encodeDecimalAsString = true, encodeInt64AsString = true) { |
| 358 | + // ------------------------------------------------------------------------------------------------------------------ |
| 359 | + // Implicits that can be imported to write JSON |
| 360 | + // ------------------------------------------------------------------------------------------------------------------ |
| 361 | + object JsonImplicits extends DefaultJsonProtocol { |
| 362 | + implicit object ApiValueJsonFormat extends RootJsonWriter[Model.ApiValue] { |
| 363 | + def write(v: Model.ApiValue): JsValue = apiValueToJsValue(v) |
| 364 | + } |
| 365 | + implicit object ApiRecordJsonFormat extends RootJsonWriter[Model.ApiRecord] { |
| 366 | + def write(v: Model.ApiRecord): JsValue = apiRecordToJsValue(v) |
| 367 | + } |
| 368 | + |
| 369 | + implicit val ContractIdFormat: JsonFormat[ContractId] = JsonContractIdFormat.ContractIdFormat |
| 370 | + } |
| 371 | +} |
0 commit comments