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}