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.rest.server.converter;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.*;
020
021import java.time.temporal.*;
022import java.util.*;
023import java.util.concurrent.*;
024
025import org.apache.juneau.commons.beanquery.*;
026import org.apache.juneau.http.*;
027import org.apache.juneau.rest.server.*;
028
029/**
030 * Converter for enabling search/view/sort/paging support on response objects returned by a
031 * <c>@RestOp</c>-annotated method, backed by the BeanQuery engine.
032 *
033 * <p>
034 * When enabled, a response that's a {@link Collection} or object array of beans, or of {@link Map Maps}, can be
035 * filtered with the BeanQuery search language via the following request parameters (see {@link BeanQueryRequest}
036 * for the per-parameter Swagger docs):
037 * <ul class='spaced-list'>
038 *    <li><c>&amp;search=</c> Comma-separated <c>column=expression</c> clauses.
039 *    <li><c>&amp;view=</c> Comma-separated columns to return, in order.
040 *    <li><c>&amp;sort=</c> Comma-separated sort columns; append <js>":desc"</js> (or <js>"-"</js>) for descending.
041 *    <li><c>&amp;position=</c> 0-based index of the first row.
042 *    <li><c>&amp;limit=</c> Maximum rows to return.
043 *    <li><c>&amp;opts=</c> Options as <c>key=value</c> clauses (e.g. <c>counts=true</c>).
044 * </ul>
045 *
046 * <p>
047 * A response that isn't tabular &mdash; not a {@link Collection} or object array, or one whose first non-null element
048 * is a plain string, number, date or other scalar &mdash; is returned <b>unchanged</b>; the query parameters are
049 * ignored, exactly as they were for non-tabular results before 10.0.  An empty or all-null collection is likewise
050 * returned unchanged (there's no element to derive columns from).
051 *
052 * <p>
053 * <b>Bean rows</b> get one {@link InMemoryBeanQueryContext} per element class, built once (with any
054 * {@link QueryableSettings} bean applied) and cached for the lifetime of this converter instance, so
055 * {@link QueryableSettings} is read once per element class, not on every request.  <b>{@link Map} rows</b> get one
056 * column per key of the first non-null row (type inferred from that row's value by the same rule as bean
057 * properties, see {@link QueryProperty#searchTypeOf(Class)}: a {@link Number} &rarr; {@link SearchType#NUMERIC}, a
058 * {@link Boolean} &rarr; {@link SearchType#BOOLEAN}, an enum &rarr; {@link SearchType#ENUM}, a
059 * date/calendar/temporal &rarr; {@link SearchType#TIMESTAMP}, anything else (or a <jk>null</jk>) &rarr;
060 * {@link SearchType#TEXT}); this context is built on every
061 * request, since the key set can differ per response.  Keys that appear only in later rows are not queryable.
062 *
063 * <p>
064 * <c>&amp;opts=counts=true</c> adds {@link #TOTAL_HEADER} / {@link #MATCHED_HEADER} response headers carrying the
065 * session's <c>total</c>/<c>matched</c> row counts &mdash; each header is set only when the corresponding count
066 * is present.  Without it, neither header is set.  The response body itself stays a bare list either way.
067 *
068 * <h5 class='section'>Example:</h5>
069 * <p class='bjava'>
070 *    <ja>@Rest</ja>(converters=Queryable.<jk>class</jk>)
071 *    <jk>public class</jk> PeopleResource <jk>extends</jk> BasicRestServlet {
072 *
073 *       <ja>@Bean</ja>
074 *       <jk>public</jk> QueryableSettings queryableSettings() {
075 *          <jk>return</jk> QueryableSettings.<jsm>create</jsm>().maxLimit(500).build();
076 *       }
077 *
078 *       <ja>@RestGet</ja>(<js>"/people"</js>)
079 *       <jk>public</jk> List&lt;Person&gt; getPeople() {
080 *          <jk>return</jk> <jf>people</jf>;
081 *       }
082 *    }
083 * </p>
084 * <p>
085 *    <c>GET /people?search=age=$gte(18)&amp;sort=name,age:desc&amp;view=name,age&amp;position=20&amp;limit=10</c>
086 * </p>
087 * <p>
088 *    A bad column or option (e.g. <c>?sort=noSuchColumn</c>) throws {@link BeanQuerySyntaxException}, which
089 *    {@code RestContext.convertThrowable} maps to an HTTP&nbsp;400 carrying an <c>X-BeanQuery-Error</c> header and,
090 *    with problem details on, a <js>"code"</js> member.
091 * </p>
092 * <p>
093 *    <c>GET /people?search=age=$gte(18)&amp;opts=counts=true</c> adds count headers to the (still bare-list) body:
094 * </p>
095 * <p class='bcode'>
096 *    X-BeanQuery-Total: 42
097 *    X-BeanQuery-Matched: 7
098 * </p>
099 *
100 * <h5 class='section'>See Also:</h5><ul>
101 *    <li class='jc'>{@link QueryableSettings} - Per-resource limits and defaults for this converter.
102 *    <li class='jc'>{@link BeanQueryRequest} - The request parameters this converter binds.
103 *    <li class='jc'>{@link InMemoryBeanQueryContext} - The engine this converter drives.
104 *    <li class='ja'>{@link RestOp#converters()} - Registering converters with REST resources.
105 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/Converters">Converters</a>
106 * </ul>
107 *
108 * @since 10.0.0
109 */
110@SuppressWarnings({
111   "unchecked" // convertBeanRows() casts the runtime element Class<?> to Class<Object> to build the in-memory query context
112})
113public class Queryable implements RestConverter {
114
115   /**
116    * Response header carrying the total (unfiltered) row count.
117    *
118    * <p>
119    * Set only when {@code &opts=counts=true} is requested and the session reports a {@code total()} count.
120    */
121   public static final String TOTAL_HEADER = "X-BeanQuery-Total";
122
123   /**
124    * Response header carrying the filtered (matched) row count.
125    *
126    * <p>
127    * Set only when {@code &opts=counts=true} is requested and the session reports a {@code matched()} count.
128    */
129   public static final String MATCHED_HEADER = "X-BeanQuery-Matched";
130
131   /**
132    * Swagger parameters for this converter.
133    */
134   public static final String SWAGGER_PARAMS = """
135\t\t{
136\t\t\tin:'query',
137\t\t\tname:'search',
138\t\t\tdescription:'Search expression: comma-separated column=expression clauses, e.g.
139\t\t\t\tname=$contains(smith),age=$gte(18). See the BeanQuery search language docs:
140\t\t\t\thttps://juneau.apache.org/docs/topics/JuneauCommonsBeanQuery',
141\t\t\ttype:'string',
142\t\t\texamples:{example:'?search=name=$contains(smith),age=$gte(18)'}
143\t\t},{
144\t\t\tin:'query',
145\t\t\tname:'view',
146\t\t\tdescription:'Comma-separated columns to return, in order.',
147\t\t\ttype:'string',
148\t\t\texamples:{example:'?view=name,age'}
149\t\t},{
150\t\t\tin:'query',
151\t\t\tname:'sort',
152\t\t\tdescription:'Comma-separated sort columns.
153\t\t\t\tAppend \\':desc\\' (or \\'-\\') for descending.
154\t\t\t\tThe default is ascending order.',
155\t\t\ttype:'string',
156\t\t\texamples:{example:'?sort=name,age:desc'}
157\t\t},{
158\t\t\tin:'query',
159\t\t\tname:'position',
160\t\t\tdescription:'0-based index of the first row.',
161\t\t\ttype:'integer',
162\t\t\tminimum:0,
163\t\t\texamples:{example:'?position=20'}
164\t\t},{
165\t\t\tin:'query',
166\t\t\tname:'limit',
167\t\t\tdescription:'Maximum rows to return.
168\t\t\t\tDefaults and caps are set by the server.',
169\t\t\ttype:'integer',
170\t\t\texamples:{example:'?limit=10'}
171\t\t},{
172\t\t\tin:'query',
173\t\t\tname:'opts',
174\t\t\tdescription:'Options as key=value clauses, e.g. counts=true.',
175\t\t\ttype:'string',
176\t\t\texamples:{example:'?opts=counts=true'}
177\t\t}""";
178
179   private static final List<Class<?>> SIMPLE_ELEMENT_TYPES = List.of(
180      CharSequence.class, Number.class, Boolean.class, Character.class, Enum.class,
181      Date.class, Calendar.class, Temporal.class
182   );
183
184   private final ConcurrentHashMap<Class<?>,Optional<InMemoryBeanQueryContext<Object>>> contextCache = new ConcurrentHashMap<>();
185
186   @Override /* Overridden from RestConverter */
187   public Object convert(RestRequest req, Object o) {
188      if (o == null)
189         return null;
190
191      List<Object> rows;
192      if (o instanceof Collection<?> c)
193         rows = nonNull(c);
194      else if (o instanceof Object[] a)
195         rows = nonNull(Arrays.asList(a));
196      else
197         return o;
198
199      if (rows.isEmpty())
200         return o;
201
202      var first = rows.get(0);
203      Optional<Page<?>> page;
204      if (first instanceof Map<?,?> m)
205         page = convertMapRows(req, rows, m);
206      else if (isSimpleType(first.getClass()))
207         page = Optional.empty();
208      else
209         page = convertBeanRows(req, rows, first.getClass());
210
211      return page.isPresent() ? page.get().rows() : o;
212   }
213
214   /**
215    * Returns the cached bean context for an element class, or <jk>null</jk> if none has been built (package-private, for tests).
216    *
217    * @param elementClass The row class.
218    * @return The cached context, or <jk>null</jk>.
219    */
220   InMemoryBeanQueryContext<Object> cachedContext(Class<?> elementClass) {
221      return contextCache.getOrDefault(elementClass, Optional.empty()).orElse(null);
222   }
223
224   private static List<Object> nonNull(Collection<?> c) {
225      return addAllNn(new ArrayList<Object>(c.size()), c);
226   }
227
228   private Optional<Page<?>> convertBeanRows(RestRequest req, List<Object> rows, Class<?> elementClass) {
229      var ctx = contextCache.computeIfAbsent(elementClass, c -> {
230         if (QueryModel.of(c).getReadableProperties().isEmpty())
231            return Optional.empty();
232         var b = InMemoryBeanQueryContext.create((Class<Object>)c);
233         return Optional.of(settingsOf(req).applyTo(b).build());
234      });
235      if (ctx.isEmpty())
236         return Optional.empty();
237      return Optional.of(run(req, ctx.get(), rows));
238   }
239
240   private Optional<Page<?>> convertMapRows(RestRequest req, List<Object> rows, Map<?,?> first) {
241      var b = InMemoryBeanQueryContext.<Object>create();
242      var columns = 0;
243      for (var e : first.entrySet()) {
244         var name = String.valueOf(e.getKey());
245         if (! name.isBlank()) {
246            b.column(name, e.getValue() == null ? SearchType.TEXT : QueryProperty.searchTypeOf(e.getValue().getClass()));
247            columns++;
248         }
249      }
250      if (columns == 0)
251         return Optional.empty();
252      return Optional.of(run(req, settingsOf(req).applyTo(b).build(), rows));
253   }
254
255   private static Page<?> run(RestRequest req, InMemoryBeanQueryContext<Object> ctx, List<Object> rows) {
256      var query = req.getRequest(BeanQueryRequest.class);
257      try (var s = ctx.getSession(rows)) {
258         var view = query.getView();
259         var page = view == null || view.isBlank() ? s.find(query) : s.findValues(query);
260         setCountHeaders(req, page);
261         return page;
262      }
263   }
264
265   private static void setCountHeaders(RestRequest req, Page<?> page) {
266      page.total().ifPresent(v -> req.getResponse().setHeader(TOTAL_HEADER, String.valueOf(v)));
267      page.matched().ifPresent(v -> req.getResponse().setHeader(MATCHED_HEADER, String.valueOf(v)));
268   }
269
270   private static boolean isSimpleType(Class<?> c) {
271      for (var t : SIMPLE_ELEMENT_TYPES)
272         if (t.isAssignableFrom(c))
273            return true;
274      return false;
275   }
276
277   @SuppressWarnings({
278      "resource" // The bean store is owned by the RestContext; this only borrows a bean and must not close it.
279   })
280   private static QueryableSettings settingsOf(RestRequest req) {
281      return req.getContext().getBeanStore().getBean(QueryableSettings.class).orElse(QueryableSettings.DEFAULT);
282   }
283}