001/** 002 * Copyright (C) 2006-2020 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.server.front; 017 018import static java.util.Collections.list; 019import static java.util.Optional.ofNullable; 020import static java.util.stream.Collectors.groupingBy; 021import static java.util.stream.Collectors.joining; 022import static java.util.stream.Collectors.toList; 023import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 024import static javax.ws.rs.core.Response.Status.NOT_FOUND; 025 026import java.io.BufferedReader; 027import java.io.IOException; 028import java.io.InputStreamReader; 029import java.io.StringReader; 030import java.net.MalformedURLException; 031import java.net.URL; 032import java.nio.charset.StandardCharsets; 033import java.nio.file.Files; 034import java.nio.file.Path; 035import java.util.ArrayList; 036import java.util.Enumeration; 037import java.util.HashMap; 038import java.util.List; 039import java.util.Locale; 040import java.util.Map; 041import java.util.Objects; 042import java.util.Optional; 043import java.util.TreeMap; 044import java.util.concurrent.ConcurrentHashMap; 045import java.util.concurrent.ConcurrentMap; 046import java.util.stream.IntStream; 047import java.util.stream.Stream; 048 049import javax.annotation.PostConstruct; 050import javax.enterprise.context.ApplicationScoped; 051import javax.enterprise.inject.Instance; 052import javax.inject.Inject; 053import javax.ws.rs.WebApplicationException; 054import javax.ws.rs.core.Response; 055 056import org.talend.sdk.component.container.Container; 057import org.talend.sdk.component.path.PathFactory; 058import org.talend.sdk.component.runtime.manager.ComponentManager; 059import org.talend.sdk.component.runtime.manager.ContainerComponentRegistry; 060import org.talend.sdk.component.server.api.DocumentationResource; 061import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; 062import org.talend.sdk.component.server.dao.ComponentDao; 063import org.talend.sdk.component.server.front.model.DocumentationContent; 064import org.talend.sdk.component.server.front.model.ErrorDictionary; 065import org.talend.sdk.component.server.front.model.error.ErrorPayload; 066import org.talend.sdk.component.server.service.ExtensionComponentMetadataManager; 067import org.talend.sdk.component.server.service.LocaleMapper; 068 069import lombok.extern.slf4j.Slf4j; 070 071@Slf4j 072@ApplicationScoped 073public class DocumentationResourceImpl implements DocumentationResource { 074 075 private static final DocumentationContent NO_DOC = new DocumentationContent("asciidoc", ""); 076 077 @Inject 078 private LocaleMapper localeMapper; 079 080 @Inject 081 private ComponentDao componentDao; 082 083 @Inject 084 private ComponentManager manager; 085 086 @Inject 087 private Instance<Object> instance; 088 089 @Inject 090 private ComponentServerConfiguration configuration; 091 092 @Inject 093 private ExtensionComponentMetadataManager virtualComponents; 094 095 private Path i18nBase; 096 097 @PostConstruct 098 private void init() { 099 i18nBase = PathFactory 100 .get(configuration 101 .getDocumentationI18nTranslations() 102 .replace("${home}", System.getProperty("meecrowave.home", ""))); 103 } 104 105 @Override 106 public DocumentationContent getDocumentation(final String id, final String language, 107 final DocumentationSegment segment) { 108 if (virtualComponents.isExtensionEntity(id)) { 109 return NO_DOC; 110 } 111 112 final Locale locale = localeMapper.mapLocale(language); 113 final Container container = ofNullable(componentDao.findById(id)) 114 .map(meta -> manager 115 .findPlugin(meta.getParent().getPlugin()) 116 .orElseThrow(() -> new WebApplicationException(Response 117 .status(NOT_FOUND) 118 .entity(new ErrorPayload(ErrorDictionary.PLUGIN_MISSING, 119 "No plugin '" + meta.getParent().getPlugin() + "'")) 120 .build()))) 121 .orElseThrow(() -> new WebApplicationException(Response 122 .status(NOT_FOUND) 123 .entity(new ErrorPayload(ErrorDictionary.COMPONENT_MISSING, "No component '" + id + "'")) 124 .build())); 125 126 // rendering to html can be slow so do it lazily and once 127 DocumentationCache cache = container.get(DocumentationCache.class); 128 if (cache == null) { 129 synchronized (container) { 130 cache = container.get(DocumentationCache.class); 131 if (cache == null) { 132 cache = new DocumentationCache(); 133 container.set(DocumentationCache.class, cache); 134 } 135 } 136 } 137 138 return cache.documentations.computeIfAbsent(new DocKey(id, language, segment), key -> { 139 final String content = Stream 140 .of("documentation_" + locale.getLanguage() + ".adoc", "documentation_" + language + ".adoc", 141 "documentation.adoc") 142 .flatMap(name -> { 143 try { 144 return ofNullable(container.getLoader().getResources("TALEND-INF/" + name)) 145 .filter(Enumeration::hasMoreElements) 146 .map(e -> list(e).stream()) 147 .orElseGet(() -> ofNullable(findLocalI18n(locale, container)) 148 .map(Stream::of) 149 .orElseGet(Stream::empty)); 150 } catch (final IOException e) { 151 throw new IllegalStateException(e); 152 } 153 }) 154 .filter(Objects::nonNull) 155 .map(url -> { 156 try (final BufferedReader stream = 157 new BufferedReader(new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { 158 return stream.lines().collect(joining("\n")); 159 } catch (final IOException e) { 160 throw new WebApplicationException(Response 161 .status(INTERNAL_SERVER_ERROR) 162 .entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, e.getMessage())) 163 .build()); 164 } 165 }) 166 .map(value -> ofNullable(container.get(ContainerComponentRegistry.class)) 167 .flatMap(r -> r 168 .getComponents() 169 .values() 170 .stream() 171 .flatMap(f -> Stream 172 .concat(f.getPartitionMappers().values().stream(), 173 f.getProcessors().values().stream())) 174 .filter(c -> c.getId().equals(id)) 175 .findFirst() 176 .map(c -> selectById(c.getName(), value, segment))) 177 .orElse(value)) 178 .map(String::trim) 179 .filter(it -> !it.isEmpty()) 180 .findFirst() 181 .orElseThrow(() -> new WebApplicationException(Response 182 .status(NOT_FOUND) 183 .entity(new ErrorPayload(ErrorDictionary.COMPONENT_MISSING, "No component '" + id + "'")) 184 .build())); 185 return new DocumentationContent("asciidoc", content); 186 }); 187 } 188 189 private URL findLocalI18n(final Locale locale, final Container container) { 190 if (!Files.exists(i18nBase)) { 191 return null; 192 } 193 final Path file = i18nBase.resolve("documentation_" + container.getId() + "_" + locale.getLanguage() + ".adoc"); 194 if (Files.exists(file)) { 195 try { 196 return file.toUri().toURL(); 197 } catch (final MalformedURLException e) { 198 throw new IllegalStateException(e); 199 } 200 } 201 return null; 202 } 203 204 private static class DocKey { 205 206 private final String id; 207 208 private final String language; 209 210 private final DocumentationSegment segment; 211 212 private final int hash; 213 214 private DocKey(final String id, final String language, final DocumentationSegment segment) { 215 this.id = id; 216 this.language = language; 217 this.segment = segment; 218 this.hash = Objects.hash(id, language, segment); 219 } 220 221 @Override 222 public boolean equals(final Object o) { 223 if (this == o) { 224 return true; 225 } 226 if (o == null || getClass() != o.getClass()) { 227 return false; 228 } 229 final DocKey docKey = DocKey.class.cast(o); 230 return id.equals(docKey.id) && language.equals(docKey.language) && segment == docKey.segment; 231 } 232 233 @Override 234 public int hashCode() { 235 return hash; 236 } 237 } 238 239 private static class DocumentationCache { 240 241 private final ConcurrentMap<DocKey, DocumentationContent> documentations = new ConcurrentHashMap<>(); 242 } 243 244 // see org.talend.sdk.component.tools.AsciidocDocumentationGenerator.toAsciidoc 245 String selectById(final String name, final String value, final DocumentationSegment segment) { 246 final List<String> lines; 247 try (final BufferedReader reader = new BufferedReader(new StringReader(value))) { 248 lines = reader.lines().collect(toList()); 249 } catch (final IOException e) { 250 throw new IllegalArgumentException(e); 251 } 252 253 return extractUsingComments(name, lines, segment) 254 .orElseGet(() -> noMarkingCommentFallbackExtraction(name, lines, segment, value)); 255 } 256 257 private Optional<String> extractUsingComments(final String name, final List<String> lines, 258 final DocumentationSegment segment) { 259 final Map<String, List<String>> linesPerComponents = new HashMap<>(); 260 List<String> currentCapture = null; 261 for (final String line : lines) { 262 if (line.startsWith("//component_start:")) { 263 currentCapture = new ArrayList<>(); 264 linesPerComponents.put(line.substring("//component_start:".length()), currentCapture); 265 } else if (line.startsWith("//component_end:")) { 266 currentCapture = null; 267 } else if (currentCapture != null && (!line.isEmpty() || !currentCapture.isEmpty())) { 268 currentCapture.add(line); 269 } 270 } 271 final List<String> componentDoc = linesPerComponents.get(name); 272 return ofNullable(componentDoc) 273 .filter(componentLines -> componentLines.stream().filter(it -> !it.isEmpty()).count() > 1) 274 .map(componentLines -> extractSegmentFromComments(segment, componentDoc)) 275 .filter(it -> !it.trim().isEmpty()); 276 } 277 278 private String noMarkingCommentFallbackExtraction(final String name, final List<String> lines, 279 final DocumentationSegment segment, final String fallback) { 280 // first try to find configuration level, default is 2 (==) 281 final TreeMap<Integer, List<Integer>> configurationLevels = lines 282 .stream() 283 .filter(it -> it.endsWith("= Configuration")) 284 .map(it -> it.indexOf(' ')) 285 .collect(groupingBy(it -> it, TreeMap::new, toList())); 286 if (configurationLevels.isEmpty()) { 287 // no standard configuration, just return it all 288 return fallback; 289 } 290 291 final int titleLevels = Math.max(1, configurationLevels.lastKey() - 1); 292 final String prefixTitle = IntStream.range(0, titleLevels).mapToObj(i -> "=").collect(joining()) + " "; 293 final int titleIndex = lines.indexOf(prefixTitle + name); 294 if (titleIndex < 0) { 295 return fallback; 296 } 297 298 List<String> endOfLines = lines.subList(titleIndex, lines.size()); 299 int lineIdx = 0; 300 for (final String line : endOfLines) { 301 if (lineIdx > 0 && line.startsWith(prefixTitle)) { 302 endOfLines = endOfLines.subList(0, lineIdx); 303 break; 304 } 305 lineIdx++; 306 } 307 if (!endOfLines.isEmpty()) { 308 return extractSegmentFromTitles(segment, prefixTitle, endOfLines); 309 } 310 311 // if not found just return all the doc 312 return fallback; 313 } 314 315 private String extractSegmentFromTitles(final DocumentationSegment segment, final String prefixTitle, 316 final List<String> endOfLines) { 317 if (endOfLines.isEmpty()) { 318 return ""; 319 } 320 switch (segment) { 321 case DESCRIPTION: { 322 final String configTitle = getConfigTitle(prefixTitle); 323 final int configIndex = endOfLines.indexOf(configTitle); 324 final boolean skipFirst = endOfLines.get(0).startsWith(prefixTitle); 325 final int lastIndex = configIndex < 0 ? endOfLines.size() : configIndex; 326 final int firstIndex = skipFirst ? 1 : 0; 327 if (lastIndex - firstIndex <= 0) { 328 return ""; 329 } 330 return String.join("\n", endOfLines.subList(firstIndex, lastIndex)); 331 } 332 case CONFIGURATION: { 333 final String configTitle = getConfigTitle(prefixTitle); 334 final int configIndex = endOfLines.indexOf(configTitle); 335 if (configIndex < 0 || configIndex + 1 >= endOfLines.size()) { 336 return ""; 337 } 338 return String.join("\n", endOfLines.subList(configIndex + 1, endOfLines.size())); 339 } 340 default: 341 return String.join("\n", endOfLines); 342 } 343 } 344 345 private String extractSegmentFromComments(final DocumentationSegment segment, final List<String> lines) { 346 if (lines.isEmpty()) { 347 return ""; 348 } 349 switch (segment) { 350 case DESCRIPTION: { 351 final int configStartIndex = lines.indexOf("//configuration_start"); 352 final int start = lines.get(0).startsWith("=") ? 1 : 0; 353 if (configStartIndex > start) { 354 return String.join("\n", lines.subList(start, configStartIndex)).trim(); 355 } 356 if (lines.get(0).startsWith("=")) { 357 return String.join("\n", lines.subList(1, lines.size())); 358 } 359 return String.join("\n", lines); 360 } 361 case CONFIGURATION: { 362 int configStartIndex = lines.indexOf("//configuration_start"); 363 if (configStartIndex > 0) { 364 configStartIndex++; 365 int configEndIndex = lines.indexOf("//configuration_end"); 366 if (configEndIndex > configStartIndex) { 367 while (configStartIndex > 0 && configStartIndex < configEndIndex 368 && (lines.get(configStartIndex).isEmpty() || lines.get(configStartIndex).startsWith("="))) { 369 configStartIndex++; 370 } 371 if (configStartIndex > 0 && configEndIndex > configStartIndex + 2) { 372 return String.join("\n", lines.subList(configStartIndex, configEndIndex)).trim(); 373 } 374 } 375 } 376 return ""; 377 } 378 default: 379 return String.join("\n", lines); 380 } 381 } 382 383 private String getConfigTitle(final String prefixTitle) { 384 return '=' + prefixTitle + "Configuration"; 385 } 386}