001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.marshall.jcs; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.math.*; 023import java.util.*; 024import java.util.function.*; 025 026import org.apache.juneau.commons.bean.*; 027import org.apache.juneau.commons.lang.*; 028import org.apache.juneau.marshall.*; 029import org.apache.juneau.marshall.json.*; 030import org.apache.juneau.marshall.serializer.*; 031import org.apache.juneau.marshall.stream.*; 032 033/** 034 * Session object for {@link JcsSerializer} that produces RFC 8785 canonical JSON. 035 * 036 * <h5 class='section'>See Also:</h5><ul> 037 * <li class='link'><a class="doclink" href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785 — JSON Canonicalization Scheme</a> 038 * </ul> 039 */ 040@SuppressWarnings({ 041 "java:S110", // Inheritance depth acceptable for this class hierarchy 042 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 043 "java:S3776", // Cognitive complexity acceptable for ECMAScript number formatting and map serialization 044 "resource" // Resource management handled externally 045}) 046public class JcsSerializerSession extends JsonSerializerSession { 047 /** 048 * Builder class. 049 */ 050 public static class Builder extends JsonSerializerSession.Builder<Builder> { 051 052 /** 053 * Constructor 054 * 055 * @param ctx The context creating this session. 056 * <br>Cannot be <jk>null</jk>. 057 */ 058 protected Builder(JcsSerializer ctx) { 059 super(reqnn("ctx", ctx)); 060 } 061 062 @Override 063 public JcsSerializerSession build() { 064 return new JcsSerializerSession(this); 065 } 066 } 067 068 /** 069 * Creates a new builder for this object. 070 * 071 * @param ctx The context creating this session. 072 * <br>Cannot be <jk>null</jk>. 073 * @return A new builder. 074 */ 075 public static Builder create(JcsSerializer ctx) { 076 return new Builder(reqnn("ctx", ctx)); 077 } 078 079 /** 080 * Constructor. 081 * 082 * @param builder The builder for this object. 083 */ 084 protected JcsSerializerSession(Builder builder) { 085 super(builder); 086 } 087 088 /** 089 * Converts a number to ECMAScript-compatible JSON string per RFC 8785. 090 * 091 * @param n The number. 092 * <br>Must not be <jk>null</jk>. 093 * @return The serialized string. 094 * @throws SerializeException If the number is NaN or Infinity. 095 */ 096 public static String toEcmaNumber(Number n) { 097 if (n instanceof Double n2) { 098 if (Double.isNaN(n2)) 099 throw new SerializeException("NaN is not permitted in JCS (RFC 8785)"); 100 if (Double.isInfinite(n2)) 101 throw new SerializeException("Infinity is not permitted in JCS (RFC 8785)"); 102 if (n2 == -0.0) 103 return "0"; 104 return formatDouble(n2); 105 } 106 if (n instanceof Float n2) { 107 if (Float.isNaN(n2)) 108 throw new SerializeException("NaN is not permitted in JCS (RFC 8785)"); 109 if (Float.isInfinite(n2)) 110 throw new SerializeException("Infinity is not permitted in JCS (RFC 8785)"); 111 if (n2 == -0.0f) 112 return "0"; 113 return formatDouble(n2.doubleValue()); 114 } 115 if (n instanceof BigDecimal n2) 116 return formatBigDecimal(n2); 117 if (n instanceof BigInteger n2) 118 return formatBigInteger(n2); 119 if (n instanceof Long || n instanceof Integer || n instanceof Short || n instanceof Byte) { 120 var l = n.longValue(); 121 if (l == 0) 122 return "0"; 123 return Long.toString(l); 124 } 125 return formatDouble(n.doubleValue()); 126 } 127 128 private static final double ECMA_FIXED_LOW = 1e-6; 129 private static final double ECMA_FIXED_HIGH = 1e21; 130 131 private static String formatDouble(double d) { 132 if (d == 0.0) 133 return "0"; 134 var abs = Math.abs(d); 135 // ECMAScript uses fixed notation for |n| in [1e-6, 1e21), scientific otherwise 136 if (abs >= ECMA_FIXED_LOW && abs < ECMA_FIXED_HIGH) { 137 var s = BigDecimal.valueOf(d).toPlainString(); 138 s = stripTrailingZeros(s); 139 return s; 140 } 141 return formatDoubleScientific(d); 142 } 143 144 private static String stripTrailingZeros(String s) { 145 // Non-regex char scan avoids regex backtracking (java:S8786) while preserving exact semantics. 146 if (s.indexOf('.') < 0) 147 return s; 148 var end = s.length(); 149 while (end > 0 && s.charAt(end - 1) == '0') 150 end--; 151 if (end > 0 && s.charAt(end - 1) == '.') 152 end--; 153 return s.substring(0, end); 154 } 155 156 private static String formatDoubleScientific(double d) { 157 var s = Double.toString(d); 158 var idxE = s.indexOf('E'); 159 if (idxE < 0) 160 idxE = s.indexOf('e'); 161 if (idxE >= 0) { 162 var mantissa = s.substring(0, idxE); 163 var exp = s.substring(idxE + 1); 164 var expNum = Integer.parseInt(exp); 165 // ECMAScript: shorten mantissa (1.0 -> 1, 2.5 -> 2.5) 166 mantissa = stripTrailingZeros(mantissa); 167 if (eqa(mantissa, "1.0", "1")) 168 mantissa = "1"; 169 else if (mantissa.endsWith(".0")) 170 mantissa = mantissa.substring(0, mantissa.length() - 2); 171 s = mantissa + 'e' + (expNum >= 0 ? "+" + expNum : String.valueOf(expNum)); 172 } 173 return s; 174 } 175 176 private static String formatBigDecimal(BigDecimal bd) { 177 try { 178 var d = bd.doubleValue(); 179 if (Double.isNaN(d) || Double.isInfinite(d)) 180 throw new SerializeException("BigDecimal value exceeds IEEE 754 double range"); 181 return formatDouble(d); 182 } catch (@SuppressWarnings("unused") NumberFormatException e) { 183 throw new SerializeException("BigDecimal value exceeds IEEE 754 double range"); 184 } 185 } 186 187 private static String formatBigInteger(BigInteger bi) { 188 try { 189 var l = bi.longValueExact(); 190 return Long.toString(l); 191 } catch (@SuppressWarnings("unused") ArithmeticException e) { 192 throw new SerializeException("BigInteger value exceeds IEEE 754 safe integer range"); 193 } 194 } 195 196 /** 197 * JCS key comparison: UTF-16 code unit ordering per RFC 8785. 198 * 199 * <p> 200 * Null keys (e.g., from {@code HashMap} with null key) sort before non-null. 201 * 202 * @param a First string. 203 * @param b Second string. 204 * @return Comparison result (negative, zero, or positive). 205 */ 206 public static int jcsCompare(String a, String b) { 207 if (a == null) 208 return b == null ? 0 : -1; 209 if (b == null) 210 return 1; 211 var len = Math.min(a.length(), b.length()); 212 for (var i = 0; i < len; i++) { 213 var diff = Character.compare(a.charAt(i), b.charAt(i)); 214 if (diff != 0) 215 return diff; 216 } 217 return Integer.compare(a.length(), b.length()); 218 } 219 220 /** 221 * Opens a low-level push generator that emits raw JSON (NOT canonical JCS) one structural 222 * event at a time, with the {@link TokenWriter#object(Object)} POJO bridge disabled (the 223 * walker would emit ordinary JSON, not canonical JCS). Use {@link #writeRecords(Object)} 224 * or {@link #write(Object, Object)} for canonical output. 225 * 226 * @param output The output. 227 * @return A new {@link JsonTokenWriter} with {@code object(...)} disabled. 228 * @throws IOException If the output type is not supported or could not be opened. 229 */ 230 @Override /* TokenWritable */ 231 public TokenWriter writeTokens(Object output) throws IOException { 232 var walk = new PojoWalker.Options( 233 isKeepNullProperties(), 234 isTrimEmptyMaps(), 235 isTrimEmptyCollections(), 236 isSortMaps(), 237 isSortCollections(), 238 isTrimStrings(), 239 getMarshallingContext()); 240 var settings = new JsonTokenWriter.Settings( 241 isUseWhitespace(), 242 getMaxIndent(), 243 getQuoteChar(), 244 isEscapeSolidus(), 245 isTrimStrings(), 246 false /* simpleAttrs */, 247 walk, 248 true /* disableObject — JCS canonicalization can't be expressed via the POJO walker */); 249 return JsonTokenWriter.forOutput(output, settings); 250 } 251 252 /** 253 * Returns a record writer that emits canonical JCS for each value passed to 254 * {@link RecordWriter#write(Object) write(...)} (delegates to this session's 255 * {@link #write(Object, Object)}). 256 * 257 * @param output The output. 258 * @return A new {@link RecordWriter}. 259 * @throws IOException If the output type is not supported or could not be opened. 260 */ 261 @Override /* RecordWritable */ 262 public RecordWriter writeRecords(Object output) throws IOException { 263 return RecordAdapter.writer(this, output); 264 } 265 266 @Override /* RecordWritable */ 267 public boolean isRecordStreaming() { return false; } 268 269 @Override /* Overridden from JsonSerializerSession */ 270 protected JsonWriter<?> getJsonWriter(SerializerPipe out) { 271 var output = out.getRawOutput(); 272 if (output instanceof JcsWriter output2) 273 return output2; 274 var w = new JcsWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isEscapeSolidus(), getQuoteChar(), false, isTrimStrings(), getUriResolver()); 275 out.setWriter(w); 276 return w; 277 } 278 279 @SuppressWarnings({ 280 "rawtypes", // Raw types necessary for generic collection/map serialization 281 "unchecked" // Type erasure requires unchecked casts in collection/map serialization 282 }) 283 @Override /* Overridden from JsonSerializerSession - package visibility, override by making accessible */ 284 protected SerializerWriter<?> writeMap(JsonWriter<?> out, Map m, ClassMeta<?> type) throws SerializeException { 285 var keyType = type.getKeyType(); 286 var valueType = type.getValueType(); 287 288 var entries = new ArrayList<Map.Entry<?,?>>(m.entrySet()); 289 entries.sort((a, b) -> jcsCompare(toString(a.getKey()), toString(b.getKey()))); 290 291 var i = indent; 292 out.w('{'); 293 294 var addComma = Flag.create(); 295 for (Map.Entry<?,?> x : entries) { 296 Object value = x.getValue(); 297 Object key = generalize(x.getKey(), keyType); 298 addComma.ifSet(() -> out.w(',').smi(i)).set(); 299 out.cr(i).attr(toString(key)).w(':').s(i); 300 writeAnything(out, value, valueType, (key == null ? null : toString(key)), null); 301 } 302 303 out.cre(i - 1).w('}'); 304 return out; 305 } 306 307 private static class BeanProp implements Map.Entry<String,Object> { 308 final BeanPropertyMeta pMeta; 309 final String key; 310 final Object value; 311 312 BeanProp(BeanPropertyMeta pMeta, String key, Object value) { 313 this.pMeta = pMeta; 314 this.key = key; 315 this.value = value; 316 } 317 318 @Override 319 public String getKey() { return key; } 320 321 @Override 322 public Object getValue() { return value; } 323 324 @Override 325 public Object setValue(Object val) { throw new UnsupportedOperationException(); } 326 } 327 328 @Override /* Overridden from JsonSerializerSession */ 329 protected SerializerWriter<?> writeBeanMap(JsonWriter<?> out, BeanMap<?> m, String typeName) throws SerializeException { 330 var entries = new ArrayList<BeanProp>(); 331 Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x); 332 333 m.forEachValue(checkNull, (pMeta, key, value, thrown) -> { 334 if (nn(thrown)) 335 onBeanGetterException(pMeta, thrown); 336 if (canIgnoreValue(pMeta, key, value)) 337 return; 338 entries.add(new BeanProp(pMeta, key, value)); 339 }); 340 341 // Add _type property if present, then sort all by key using JCS UTF-16 comparison 342 if (nn(typeName)) { 343 var pm = m.getMeta().getTypeProperty(); 344 entries.add(new BeanProp(pm, pm.getName(), typeName)); 345 } 346 entries.sort((a, b) -> jcsCompare(a.getKey(), b.getKey())); 347 348 var i = indent; 349 out.w('{'); 350 351 var addComma = Flag.create(); 352 for (var entry : entries) { 353 addComma.ifSet(() -> out.append(',').smi(i)).set(); 354 out.cr(i).attr(entry.getKey()).w(':').s(i); 355 writeAnything(out, entry.getValue(), (ClassMeta<?>) entry.pMeta.getBeanInfo(), entry.getKey(), entry.pMeta); 356 } 357 358 out.cre(i - 1).w('}'); 359 return out; 360 } 361}