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}