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}