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}