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}