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 org.apache.juneau.bean.html5.HtmlBuilder.*; 020import static org.apache.juneau.bean.html5.HtmlBuilder.a; 021import static org.apache.juneau.commons.http.MediaType.*; 022import static org.apache.juneau.commons.utils.Shorts.*; 023 024import org.apache.juneau.bean.html5.*; 025import org.apache.juneau.bean.openapi3.*; 026import org.apache.juneau.commons.http.MediaType; 027import org.apache.juneau.commons.inject.*; 028import org.apache.juneau.marshall.*; 029import org.apache.juneau.marshall.cp.*; 030import org.apache.juneau.marshall.swap.spi.*; 031 032/** 033 * Generates a Redoc-style documentation interface from an OpenAPI 3.1 document. 034 * 035 * <p> 036 * Renders a two-column layout — a left sidebar containing the operation table-of-contents and a right 037 * content panel with full operation descriptions, parameters, request/response schemas, and components. 038 * 039 * <p> 040 * This is a sibling of {@link OpenApiUI} that provides a documentation-page-style rendering rather 041 * than the interactive collapsing-block layout. Both are server-rendered HTML — neither bundles 042 * external JavaScript packages. 043 * 044 * <h5 class='section'>See Also:</h5><ul> 045 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JuneauBeanOpenApi3">juneau-bean-openapi-v3</a> 046 * </ul> 047 */ 048@SuppressWarnings({ 049 "java:S1192" // String literals repeated for clarity in UI generation 050}) 051public class RedocUI extends ObjectSwap<OpenApi,Div> { 052 053 // @formatter:off 054 static final FileFinder RESOURCES = FileFinder 055 .create(BasicBeanStore.INSTANCE) 056 .cp(RedocUI.class, null, true) 057 .dir(",") 058 .caching(Boolean.getBoolean("RestContext.disableClasspathResourceCaching.b") ? -1 : 1_000_000) 059 .build(); 060 // @formatter:on 061 062 @Override 063 public MediaType[] forMediaTypes() { 064 return new MediaType[] { HTML }; 065 } 066 067 @Override 068 public Div swap(MarshallingSession session, OpenApi openApi) throws Exception { 069 var css = RESOURCES.getString("files/htdocs/styles/RedocUI.css", null) 070 .orElse(RESOURCES.getString("RedocUI.css", null).orElse(DEFAULT_CSS)); 071 072 var sidebar = sidebar(openApi); 073 var content = content(openApi); 074 075 return div(style(css), div(sidebar, content).class_("redoc-container")).class_("redoc-ui"); 076 } 077 078 private static Div sidebar(OpenApi openApi) { 079 var nav = div().class_("redoc-sidebar"); 080 var info = openApi.getInfo(); 081 if (nn(info) && nn(info.getTitle())) 082 nav.child(div(info.getTitle()).class_("redoc-title")); 083 084 var paths = openApi.getPaths(); 085 if (nn(paths)) { 086 var ul = ul().class_("redoc-toc"); 087 paths.forEach((path, pathItem) -> appendOperations(ul, path, pathItem)); 088 nav.child(ul); 089 } 090 if (nn(openApi.getComponents()) && nn(openApi.getComponents().getSchemas())) { 091 var ul = ul().class_("redoc-toc redoc-models-toc"); 092 ul.child(li(div("Models").class_("redoc-section-title"))); 093 openApi.getComponents().getSchemas().forEach((name, schema) -> ul.child(li(a("#model-" + name, name)))); 094 nav.child(ul); 095 } 096 return nav; 097 } 098 099 private static void appendOperations(Ul ul, String path, PathItem item) { 100 appendOperation(ul, path, "GET", item.getGet()); 101 appendOperation(ul, path, "PUT", item.getPut()); 102 appendOperation(ul, path, "POST", item.getPost()); 103 appendOperation(ul, path, "DELETE", item.getDelete()); 104 appendOperation(ul, path, "PATCH", item.getPatch()); 105 appendOperation(ul, path, "HEAD", item.getHead()); 106 appendOperation(ul, path, "OPTIONS", item.getOptions()); 107 appendOperation(ul, path, "TRACE", item.getTrace()); 108 } 109 110 private static void appendOperation(Ul ul, String path, String verb, Operation op) { 111 if (op == null) 112 return; 113 var anchor = "op-" + lcr(verb) + "-" + path.replaceAll("[^A-Za-z0-9]", "_"); 114 var label = nn(op.getSummary()) ? op.getSummary() : verb + " " + path; 115 ul.child(li(span(verb).class_("redoc-method redoc-method-" + lcr(verb)), a("#" + anchor, label))); 116 } 117 118 private static Div content(OpenApi openApi) { 119 var contentDiv = div().class_("redoc-content"); 120 var info = openApi.getInfo(); 121 if (nn(info)) { 122 if (nn(info.getTitle())) 123 contentDiv.child(h1(info.getTitle())); 124 if (nn(info.getVersion())) 125 contentDiv.child(div("Version: " + info.getVersion()).class_("redoc-version")); 126 if (nn(info.getDescription())) 127 contentDiv.child(div(info.getDescription()).class_("redoc-description")); 128 } 129 130 var paths = openApi.getPaths(); 131 if (nn(paths)) 132 paths.forEach((path, pathItem) -> renderPathOperations(contentDiv, path, pathItem)); 133 134 if (nn(openApi.getComponents()) && nn(openApi.getComponents().getSchemas())) { 135 contentDiv.child(h2("Models")); 136 openApi.getComponents().getSchemas().forEach((name, schema) -> { 137 contentDiv.child(h3(name).id("model-" + name)); 138 if (nn(schema.getDescription())) 139 contentDiv.child(div(schema.getDescription()).class_("redoc-model-desc")); 140 }); 141 } 142 return contentDiv; 143 } 144 145 private static void renderPathOperations(Div contentDiv, String path, PathItem item) { 146 renderOperation(contentDiv, path, "GET", item.getGet()); 147 renderOperation(contentDiv, path, "PUT", item.getPut()); 148 renderOperation(contentDiv, path, "POST", item.getPost()); 149 renderOperation(contentDiv, path, "DELETE", item.getDelete()); 150 renderOperation(contentDiv, path, "PATCH", item.getPatch()); 151 renderOperation(contentDiv, path, "HEAD", item.getHead()); 152 renderOperation(contentDiv, path, "OPTIONS", item.getOptions()); 153 renderOperation(contentDiv, path, "TRACE", item.getTrace()); 154 } 155 156 private static void renderOperation(Div contentDiv, String path, String verb, Operation op) { 157 if (op == null) 158 return; 159 var anchor = "op-" + lcr(verb) + "-" + path.replaceAll("[^A-Za-z0-9]", "_"); 160 contentDiv.child(div( 161 h2(verb + " " + path).id(anchor).class_("redoc-op-title redoc-op-title-" + lcr(verb)), 162 nn(op.getSummary()) ? div(op.getSummary()).class_("redoc-op-summary") : null, 163 nn(op.getDescription()) ? div(op.getDescription()).class_("redoc-op-desc") : null 164 ).class_("redoc-op-block")); 165 } 166 167 private static final String DEFAULT_CSS = 168 ".redoc-ui{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#333;}" + 169 ".redoc-container{display:flex;min-height:100vh;}" + 170 ".redoc-sidebar{width:260px;background:#f4f5f7;padding:20px;border-right:1px solid #e1e4e8;overflow-y:auto;}" + 171 ".redoc-content{flex:1;padding:30px 40px;background:#fff;}" + 172 ".redoc-title{font-size:1.4em;font-weight:bold;margin-bottom:15px;color:#222;}" + 173 ".redoc-toc{list-style:none;padding:0;margin:0 0 20px 0;}" + 174 ".redoc-toc li{margin:6px 0;display:flex;align-items:center;}" + 175 ".redoc-toc a{color:#0366d6;text-decoration:none;font-size:0.9em;margin-left:8px;}" + 176 ".redoc-toc a:hover{text-decoration:underline;}" + 177 ".redoc-section-title{font-weight:bold;margin-top:15px;color:#666;text-transform:uppercase;font-size:0.8em;}" + 178 ".redoc-method{display:inline-block;font-size:0.7em;font-weight:bold;padding:2px 6px;border-radius:3px;color:#fff;text-transform:uppercase;}" + 179 ".redoc-method-get{background:#61affe;}" + 180 ".redoc-method-post{background:#49cc90;}" + 181 ".redoc-method-put{background:#fca130;}" + 182 ".redoc-method-delete{background:#f93e3e;}" + 183 ".redoc-method-patch{background:#50e3c2;}" + 184 ".redoc-method-head{background:#9012fe;}" + 185 ".redoc-method-options{background:#0d5aa7;}" + 186 ".redoc-method-trace{background:#ebebeb;color:#333;}" + 187 ".redoc-op-block{border-bottom:1px solid #e1e4e8;padding:20px 0;}" + 188 ".redoc-op-title{margin:0 0 10px 0;font-size:1.2em;}" + 189 ".redoc-op-summary{font-weight:bold;margin-bottom:8px;}" + 190 ".redoc-op-desc,.redoc-description,.redoc-model-desc{color:#555;line-height:1.5;}" + 191 ".redoc-version{color:#666;font-size:0.9em;margin-bottom:15px;}"; 192}