001/*
002 * Bnd Nexus Search Plugin
003 * Copyright (C) 2017  Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package de.mnl.osgi.bnd.repository.maven.nexussearch;
020
021import aQute.bnd.http.HttpClient;
022import aQute.bnd.osgi.Processor;
023import aQute.bnd.osgi.repository.XMLResourceParser;
024import aQute.lib.strings.Strings;
025import aQute.maven.api.IMavenRepo;
026import aQute.maven.api.Revision;
027import aQute.maven.provider.MavenBackingRepository;
028import aQute.maven.provider.MavenRepository;
029import aQute.service.reporter.Reporter;
030import de.mnl.osgi.bnd.repository.maven.nexussearch.NexusSearchNGResponseParser.ParseResult;
031import java.io.File;
032import java.io.FileInputStream;
033import java.io.FileOutputStream;
034import java.io.InputStream;
035import java.net.URL;
036import java.util.ArrayList;
037import java.util.HashMap;
038import java.util.HashSet;
039import java.util.List;
040import java.util.Map;
041import java.util.Set;
042import java.util.concurrent.Callable;
043import java.util.concurrent.ExecutorCompletionService;
044import java.util.concurrent.Executors;
045import javax.xml.stream.XMLEventReader;
046import javax.xml.stream.XMLInputFactory;
047import javax.xml.stream.XMLOutputFactory;
048import javax.xml.stream.XMLStreamConstants;
049import javax.xml.stream.XMLStreamWriter;
050import javax.xml.stream.events.XMLEvent;
051import org.osgi.resource.Resource;
052import org.osgi.service.repository.Repository;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056/**
057 * Provide an OSGi repository (a collection of {@link Resource}s, see 
058 * {@link Repository}), filled with the results of performing a search 
059 * on a Nexus server.
060 */
061public class NexusSearchOsgiRepository extends LocalMavenBackedOsgiRepository {
062
063    private static final Logger logger = LoggerFactory.getLogger(
064        NexusSearchOsgiRepository.class);
065    private URL server;
066    private String queryString;
067    private int searchBreadth;
068    private int chunkSize;
069    private File localRepo = null;
070    private File mvnReposFile;
071    private Reporter reporter;
072    private HttpClient client;
073    private MavenRepository mavenRepository;
074
075    /**
076     * Create a new instance that uses the provided information/resources to perform
077     * its work.
078     *
079     * @param name the name
080     * @param server the url of the Nexus server
081     * @param localRepo the local Maven repository (cache)
082     * @param obrIndexFile the persistent representation of this repository's content
083     * @param mvnResposFile the mvn respos file
084     * @param queryString the query string
085     * @param searchBreadth the search breadth
086     * @param chunkSize the chunk size
087     * @param reporter a reporter for reporting the progress
088     * @param client an HTTP client for obtaining information from the Nexus server
089     * @throws Exception if a problem occurs
090     */
091    public NexusSearchOsgiRepository(String name, URL server, File localRepo,
092            File obrIndexFile, File mvnResposFile, String queryString,
093            int searchBreadth,
094            int chunkSize, Reporter reporter, HttpClient client)
095            throws Exception {
096        super(name, obrIndexFile);
097        this.server = server;
098        this.queryString = queryString;
099        this.searchBreadth = searchBreadth;
100        this.chunkSize = chunkSize;
101        this.localRepo = localRepo;
102        this.mvnReposFile = mvnResposFile;
103        this.reporter = reporter;
104        this.client = client;
105
106        // load results from previous execution.
107        mavenRepository = restoreRepository();
108        if (mavenRepository == null
109            || !location().exists() || !location().isFile()) {
110            refresh();
111        } else {
112            try (XMLResourceParser parser = new XMLResourceParser(location())) {
113                List<Resource> resources = parser.parse();
114                addAll(resources);
115            }
116        }
117    }
118
119    private MavenRepository restoreRepository() throws Exception {
120        if (!mvnReposFile.exists()) {
121            return null;
122        }
123        List<MavenBackingRepository> releaseBackers = new ArrayList<>();
124        List<MavenBackingRepository> snapshotBackers = new ArrayList<>();
125        List<MavenBackingRepository> backers = null;
126        XMLEventReader xmlIn
127            = XMLInputFactory.newFactory().createXMLEventReader(
128                new FileInputStream(mvnReposFile));
129        while (xmlIn.hasNext()) {
130            XMLEvent event = xmlIn.nextEvent();
131            if (event.getEventType() != XMLStreamConstants.START_ELEMENT) {
132                continue;
133            }
134            switch (event.asStartElement().getName().getLocalPart()) {
135            case "releaseUrls":
136                backers = releaseBackers;
137                break;
138            case "snapshotUrls":
139                backers = snapshotBackers;
140                break;
141            case "url":
142                do {
143                    event = xmlIn.nextEvent();
144                } while (event.getEventType() != XMLStreamConstants.CHARACTERS);
145                backers.addAll(MavenBackingRepository.create(
146                    event.asCharacters().getData(), reporter, localRepo,
147                    client));
148                break;
149            }
150        }
151        xmlIn.close();
152        return new MavenRepository(localRepo, name(),
153            releaseBackers, snapshotBackers, Processor.getExecutor(), reporter);
154    }
155
156    /**
157     * Refresh this repository's content.
158     * 
159     * @return true if refreshed, false if not refreshed possibly due to error
160     * @throws Exception if a problem occurs
161     */
162    public boolean refresh() throws Exception {
163        if (queryString == null) {
164            return false;
165        }
166        NexusSearchNGResponseParser parser = new NexusSearchNGResponseParser();
167        queryRepositories(parser);
168        queryArtifacts(parser);
169        // Repository information is obtained from both querying the
170        // repositories
171        // (provides information about existing repositories) and from executing
172        // the query (provides information about actually used repositories).
173        mavenRepository = createMavenRepository(parser);
174        Set<Revision> revsFound = parser.artifacts();
175        Map<String, List<Revision>> revsByName = new HashMap<>();
176        for (Revision rev : revsFound) {
177            revsByName.computeIfAbsent(rev.group + ":" + rev.artifact,
178                k -> new ArrayList<>()).add(rev);
179        }
180        Set<Revision> filteredRevs = new HashSet<>();
181        for (List<Revision> artifactRevs : revsByName.values()) {
182            artifactRevs.sort(new MavenRevisionComparator().reversed());
183            filteredRevs.addAll(artifactRevs.subList(
184                0, Math.min(searchBreadth, artifactRevs.size())));
185        }
186        return refresh(mavenRepository, filteredRevs);
187    }
188
189    /**
190     * Obtain information about the repositories that exist on the server.
191     * The information is stored in the parser.
192     * 
193     * @param parser the parser
194     * @throws Exception
195     */
196    private void queryRepositories(NexusSearchNGResponseParser parser)
197            throws Exception {
198        int attempts = 0;
199        while (true) {
200            try {
201                logger.debug("Getting repositories");
202                InputStream result = client.build()
203                    .headers("User-Agent", "Bnd")
204                    .get(InputStream.class)
205                    .go(new URL(server, "service/local/repositories"));
206                parser.parse(result);
207                result.close();
208                break;
209            } catch (Exception e) {
210                attempts += 1;
211                if (attempts > 3) {
212                    throw e;
213                }
214                Thread.sleep(1000 * attempts);
215            }
216        }
217    }
218
219    /**
220     * Execute the query. The result is stored in the parser.
221     * 
222     * @param parser the parser
223     * @throws Exception
224     */
225    private void queryArtifacts(NexusSearchNGResponseParser parser)
226            throws Exception {
227        ExecutorCompletionService<QueryResult> exeSvc
228            = new ExecutorCompletionService<>(Executors.newFixedThreadPool(4));
229        int executing = 0;
230        for (String query : Strings.split(queryString)) {
231            exeSvc.submit(new ArtifactQuery(parser, query, 1, chunkSize));
232            executing += 1;
233        }
234        while (executing > 0) {
235            QueryResult result = exeSvc.take().get();
236            executing -= 1;
237            ParseResult parsed = result.parsed;
238            if (parsed.from > 1) {
239                continue;
240            }
241            int from = parsed.from;
242            while (from + chunkSize - 1 < parsed.totalCount) {
243                from += chunkSize;
244                exeSvc.submit(new ArtifactQuery(parser, result.query, from,
245                    chunkSize));
246                executing += 1;
247            }
248        }
249    }
250
251    /**
252     * Execute the query. The result is stored in the parser.
253     */
254    class ArtifactQuery implements Callable<QueryResult> {
255        private NexusSearchNGResponseParser parser;
256        private String query;
257        private int from;
258        private int count;
259
260        public ArtifactQuery(NexusSearchNGResponseParser parser, String query,
261                int from, int count) {
262            super();
263            this.parser = parser;
264            this.query = query;
265            this.from = from;
266            this.count = count;
267        }
268
269        @Override
270        public QueryResult call() throws Exception {
271            int attempts = 0;
272            QueryResult result = new QueryResult();
273            result.query = query;
274            while (true) {
275                try {
276                    logger.debug("Searching {}", query);
277                    InputStream answer = client.build()
278                        .headers("User-Agent", "Bnd")
279                        .get(InputStream.class)
280                        .go(new URL(server, "service/local/lucene/search?"
281                            + query + "&from=" + from + "&count=" + count));
282                    result.parsed = parser.parse(answer);
283                    answer.close();
284                    logger.debug("Got for {} results from {} to {} (of {})",
285                        query, result.parsed.from,
286                        result.parsed.from + result.parsed.count - 1,
287                        result.parsed.totalCount);
288                    if (result.parsed.tooManyResults
289                        && result.parsed.count < count) {
290                        logger.error("Too many results for {}, results were "
291                            + "lost (chunk size too big)", query);
292                    }
293                    break;
294                } catch (Exception e) {
295                    attempts += 1;
296                    if (attempts > 3) {
297                        throw e;
298                    }
299                    Thread.sleep(1000 * attempts);
300                }
301            }
302
303            // List all revisions from query.
304            for (Revision revision : parser.artifacts()) {
305                logger.debug("Found {}", revision);
306            }
307
308            return result;
309        }
310    }
311
312    private class QueryResult {
313        String query;
314        ParseResult parsed;
315    }
316
317    private MavenRepository createMavenRepository(
318            NexusSearchNGResponseParser parser) throws Exception {
319        // Create repository from URLs
320        XMLStreamWriter xmlOut
321            = XMLOutputFactory.newFactory().createXMLStreamWriter(
322                new FileOutputStream(mvnReposFile));
323        xmlOut.writeStartDocument();
324        xmlOut.writeStartElement("repositories");
325        xmlOut.writeStartElement("repository");
326        xmlOut.writeStartElement("releaseUrls");
327        List<MavenBackingRepository> releaseBackers = new ArrayList<>();
328        for (URL repoUrl : parser.releaseRepositories()) {
329            xmlOut.writeStartElement("url");
330            xmlOut.writeCharacters(repoUrl.toString());
331            xmlOut.writeEndElement();
332            releaseBackers.addAll(MavenBackingRepository.create(
333                repoUrl.toString(), reporter, localRepo, client));
334        }
335        xmlOut.writeEndElement();
336        xmlOut.writeStartElement("snapshotUrls");
337        List<MavenBackingRepository> snapshotBackers = new ArrayList<>();
338        for (URL repoUrl : parser.snapshotRepositories()) {
339            xmlOut.writeStartElement("url");
340            xmlOut.writeCharacters(repoUrl.toString());
341            xmlOut.writeEndElement();
342            snapshotBackers.addAll(MavenBackingRepository.create(
343                repoUrl.toString(), reporter, localRepo, client));
344        }
345        xmlOut.writeEndElement();
346        xmlOut.writeEndElement();
347        xmlOut.writeEndElement();
348        xmlOut.writeEndDocument();
349        xmlOut.close();
350        return new MavenRepository(localRepo, name(),
351            releaseBackers, snapshotBackers,
352            Processor.getExecutor(), reporter);
353    }
354
355    /**
356     * Return the Maven repository object used to back this repository.
357     *
358     * @return the maven repository
359     * @throws Exception if a problem occurs
360     */
361    public IMavenRepo mavenRepository() throws Exception {
362        if (mavenRepository == null) {
363            refresh();
364        }
365        return mavenRepository;
366    }
367}