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}