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.console.bus;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.regex.*;
023
024import org.apache.juneau.marshall.collections.*;
025import org.apache.juneau.marshall.json.JsonParser;
026import org.apache.juneau.marshall.marshaller.*;
027
028/**
029 * The bus wire format, version 1: one JSON object per frame, in a canonical field order shared with the
030 * browser codec and pinned by {@code bus-frames-corpus.json}.
031 *
032 * <p>
033 * The SSE transport writes each frame as {@code event: bus}, {@code id: <seq>}, {@code data: <frame>}; the WebSocket
034 * transport sends one frame per text message.
035 *
036 * <h5 class='section'>Example:</h5>
037 * <p class='bjava'>
038 *   String <jv>f</jv> = BusFrames.<jsm>pub</jsm>(<js>"ops.jobs"</js>, Json.<jsm>of</jsm>(<jv>payload</jv>), <jk>true</jk>, 7);
039 *   <jc>// {"v":1,"type":"pub","topic":"ops.jobs","payload":{...},"retained":true,"seq":7}</jc>
040 *
041 *   JsonMap <jv>up</jv> = BusFrames.<jsm>decode</jsm>(<jv>text</jv>);   <jc>// BusRefusal bus:bad-frame when malformed</jc>
042 * </p>
043 *
044 * @since 10.0.0
045 */
046@SuppressWarnings({
047   "java:S127", // utf8Length() and quote() skip the low surrogate of a pair inside the loop body.
048   "java:S1192", // Frame field names and types are wire-format literals; named constants would not clarify them.
049   "java:S3776" // Branching is inherent to decode() and isSingleObject().
050})
051public final class BusFrames {
052
053   /** The wire format version. */
054   public static final int VERSION = 1;
055
056   /** The SSE event name carrying frames. */
057   public static final String EVENT = "bus";
058
059   /** The frame types of version 1. */
060   public static final Set<String> TYPES = Set.of("pub", "clear", "resync-begin", "resync-end", "ping", "error");
061
062   /** The largest {@code seq} both codecs represent exactly: {@code Number.MAX_SAFE_INTEGER}. */
063   private static final long MAX_SAFE_SEQ = 9007199254740991L;
064
065   private static final Pattern TRAILING_SEQ = Pattern.compile("\"seq\":(\\d+)}$");
066
067   private BusFrames() {}
068
069   /**
070    * A {@code pub} frame.
071    *
072    * @param topic The topic.
073    * @param payloadJson The payload, already serialized as JSON.
074    * @param retained Whether the value is retained.
075    * @param seq The session sequence number.
076    * @return The encoded frame.
077    */
078   public static String pub(String topic, String payloadJson, boolean retained, long seq) {
079      return pubRaw(topic, payloadJson, retained, seq);
080   }
081
082   /**
083    * A {@code clear} frame.
084    *
085    * @param topic The retained topic being cleared.
086    * @param seq The session sequence number.
087    * @return The encoded frame.
088    */
089   public static String clear(String topic, long seq) {
090      return head("clear").append(",\"topic\":").append(quote(topic)).append(",\"seq\":").append(seq).append('}').toString();
091   }
092
093   /** @param seq The session sequence number. @return The encoded {@code resync-begin} frame. */
094   public static String resyncBegin(long seq) {
095      return head("resync-begin").append(",\"seq\":").append(seq).append('}').toString();
096   }
097
098   /** @param seq The session sequence number. @return The encoded {@code resync-end} frame. */
099   public static String resyncEnd(long seq) {
100      return head("resync-end").append(",\"seq\":").append(seq).append('}').toString();
101   }
102
103   /** @return The encoded {@code ping} frame. */
104   public static String ping() {
105      return head("ping").append('}').toString();
106   }
107
108   /**
109    * An {@code error} frame.
110    *
111    * @param code The {@code bus:*} code.
112    * @param message Fixed server text.
113    * @param topic The topic concerned.  Can be <jk>null</jk>.
114    * @return The encoded frame.
115    */
116   public static String error(String code, String message, String topic) {
117      var sb = head("error").append(",\"code\":").append(quote(code)).append(",\"message\":").append(quote(message));
118      if (topic != null)
119         sb.append(",\"topic\":").append(quote(topic));
120      return sb.append('}').toString();
121   }
122
123   /**
124    * Encodes a frame given as a map, in canonical field order, omitting absent optional fields.
125    *
126    * @param frame The frame ({@code type} required; {@code v} is always written as {@code 1}).
127    * @return The encoded frame.
128    * @throws IllegalArgumentException On an unknown {@code type}, or when {@code topic}, {@code code} or {@code message}
129    *    is missing or not a string, or {@code retained} is not a boolean.
130    */
131   public static String encode(Map<String,?> frame) {
132      var type = String.valueOf(frame.get("type"));
133      return switch (type) {
134         case "pub" -> pubRaw(requireString(frame, "topic"), Json.of(frame.get("payload")), optionalBoolean(frame, "retained"),
135            seqOf(frame.get("seq")));
136         case "clear" -> {
137            var sb = head("clear").append(",\"topic\":").append(quote(requireString(frame, "topic")));
138            var seq = seqOf(frame.get("seq"));
139            if (seq != null)
140               sb.append(",\"seq\":").append(seq);
141            yield sb.append('}').toString();
142         }
143         case "resync-begin", "resync-end" -> {
144            var sb = head(type);
145            var seq = seqOf(frame.get("seq"));
146            if (seq != null)
147               sb.append(",\"seq\":").append(seq);
148            yield sb.append('}').toString();
149         }
150         case "ping" -> ping();
151         case "error" -> error(requireString(frame, "code"), requireString(frame, "message"), optionalString(frame, "topic"));
152         default -> throw new IllegalArgumentException("BusFrames.encode: unknown frame type '" + type + "'");
153      };
154   }
155
156   /**
157    * Decodes and validates one frame.  The result holds only the known fields of its type, in canonical order.
158    *
159    * @param text The frame text (strict RFC 8259 JSON).
160    * @return The frame.
161    * @throws BusRefusal {@code bus:bad-frame} when the text is not a valid version-1 frame.
162    */
163   public static JsonMap decode(String text) throws BusRefusal {
164      JsonMap m;
165      try {
166         m = text == null || ! isSingleObject(text) ? null : JsonMap.ofString(text, JsonParser.DEFAULT);
167      } catch (RuntimeException e) {
168         throw BusRefusal.badFrame("frame is not a JSON object");
169      }
170      if (m == null)
171         throw BusRefusal.badFrame("frame is not a JSON object");
172      if (! (m.get("v") instanceof Number v) || v.doubleValue() != VERSION)
173         throw BusRefusal.badFrame("unsupported frame version");
174      if (! (m.get("type") instanceof String type) || ! TYPES.contains(type))
175         throw BusRefusal.badFrame("unknown frame type");
176      var out = new JsonMap();
177      out.put("v", VERSION);
178      out.put("type", type);
179      switch (type) {
180         case "pub" -> {
181            requireTopic(m, out);
182            if (! m.containsKey("payload"))
183               throw BusRefusal.badFrame("pub frame has no payload");
184            out.put("payload", m.get("payload"));
185            if (m.containsKey("retained")) {
186               if (! (m.get("retained") instanceof Boolean))
187                  throw BusRefusal.badFrame("retained must be a boolean");
188               out.put("retained", m.get("retained"));
189            }
190            optionalSeq(m, out);
191         }
192         case "clear" -> {
193            requireTopic(m, out);
194            optionalSeq(m, out);
195         }
196         case "resync-begin", "resync-end" -> optionalSeq(m, out);
197         case "error" -> {
198            if (! (m.get("code") instanceof String code))
199               throw BusRefusal.badFrame("error frame has no code");
200            if (! (m.get("message") instanceof String message))
201               throw BusRefusal.badFrame("error frame has no message");
202            out.put("code", code);
203            out.put("message", message);
204            if (m.get("topic") instanceof String topic)
205               out.put("topic", topic);
206         }
207         default -> { /* ping: no fields */ }
208      }
209      return out;
210   }
211
212   /**
213    * Extracts the {@code seq} of a frame produced by this class (it is always the last field when present).
214    *
215    * @param encoded An encoded frame.
216    * @return The sequence number, or empty for {@code ping} and {@code error}.
217    */
218   public static OptionalLong seq(String encoded) {
219      var m = TRAILING_SEQ.matcher(encoded);
220      return m.find() ? OptionalLong.of(Long.parseLong(m.group(1))) : OptionalLong.empty();
221   }
222
223   /**
224    * The UTF-8 length of a string, without allocating its bytes.
225    *
226    * @param s The string.
227    * @return The number of UTF-8 bytes.
228    */
229   public static int utf8Length(String s) {
230      var n = 0;
231      var i = 0;
232      while (i < s.length()) {
233         var c = s.charAt(i);
234         if (c < 0x80)
235            n++;
236         else if (c < 0x800)
237            n += 2;
238         else if (Character.isHighSurrogate(c) && i + 1 < s.length() && Character.isLowSurrogate(s.charAt(i + 1))) {
239            n += 4;
240            i++;
241         } else
242            n += 3;
243         i++;
244      }
245      return n;
246   }
247
248   /**
249    * Quotes a string exactly as JavaScript's {@code JSON.stringify} does (lowercase {@code \\u} hex, lone surrogates
250    * escaped, solidus not escaped).
251    *
252    * @param s The string.
253    * @return The JSON string literal.
254    */
255   static String quote(String s) {
256      var sb = new StringBuilder(s.length() + 2).append('"');
257      var i = 0;
258      while (i < s.length()) {
259         var c = s.charAt(i);
260         switch (c) {
261            case '"' -> sb.append("\\\"");
262            case '\\' -> sb.append("\\\\");
263            case '\n' -> sb.append("\\n");
264            case '\r' -> sb.append("\\r");
265            case '\t' -> sb.append("\\t");
266            case '\b' -> sb.append("\\b");
267            case '\f' -> sb.append("\\f");
268            default -> {
269               if (c < 0x20)
270                  sb.append(f("\\u%04x", (int) c));
271               else if (Character.isHighSurrogate(c) && i + 1 < s.length() && Character.isLowSurrogate(s.charAt(i + 1))) {
272                  sb.append(c).append(s.charAt(i + 1));
273                  i++;
274               } else if (Character.isSurrogate(c))
275                  sb.append(f("\\u%04x", (int) c));
276               else
277                  sb.append(c);
278            }
279         }
280         i++;
281      }
282      return sb.append('"').toString();
283   }
284
285   /**
286    * Whether the text is one JSON object optionally surrounded by whitespace.  {@link JsonParser} stops after the first
287    * value and ignores whatever follows, but a frame is the whole text.  String contents are skipped so a brace inside
288    * a string does not end the object early; well-formedness itself is left to the parser.
289    */
290   private static boolean isSingleObject(String text) {
291      var n = text.length();
292      var i = 0;
293      while (i < n && isWhitespace(text.charAt(i)))
294         i++;
295      if (i == n || text.charAt(i) != '{')
296         return false;
297      var depth = 0;
298      for (; i < n; i++) {
299         var c = text.charAt(i);
300         if (c == '"') {
301            for (i++; i < n && text.charAt(i) != '"'; i++)
302               if (text.charAt(i) == '\\')
303                  i++;
304         } else if (c == '{' || c == '[') {
305            depth++;
306         } else if ((c == '}' || c == ']') && --depth == 0) {
307            for (i++; i < n; i++)
308               if (! isWhitespace(text.charAt(i)))
309                  return false;
310            return true;
311         }
312      }
313      return false;
314   }
315
316   private static boolean isWhitespace(char c) {
317      return c == ' ' || c == '\t' || c == '\n' || c == '\r';
318   }
319
320   private static StringBuilder head(String type) {
321      return new StringBuilder(64).append("{\"v\":").append(VERSION).append(",\"type\":").append(quote(type));
322   }
323
324   private static String pubRaw(String topic, String payloadJson, Boolean retained, Long seq) {
325      var sb = head("pub").append(",\"topic\":").append(quote(topic)).append(",\"payload\":").append(payloadJson);
326      if (retained != null)
327         sb.append(",\"retained\":").append(retained);
328      if (seq != null)
329         sb.append(",\"seq\":").append(seq);
330      return sb.append('}').toString();
331   }
332
333   private static void requireTopic(JsonMap in, JsonMap out) throws BusRefusal {
334      if (! (in.get("topic") instanceof String topic) || topic.isEmpty())
335         throw BusRefusal.badFrame("frame has no topic");
336      out.put("topic", topic);
337   }
338
339   private static void optionalSeq(JsonMap in, JsonMap out) throws BusRefusal {
340      if (! in.containsKey("seq"))
341         return;
342      var seq = seqOf(in.get("seq"));
343      if (seq == null)
344         throw BusRefusal.badFrame("seq must be a non-negative integer");
345      out.put("seq", seq);
346   }
347
348   private static Long seqOf(Object o) {
349      if (! (o instanceof Number n))
350         return null;
351      var d = n.doubleValue();
352      if (d < 0 || d > MAX_SAFE_SEQ || d != Math.floor(d))
353         return null;
354      return n.longValue();
355   }
356
357   private static String requireString(Map<String,?> frame, String key) {
358      if (! (frame.get(key) instanceof String s))
359         throw new IllegalArgumentException("BusFrames.encode: '" + key + "' must be a string");
360      return s;
361   }
362
363   private static String optionalString(Map<String,?> frame, String key) {
364      return frame.get(key) == null ? null : requireString(frame, key);
365   }
366
367   private static Boolean optionalBoolean(Map<String,?> frame, String key) {
368      var o = frame.get(key);
369      if (o != null && ! (o instanceof Boolean))
370         throw new IllegalArgumentException("BusFrames.encode: '" + key + "' must be a boolean");
371      return (Boolean) o;
372   }
373}