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.data; 018 019import static java.nio.charset.StandardCharsets.UTF_8; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.io.*; 023import java.util.*; 024import java.util.stream.*; 025 026import org.apache.juneau.rest.server.*; 027 028/** 029 * Streams rows to a console data source as CSV or NDJSON. 030 * 031 * <p> 032 * Rows are written in order and flushed every {@value #FLUSH_ROWS} rows or {@value #FLUSH_CHARS} characters, so the 033 * browser can parse while the response is still arriving. With {@link #cap(long)} the stream stops after that many 034 * rows and, when more rows exist, writes the truncation sentinel the browser reports as E-JS-102. Times are written 035 * as ISO-8601 UTC; a {@code LocalDate} is the start of that day in UTC, and a {@code LocalDateTime} is read as UTC. 036 * 037 * <h5 class='section'>Example:</h5> 038 * <p class='bjava'> 039 * <ja>@RestGet</ja>(path=<js>"/series"</js>) 040 * <jk>public void</jk> series(RestResponse <jv>res</jv>) <jk>throws</jk> IOException { 041 * <jk>try</jk> (<jk>var</jk> <jv>rows</jv> = <jv>svc</jv>.rows()) { 042 * SeriesSource.<jsm>csv</jsm>(<jv>res</jv>).columns(<js>"start"</js>, <js>"v"</js>).cap(100_000) 043 * .write(<jv>rows</jv>); 044 * } 045 * } 046 * </p> 047 * 048 * @since 10.0.0 049 */ 050@SuppressWarnings({ 051 "resource" // The output stream belongs to the response and is closed by the container, not by this streamer. 052}) 053public final class SeriesSource { 054 055 /** One row; {@link #get(String)} returns the value for a column, or null. */ 056 @FunctionalInterface 057 public interface Row { 058 059 /** 060 * Returns the value of one column. 061 * 062 * @param column The column name. 063 * @return The value, or <jk>null</jk>. 064 */ 065 Object get(String column); 066 067 /** 068 * A row backed by a map. 069 * 070 * @param m The map. Not copied. 071 * @return A new row. 072 */ 073 static Row of(Map<String,?> m) { 074 return m::get; 075 } 076 } 077 078 /** The output format. */ 079 public enum Format { 080 /** RFC 4180 CSV with a header row and LF line endings. */ 081 CSV, 082 /** One JSON object per line. */ 083 NDJSON 084 } 085 086 /** Rows written between flushes. */ 087 public static final int FLUSH_ROWS = 1000; 088 089 /** Characters written between flushes. */ 090 public static final int FLUSH_CHARS = 64 * 1024; 091 092 private final Format format; 093 private final OutputStream out; 094 private List<String> columns; 095 private long cap = Long.MAX_VALUE; 096 097 SeriesSource(Format format, OutputStream out) { 098 this.format = format; 099 this.out = out; 100 } 101 102 /** 103 * A CSV streamer on the response: sets <c>text/csv;charset=utf-8</c> and <c>X-Content-Type-Options: nosniff</c>. 104 * 105 * @param res The response. 106 * @return A new streamer. 107 * @throws IOException If the output stream cannot be opened. 108 */ 109 public static SeriesSource csv(RestResponse res) throws IOException { 110 return open(res, Format.CSV, "text/csv;charset=utf-8"); 111 } 112 113 /** 114 * An NDJSON streamer on the response: sets <c>application/x-ndjson;charset=utf-8</c> and <c>nosniff</c>. 115 * 116 * @param res The response. 117 * @return A new streamer. 118 * @throws IOException If the output stream cannot be opened. 119 */ 120 public static SeriesSource ndjson(RestResponse res) throws IOException { 121 return open(res, Format.NDJSON, "application/x-ndjson;charset=utf-8"); 122 } 123 124 private static SeriesSource open(RestResponse res, Format format, String contentType) throws IOException { 125 res.setContentType(contentType); 126 res.setHeader("X-Content-Type-Options", "nosniff"); 127 return new SeriesSource(format, res.getOutputStream()); 128 } 129 130 /** 131 * The columns to write, in order. CSV writes them as the header row. 132 * 133 * @param names The column names. At least one. 134 * @return This object. 135 */ 136 public SeriesSource columns(String...names) { 137 if (names.length == 0) 138 throw iaex("SeriesSource.columns: at least one column is required"); 139 columns = List.of(names); 140 return this; 141 } 142 143 /** 144 * The most rows to write. When more rows exist, the truncation sentinel follows them. 145 * 146 * @param value The cap. At least 1. 147 * @return This object. 148 */ 149 public SeriesSource cap(long value) { 150 if (value < 1) 151 throw iaex("SeriesSource.cap: '%s' is not a positive row count", value); 152 cap = value; 153 return this; 154 } 155 156 /** 157 * Writes the rows. Reads at most one row past the cap. The caller closes the stream. 158 * 159 * @param rows The rows. 160 */ 161 @SuppressWarnings({ 162 "java:S2095" // The writer wraps the response stream, which the container closes. 163 }) 164 public void write(Stream<? extends Row> rows) { 165 if (columns == null) 166 throw isex("SeriesSource.write: columns(...) not set"); 167 var w = new BufferedWriter(new OutputStreamWriter(out, UTF_8), FLUSH_CHARS); 168 try { 169 var pending = 0L; 170 var count = 0L; 171 var rowsSinceFlush = 0; 172 if (format == Format.CSV) 173 pending += line(w, csvHeader()); 174 var it = rows.iterator(); 175 while (it.hasNext()) { 176 var r = it.next(); 177 if (count == cap) { 178 line(w, format == Format.CSV ? "#truncated," + cap : "{\"$truncated\":" + cap + "}"); 179 break; 180 } 181 pending += line(w, format == Format.CSV ? csvRow(r) : jsonRow(r)); 182 count++; 183 if (++rowsSinceFlush >= FLUSH_ROWS || pending >= FLUSH_CHARS) { 184 w.flush(); 185 rowsSinceFlush = 0; 186 pending = 0; 187 } 188 } 189 w.flush(); 190 } catch (IOException e) { 191 throw new UncheckedIOException(e); 192 } 193 } 194 195 private static int line(Writer w, String s) throws IOException { 196 w.write(s); 197 w.write('\n'); 198 return s.length() + 1; 199 } 200 201 private String csvHeader() { 202 return columns.stream().map(SeriesSource::csvField).collect(Collectors.joining(",")); 203 } 204 205 private String csvRow(Row r) { 206 var sb = new StringBuilder(); 207 for (var i = 0; i < columns.size(); i++) { 208 if (i > 0) sb.append(','); 209 var v = r.get(columns.get(i)); 210 if (v != null) sb.append(csvField(text(v))); 211 } 212 return sb.toString(); 213 } 214 215 private static String text(Object v) { 216 if (Values.isTime(v)) return Values.iso(Values.toInstant(v)); 217 return String.valueOf(v); 218 } 219 220 // Quotes a field holding a comma, quote, CR or LF; one starting with '#' (so data never reads as the 221 // sentinel); and the empty string (so it stays distinct from null, which is written as nothing). 222 static String csvField(String s) { 223 if (s.isEmpty() || s.charAt(0) == '#' || s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 224 || s.indexOf('\r') >= 0) 225 return '"' + s.replace("\"", "\"\"") + '"'; 226 return s; 227 } 228 229 private String jsonRow(Row r) { 230 var sb = new StringBuilder("{"); 231 for (var i = 0; i < columns.size(); i++) { 232 if (i > 0) sb.append(','); 233 jsonString(sb, columns.get(i)).append(':'); 234 jsonValue(sb, r.get(columns.get(i))); 235 } 236 return sb.append('}').toString(); 237 } 238 239 private static void jsonValue(StringBuilder sb, Object v) { 240 if (v == null) sb.append("null"); 241 else if (Values.isTime(v)) jsonString(sb, Values.iso(Values.toInstant(v))); 242 else if (v instanceof Number n) sb.append(Values.nonFinite(n) ? "null" : n.toString()); 243 else if (v instanceof Boolean b) sb.append(b); 244 else jsonString(sb, v.toString()); 245 } 246 247 private static StringBuilder jsonString(StringBuilder sb, String s) { 248 sb.append('"'); 249 for (var i = 0; i < s.length(); i++) { 250 var c = s.charAt(i); 251 switch (c) { 252 case '"' -> sb.append("\\\""); 253 case '\\' -> sb.append("\\\\"); 254 case '\n' -> sb.append("\\n"); 255 case '\r' -> sb.append("\\r"); 256 case '\t' -> sb.append("\\t"); 257 default -> { 258 if (c < 0x20 || c == 0x2028 || c == 0x2029) sb.append(f("\\u%04x", (int)c)); 259 else sb.append(c); 260 } 261 } 262 } 263 return sb.append('"'); 264 } 265}