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