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}