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 &mdash; an unknown key, tab or row, a null lookup result or a malformed id &mdash; 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}