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}