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