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.bean.openapi3.ui;
018
019import static java.util.Collections.*;
020import static org.apache.juneau.bean.html5.HtmlBuilder.*;
021import static org.apache.juneau.bean.html5.HtmlBuilder.a;
022import static org.apache.juneau.bean.html5.HtmlBuilder.tr;
023import static org.apache.juneau.commons.http.MediaType.*;
024import static org.apache.juneau.commons.utils.CollectionUtils.*;
025import static org.apache.juneau.commons.utils.CollectionUtils.list;
026import static org.apache.juneau.commons.utils.ObjectUtils.*;
027import static org.apache.juneau.commons.utils.Shorts.*;
028import static org.apache.juneau.commons.utils.StringUtils.*;
029
030import java.util.*;
031
032import org.apache.juneau.bean.html5.*;
033import org.apache.juneau.bean.openapi3.*;
034import org.apache.juneau.commons.http.MediaType;
035import org.apache.juneau.commons.inject.*;
036import org.apache.juneau.commons.logging.*;
037import org.apache.juneau.commons.utils.*;
038import org.apache.juneau.marshall.*;
039import org.apache.juneau.marshall.collections.*;
040import org.apache.juneau.marshall.cp.*;
041import org.apache.juneau.marshall.html.*;
042import org.apache.juneau.marshall.swap.spi.*;
043
044/**
045 * Generates an OpenAPI-UI interface from an OpenAPI document.
046 *
047 * <h5 class='section'>See Also:</h5><ul>
048 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JuneauBeanOpenApi3">juneau-bean-openapi-v3</a>
049 * </ul>
050 */
051@SuppressWarnings({
052   "java:S1192", // String literals repeated for clarity in UI generation
053   "java:S3776", // Cognitive complexity acceptable for this logic
054   "javabugs:S2259", // Null accesses are guarded by nn() checks; Sonar's flow analysis does not track nn() as a null guard
055   "null" // Null analysis not applicable for optional values
056})
057public class OpenApiUI extends ObjectSwap<OpenApi,Div> {
058
059   private static class Session {
060      final int resolveRefsMaxDepth;
061      final OpenApi openApi;
062
063      Session(OpenApi openApi) {
064         this.openApi = openApi.copy();
065         this.resolveRefsMaxDepth = 1;
066      }
067   }
068
069   // @formatter:off
070   static final FileFinder RESOURCES = FileFinder
071      .create(BasicBeanStore.INSTANCE)
072      .cp(OpenApiUI.class, null, true)
073      .dir(",")
074      .caching(Boolean.getBoolean("RestContext.disableClasspathResourceCaching.b") ? -1 : 1_000_000) // HTT - system property branch only triggers when property is set
075      .build();
076   // @formatter:on
077
078   private static final Set<String> STANDARD_METHODS = set("get", "put", "post", "delete", "options", "head", "patch", "trace");
079
080   private static final RichLogger LOG = RichLogger.getLogger(OpenApiUI.class);
081
082   private static void addOperationIfTagMatches(Div tagBlockContents, Session s, String path, String method, Operation op, Tag t) {
083      if ((t == null && (op.getTags() == null || op.getTags().isEmpty())) || (nn(t) && nn(op.getTags()) && op.getTags().contains(t.getName()))) { // HTT - op.getTags() is always non-null (initialized to list()); the null branch is unreachable
084         tagBlockContents.child(opBlock(s, path, method, op));
085      }
086   }
087
088   private static Div examples(Session s, Parameter pi) {
089      var m = new JsonMap();
090
091      try {
092         var si = pi.getSchema();
093         if (nn(si))
094            m.put("model", si.copy().resolveRefs(s.openApi, new ArrayDeque<>(), s.resolveRefsMaxDepth));
095      } catch (Exception e) { // HTT - requires resolveRefs to throw during schema resolution
096         LOG.warning(e, "Could not resolve schema references while rendering examples."); // HTT
097      }
098
099      if (m.isEmpty())
100         return null;
101
102      return examplesDiv(m);
103   }
104
105   private static Div examples(Session s, Response ri) {
106      var m = new JsonMap();
107      try {
108         var content = ri.getContent();
109         if (nn(content)) {
110            // For OpenAPI 3.0, content is a map of media types to MediaType objects
111            content.forEach((mediaType, mediaTypeObj) -> {
112               if (nn(mediaTypeObj.getSchema())) {
113               try {
114                  var schema = mediaTypeObj.getSchema().copy().resolveRefs(s.openApi, new ArrayDeque<>(), s.resolveRefsMaxDepth);
115                  m.put(mediaType, schema);
116               } catch (Exception e) { // HTT - requires resolveRefs to throw
117                  LOG.warning(e, "Could not resolve schema references while rendering examples."); // HTT
118               }
119            }
120         });
121      }
122   } catch (Exception e) { // HTT - requires content.forEach to throw
123      LOG.warning(e, "Could not resolve schema references while rendering examples."); // HTT
124   }
125
126      if (m.isEmpty())
127         return null;
128
129      return examplesDiv(m);
130   }
131
132   private static Div examplesDiv(JsonMap m) {
133      if (m.isEmpty()) // HTT - callers always check !m.isEmpty() before calling examplesDiv
134         return null;
135
136      Select select = null;
137      if (m.size() > 1) {
138         select = select().onchange("selectExample(this)").class_("example-select");
139      }
140
141      var div = div(select).class_("examples");
142
143      if (nn(select))
144         select.child(option("model", "model"));
145      var modelContent = m.remove("model");
146      div.child(div(nn(modelContent) ? modelContent : "").class_("model active").attr("data-name", "model"));
147
148      var select2 = select;
149      m.forEach((k, v) -> {
150         if (nn(select2))
151            select2.child(option(k, k));
152         div.child(div(v.toString().replace("\\n", "\n")).class_("example").attr("data-name", k));
153      });
154
155      return div;
156   }
157
158   // Creates the informational summary before the ops.
159   private static Table header(Session s) {
160      var table = table().class_("header");
161
162      var info = s.openApi.getInfo();
163      if (nn(info)) {
164
165         if (nn(info.getDescription()))
166            table.child(tr(th("Description:"), td(toBRL(info.getDescription()))));
167
168         if (nn(info.getVersion()))
169            table.child(tr(th("Version:"), td(info.getVersion())));
170
171         var c = info.getContact();
172         if (nn(c)) {
173            var t2 = table();
174
175            if (nn(c.getName()))
176               t2.child(tr(th("Name:"), td(c.getName())));
177            if (nn(c.getUrl()))
178               t2.child(tr(th("URL:"), td(a(c.getUrl(), c.getUrl()))));
179            if (nn(c.getEmail()))
180               t2.child(tr(th("Email:"), td(a("mailto:" + c.getEmail(), c.getEmail()))));
181
182            table.child(tr(th("Contact:"), td(t2)));
183         }
184
185         var l = info.getLicense();
186         if (nn(l)) {
187            var content = nn(l.getName()) ? l.getName() : l.getUrl();
188            var child = nn(l.getUrl()) ? a(l.getUrl(), content) : l.getName();
189            table.child(tr(th("License:"), td(child)));
190         }
191
192         ExternalDocumentation ed = s.openApi.getExternalDocs();
193         if (nn(ed)) {
194            var content = nn(ed.getDescription()) ? ed.getDescription() : ed.getUrl();
195            var child = nn(ed.getUrl()) ? a(ed.getUrl(), content) : ed.getDescription();
196            table.child(tr(th("Docs:"), td(child)));
197         }
198
199         if (nn(info.getTermsOfService())) {
200            var tos = info.getTermsOfService();
201            var child = isUri(tos) ? a(tos, tos) : tos;
202            table.child(tr(th("Terms of Service:"), td(child)));
203         }
204      }
205
206      return table;
207   }
208
209   private static Div headers(Response ri) {
210      if (ri.getHeaders() == null)
211         return null;
212
213      var sectionTable = table(tr(th("Name"), th("Description"), th("Schema"))).class_("section-table");
214
215      var headers = div(div("Headers:").class_("section-name"), sectionTable).class_("headers");
216
217      ri.getHeaders()
218         .forEach((k, v) -> sectionTable.child(tr(td(k).class_("name"), td(toBRL(v.getDescription())).class_("description"), td(v.asMap().keepAll("type", "format", "items", "collectionFormat",
219            "default", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern", "maxItems", "minItems", "uniqueItems", "enum", "multipleOf")))));
220
221      return headers;
222   }
223
224   private static Div modelBlock(String modelName, SchemaInfo model) {
225      return div().class_("op-block op-block-closed model").children(modelBlockSummary(modelName, model), div(model).class_("op-block-contents"));
226   }
227
228   private static Div modelBlockSummary(String modelName, SchemaInfo model) {
229      return div().class_("op-block-summary").onclick("toggleOpBlock(this)").children(span(modelName).class_("method-button"),
230         nn(model.getDescription()) ? span(toBRL(model.getDescription())).class_("summary") : null);
231   }
232
233   // Creates the contents under the "Model" header.
234   private static Div modelsBlockContents(Session s) {
235      var modelBlockContents = div().class_("tag-block-contents");
236      if (nn(s.openApi.getComponents()) && nn(s.openApi.getComponents().getSchemas())) { // HTT - always true when called from swap() which guards with same condition; both false-branches are unreachable
237         s.openApi.getComponents().getSchemas().forEach((k, v) -> modelBlockContents.child(modelBlock(k, v)));
238      }
239      return modelBlockContents;
240   }
241
242   // Creates the "Model" header.
243   private static Div modelsBlockSummary() {
244      return div().class_("tag-block-summary").onclick("toggleTagBlock(this)").children(span("Models").class_("name"));
245   }
246
247   private static Div opBlock(Session s, String path, String opName, Operation op) {
248
249      boolean deprecated = isTrue(op.getDeprecated());
250      var opClass = deprecated ? "deprecated" : lcr(opName);
251      if (! deprecated && ! STANDARD_METHODS.contains(opClass)) // HTT - opBlock only called with standard method names; custom method branch unreachable
252         opClass = "other";
253
254      return div().class_("op-block op-block-closed " + opClass).children(opBlockSummary(path, opName, op), div(tableContainer(s, op)).class_("op-block-contents"));
255   }
256
257   private static Div opBlockSummary(String path, String opName, Operation op) {
258      return div().class_("op-block-summary").onclick("toggleOpBlock(this)").children(span(ucr(opName)).class_("method-button"), span(path).class_("path"),
259         nn(op.getSummary()) ? span(op.getSummary()).class_("summary") : null);
260   }
261
262   private static Div tableContainer(Session s, Operation op) {
263      var tableContainer = div().class_("table-container");
264
265      if (nn(op.getDescription()))
266         tableContainer.child(div(toBRL(op.getDescription())).class_("op-block-description"));
267
268      if (nn(op.getParameters())) {
269         tableContainer.child(div(h4("Parameters").class_("title")).class_("op-block-section-header"));
270
271         var parameters = table(tr(th("Name").class_("parameter-key"), th("Description").class_("parameter-key"))).class_("parameters");
272
273         op.getParameters().forEach(x -> {
274            var piName = x.getName();
275            var required = nn(x.getRequired()) && x.getRequired(); // HTT - JaCoCo counts Boolean unboxing NPE check as a branch; unreachable since nn() already guarantees non-null
276
277            var parameterKey = td(div(piName).class_("name" + (required ? " required" : "")), required ? div("required").class_("requiredlabel") : null,
278               nn(x.getSchema()) ? div(x.getSchema().getType()).class_("type") : null, div('(' + x.getIn() + ')').class_("in")).class_("parameter-key");
279
280            var parameterValue = td(div(toBRL(x.getDescription())).class_("description"), examples(s, x)).class_("parameter-value");
281
282            parameters.child(tr(parameterKey, parameterValue));
283         });
284
285         tableContainer.child(parameters);
286      }
287
288      if (nn(op.getResponses())) {
289         tableContainer.child(div(h4("Responses").class_("title")).class_("op-block-section-header"));
290
291         var responses = table(tr(th("Code").class_("response-key"), th("Description").class_("response-key"))).class_("responses");
292         tableContainer.child(responses);
293
294         op.getResponses().forEach((k, v) -> {
295            var code = td(k).class_("response-key");
296
297            var codeValue = td(div(toBRL(v.getDescription())).class_("description"), examples(s, v), headers(v)).class_("response-value");
298
299            responses.child(tr(code, codeValue));
300         });
301      }
302
303      return tableContainer;
304   }
305
306   // Creates the contents under the "pet  Everything about your Pets  ext-link" header.
307   private static Div tagBlockContents(Session s, Tag t) {
308      var tagBlockContents = div().class_("tag-block-contents");
309
310      if (nn(s.openApi.getPaths())) {
311         s.openApi.getPaths().forEach((path, pathItem) -> {
312            // Check each HTTP method in the path item
313            if (nn(pathItem.getGet()))
314               addOperationIfTagMatches(tagBlockContents, s, path, "get", pathItem.getGet(), t);
315            if (nn(pathItem.getPut()))
316               addOperationIfTagMatches(tagBlockContents, s, path, "put", pathItem.getPut(), t);
317            if (nn(pathItem.getPost()))
318               addOperationIfTagMatches(tagBlockContents, s, path, "post", pathItem.getPost(), t);
319            if (nn(pathItem.getDelete()))
320               addOperationIfTagMatches(tagBlockContents, s, path, "delete", pathItem.getDelete(), t);
321            if (nn(pathItem.getOptions()))
322               addOperationIfTagMatches(tagBlockContents, s, path, "options", pathItem.getOptions(), t);
323            if (nn(pathItem.getHead()))
324               addOperationIfTagMatches(tagBlockContents, s, path, "head", pathItem.getHead(), t);
325            if (nn(pathItem.getPatch()))
326               addOperationIfTagMatches(tagBlockContents, s, path, "patch", pathItem.getPatch(), t);
327            if (nn(pathItem.getTrace()))
328               addOperationIfTagMatches(tagBlockContents, s, path, "trace", pathItem.getTrace(), t);
329         });
330      }
331
332      return tagBlockContents;
333   }
334
335   // Creates the "pet  Everything about your Pets  ext-link" header.
336   private static Div tagBlockSummary(Tag t) {
337      var ed = t.getExternalDocs();
338
339      String content;
340      if (nn(ed) && nn(ed.getDescription())) {
341         content = ed.getDescription();
342      } else if (nn(ed)) {
343         content = ed.getUrl().toString();
344      } else {
345         content = null;
346      }
347      return div().class_("tag-block-summary").onclick("toggleTagBlock(this)").children(span(t.getName()).class_("name"), span(toBRL(t.getDescription())).class_("description"),
348         nn(ed) && nn(ed.getUrl()) ? span(a(ed.getUrl(), content)).class_("extdocs") : null);
349   }
350
351   /**
352    * Replaces newlines with <br> elements.
353    */
354   private static List<Object> toBRL(String s) {
355      if (s == null)
356         return emptyList();
357      if (s.indexOf(',') == -1)
358         return singletonList(s);
359      var l = list();
360      var sa = s.split("\n");
361      for (var i = 0; i < sa.length; i++) {
362         if (i > 0)
363            l.add(br());
364         l.add(sa[i]);
365      }
366      return l;
367   }
368
369   /**
370    * This UI applies to HTML requests only.
371    */
372   @Override
373   public MediaType[] forMediaTypes() {
374      return Shorts.a(HTML);
375   }
376
377   @Override
378   public Div swap(MarshallingSession marshallingSession, OpenApi openApi) throws Exception {
379
380      var s = new Session(openApi);
381
382      var css = RESOURCES.getString("files/htdocs/styles/OpenApiUI.css", null).orElse(null);
383      if (css == null) // HTT - primary CSS file path succeeds in normal test environments
384         css = RESOURCES.getString("OpenApiUI.css", null).orElse(null);
385
386      // Stamp the per-response Content-Security-Policy nonce onto the inline <style>/<script> when present so the UI
387      // satisfies a nonce-based CSP (the same nonce the REST layer emits in the Content-Security-Policy header).
388      var nonce = (marshallingSession instanceof HtmlDocSerializerSession marshallingSession2) ? marshallingSession2.getNonce() : null;
389
390      var style = style(css);
391      var script = script("text/javascript", RESOURCES.getString("OpenApiUI.js", null).orElse(null));
392      if (nonce != null) {
393         style.attr("nonce", nonce);
394         script.attr("nonce", nonce);
395      }
396
397      var outer = div(style, script, header(s)).class_("openapi-ui");
398
399      // Operations without tags are rendered first.
400      outer.child(div().class_("tag-block tag-block-open").children(tagBlockContents(s, null)));
401
402      if (nn(s.openApi.getTags())) {
403         s.openApi.getTags().forEach(x -> {
404            var tagBlock = div().class_("tag-block tag-block-open").children(tagBlockSummary(x), tagBlockContents(s, x));
405            outer.child(tagBlock);
406         });
407      }
408
409      if (nn(s.openApi.getComponents()) && nn(s.openApi.getComponents().getSchemas())) {
410         var modelBlock = div().class_("tag-block").children(modelsBlockSummary(), modelsBlockContents(s));
411         outer.child(modelBlock);
412      }
413
414      return outer;
415   }
416}