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>&search=</c> Comma-separated <c>column=expression</c> clauses. 039 * <li><c>&view=</c> Comma-separated columns to return, in order. 040 * <li><c>&sort=</c> Comma-separated sort columns; append <js>":desc"</js> (or <js>"-"</js>) for descending. 041 * <li><c>&position=</c> 0-based index of the first row. 042 * <li><c>&limit=</c> Maximum rows to return. 043 * <li><c>&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 — not a {@link Collection} or object array, or one whose first non-null element 048 * is a plain string, number, date or other scalar — 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} → {@link SearchType#NUMERIC}, a 058 * {@link Boolean} → {@link SearchType#BOOLEAN}, an enum → {@link SearchType#ENUM}, a 059 * date/calendar/temporal → {@link SearchType#TIMESTAMP}, anything else (or a <jk>null</jk>) → 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>&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 — 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<Person> getPeople() { 080 * <jk>return</jk> <jf>people</jf>; 081 * } 082 * } 083 * </p> 084 * <p> 085 * <c>GET /people?search=age=$gte(18)&sort=name,age:desc&view=name,age&position=20&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 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)&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}