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