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.views; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022import java.util.regex.*; 023 024import org.apache.juneau.marshall.*; 025import org.apache.juneau.http.*; 026import org.apache.juneau.marshall.collections.*; 027import org.apache.juneau.rest.server.*; 028 029/** 030 * Mixin that serves the bean-backed row details of a resource. 031 * 032 * <p> 033 * An implementing resource lists its {@link RowDetail#fromBean(Class, java.util.function.Function) bean-backed 034 * details} in {@link #rowDetails()}. Each detail is served under its key at three endpoints: the detail 035 * ({@code row-detail/{key}/{id}}), one tab ({@code row-detail/{key}/{id}/tab/{tabId}}) and the tab counts 036 * ({@code row-detail/{key}/{id}/counts}). 037 * 038 * <p> 039 * Only readable, non-ignored bean properties that the owning table shows (or the detail names explicitly) are ever 040 * served. Every failure — an unknown key, tab or row, a null lookup result or a malformed id — answers 041 * the same empty {@code 404} with {@code Cache-Control: no-store}, so nothing about what exists can be learned 042 * from the response. 043 * 044 * @since 10.0.0 045 */ 046public interface RowDetailMixin { 047 048 /** Grammar of a servable row id. */ 049 Pattern ROW_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); 050 051 /** Grammar of a servable detail key or tab id. */ 052 Pattern SEGMENT = Pattern.compile("[A-Za-z][A-Za-z0-9-]{0,63}"); 053 054 /** 055 * The bean-backed details this resource serves. 056 * 057 * @return The details. Each must have a distinct key. 058 */ 059 Collection<RowDetail> rowDetails(); 060 061 /** 062 * Whether {@code id} is a servable row id. 063 * 064 * @param id The candidate id. 065 * @return <jk>true</jk> if the id matches the row-id grammar and is not {@code .} or {@code ..}. 066 */ 067 default boolean validRowId(String id) { 068 return id != null && ROW_ID.matcher(id).matches() && ! ".".equals(id) && ! "..".equals(id); 069 } 070 071 /** 072 * Returns the details indexed by key. 073 * 074 * @return The index. 075 * @throws IllegalStateException If two details share a key. 076 */ 077 default Map<String,RowDetail> rowDetailIndex() { 078 return indexOf(rowDetails()); 079 } 080 081 private static Map<String,RowDetail> indexOf(Collection<RowDetail> details) { 082 var m = new LinkedHashMap<String,RowDetail>(); 083 for (var d : details) { 084 if (d.key() == null || d.lookup() == null) 085 continue; 086 if (m.put(d.key(), d) != null) 087 throw isex("Two row details share the key '%s'.", d.key()); 088 } 089 return m; 090 } 091 092 /** 093 * Serves one row's detail. 094 * 095 * @param key The detail key. 096 * @param id The row id. 097 * @param res The response. 098 * @return The detail, or an empty map with a {@code 404}. 099 */ 100 @RestGet(path="row-detail/{key}/{id}", summary="Row detail", swagger=@OpSwagger(ignore=true)) 101 default JsonMap rowDetail(@Path("key") String key, @Path("id") String id, RestResponse res) { 102 var d = detail(key); 103 var bean = bean(d, id); 104 if (bean == null) 105 return notFound(res); 106 var names = new LinkedHashSet<String>(titleFields(d)); 107 names.addAll(firstFieldsTab(d)); 108 var out = RowDetail.response(project(d, bean, names)); 109 var meta = new JsonMap(); 110 meta.put("labels", labels(d, names)); 111 out.put("meta", meta); 112 out.put("counts", counts(d, id)); 113 return out; 114 } 115 116 /** 117 * Serves one tab of a row's detail. 118 * 119 * @param key The detail key. 120 * @param id The row id. 121 * @param tabId The tab id. 122 * @param res The response. 123 * @return The tab's fields, or an empty map with a {@code 404}. 124 */ 125 @RestGet(path="row-detail/{key}/{id}/tab/{tabId}", summary="Row detail tab", swagger=@OpSwagger(ignore=true)) 126 default JsonMap rowDetailTab(@Path("key") String key, @Path("id") String id, @Path("tabId") String tabId, RestResponse res) { 127 var d = detail(key); 128 var bean = bean(d, id); 129 if (bean == null || tabId == null || ! SEGMENT.matcher(tabId).matches()) 130 return notFound(res); 131 for (var t : d.tabList()) 132 if (t.kind() == DetailTab.Kind.FIELDS && tabId.equals(t.resolvedId())) { 133 var names = new LinkedHashSet<>(t.fieldNames()); 134 var out = RowDetail.response(project(d, bean, names)); 135 var meta = new JsonMap(); 136 meta.put("labels", labels(d, names)); 137 out.put("meta", meta); 138 return out; 139 } 140 return notFound(res); 141 } 142 143 /** 144 * Serves the tab counts of a row. 145 * 146 * @param key The detail key. 147 * @param id The row id. 148 * @param res The response. 149 * @return The counts keyed by tab id, or an empty map with a {@code 404}. 150 */ 151 @RestGet(path="row-detail/{key}/{id}/counts", summary="Row detail counts", swagger=@OpSwagger(ignore=true)) 152 default JsonMap rowDetailCounts(@Path("key") String key, @Path("id") String id, RestResponse res) { 153 var d = detail(key); 154 if (bean(d, id) == null) 155 return notFound(res); 156 var out = new JsonMap(); 157 out.put("contractVersion", RowDetail.CONTRACT_VERSION); 158 out.put("counts", counts(d, id)); 159 return out; 160 } 161 162 private RowDetail detail(String key) { 163 return key == null || ! SEGMENT.matcher(key).matches() ? null : rowDetailIndex().get(key); 164 } 165 166 private Object bean(RowDetail d, String id) { 167 if (d == null || ! validRowId(id)) 168 return null; 169 try { 170 return d.lookup().apply(id); 171 } catch (RuntimeException e) { 172 return null; 173 } 174 } 175 176 private static JsonMap notFound(RestResponse res) { 177 res.setStatus(404); 178 res.setHeader("Cache-Control", "no-store"); 179 return new JsonMap(); 180 } 181 182 private static List<String> titleFields(RowDetail d) { 183 var out = new ArrayList<String>(); 184 if (d.titleTemplate() != null) { 185 var m = Pattern.compile("\\{([A-Za-z_]\\w*)\\}").matcher(d.titleTemplate()); 186 while (m.find()) 187 out.add(m.group(1)); 188 } 189 return out; 190 } 191 192 private static List<String> firstFieldsTab(RowDetail d) { 193 for (var t : d.tabList()) 194 if (t.kind() == DetailTab.Kind.FIELDS) 195 return t.fieldNames().isEmpty() ? d.resolvedFields() : t.fieldNames(); 196 return d.resolvedFields(); 197 } 198 199 private static JsonMap project(RowDetail d, Object bean, Collection<String> names) { 200 var ok = d.servableProperties(); 201 var bm = MarshallingContext.DEFAULT.toBeanMap(bean); 202 var out = new JsonMap(); 203 for (var n : names) 204 if (ok.contains(n)) 205 out.put(n, bm.get(n)); 206 return out; 207 } 208 209 private static JsonMap labels(RowDetail d, Collection<String> names) { 210 var explicit = d.resolvedLabels(); 211 var out = new JsonMap(); 212 for (var n : names) 213 out.put(n, RowDetail.resolveLabel(n, explicit.get(n), null, null)); 214 return out; 215 } 216 217 private static JsonMap counts(RowDetail d, String id) { 218 var out = new JsonMap(); 219 for (var t : d.tabList()) 220 if (t.hasCounter()) 221 try { 222 out.put(t.resolvedId(), t.count(id)); 223 } catch (RuntimeException e) { 224 // a failed counter drops its own count only 225 } 226 return out; 227 } 228}