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.maven.api.Program;
022import aQute.maven.api.Revision;
023import java.io.InputStream;
024import java.net.MalformedURLException;
025import java.net.URI;
026import java.net.URL;
027import java.util.Collections;
028import java.util.HashSet;
029import java.util.Iterator;
030import java.util.Map;
031import java.util.Set;
032import java.util.concurrent.ConcurrentHashMap;
033import javax.xml.stream.XMLEventReader;
034import javax.xml.stream.XMLInputFactory;
035import javax.xml.stream.XMLStreamException;
036import javax.xml.stream.events.StartElement;
037import javax.xml.stream.events.XMLEvent;
038import org.slf4j.Logger;
039import org.slf4j.LoggerFactory;
040
041/**
042 * Parses the XML document returned by a Nexus server in response to a
043 * <code>lucene/search</code> request.
044 */
045public class NexusSearchNGResponseParser {
046    private static final Logger logger = LoggerFactory.getLogger(
047        NexusSearchNGResponseParser.class);
048    private Set<Revision> artifacts
049        = Collections.newSetFromMap(new ConcurrentHashMap<>());
050    private Map<String, RepoInfo> repoInfos = new ConcurrentHashMap<>();
051
052    /**
053     * Returns the reported snapshot repositories.
054     * 
055     * @return the result
056     * @throws MalformedURLException if the URL is malformed
057     */
058    public Set<URL> snapshotRepositories() throws MalformedURLException {
059        Set<URL> result = new HashSet<>();
060        Iterator<RepoInfo> itr = repoInfos.values().stream().filter(
061            ri -> ri.referenced && ri.repoPolicy == RepoPolicy.Snapshot)
062            .iterator();
063        while (itr.hasNext()) {
064            result.add(itr.next().contentResourceUri.toURL());
065        }
066        return result;
067    }
068
069    /**
070     * Returns the reported release repositories.
071     * 
072     * @return the result
073     * @throws MalformedURLException if the URL is malformed
074     */
075    public Set<URL> releaseRepositories() throws MalformedURLException {
076        Set<URL> result = new HashSet<>();
077        Iterator<RepoInfo> itr = repoInfos.values().stream().filter(
078            ri -> ri.referenced && ri.repoPolicy == RepoPolicy.Release)
079            .iterator();
080        while (itr.hasNext()) {
081            result.add(itr.next().contentResourceUri.toURL());
082        }
083        return result;
084    }
085
086    /**
087     * Returns the reported artifacts.
088     * 
089     * @return the result
090     */
091    public Set<Revision> artifacts() {
092        return Collections.unmodifiableSet(artifacts);
093    }
094
095    /**
096     * Parse the result return from the Nexus server. The outcome will be reflected in
097     * the attributes.
098     *
099     * @param in the stream with result data
100     * @return the parse result
101     * @throws Exception if a problem occurs
102     */
103    public ParseResult parse(InputStream in) throws Exception {
104        XMLEventReader eventReader = XMLInputFactory.newInstance()
105            .createXMLEventReader(in);
106        ParseResult result = new ParseResult();
107        while (eventReader.hasNext()) {
108            XMLEvent event = eventReader.nextEvent();
109            if (event.isStartElement()) {
110                StartElement startElement = event.asStartElement();
111                switch (startElement.getName().getLocalPart()) {
112                case "totalCount":
113                    result.totalCount = Integer
114                        .parseInt(parseCharacters(eventReader));
115                    break;
116                case "from":
117                    result.from = Integer
118                        .parseInt(parseCharacters(eventReader));
119                    break;
120                case "count":
121                    result.count = Integer
122                        .parseInt(parseCharacters(eventReader));
123                    break;
124                case "tooManyResults":
125                    result.tooManyResults = Boolean
126                        .parseBoolean(parseCharacters(eventReader));
127                    break;
128                // Repositories (item by item)
129                case "repositories-item":
130                    parseRepositoryData(eventReader);
131                    break;
132                // Repository definitions
133                case "org.sonatype.nexus.rest.model.NexusNGRepositoryDetail":
134                    parseRepositoryDetail(eventReader);
135                    break;
136                // Artifacts
137                case "artifact":
138                    parseArtifact(eventReader);
139                    result.artifactsInResult += 1;
140                    break;
141                }
142            }
143        }
144        return result;
145    }
146
147    /**
148     * Parse a repository detail section. The results are added to attributes
149     * {@link #snapshotRepositories} and {@link #releaseRepositories} 
150     * 
151     * @param eventReader
152     * @throws Exception
153     */
154    private void parseRepositoryData(XMLEventReader eventReader)
155            throws Exception {
156        RepoInfo repoInfo = new RepoInfo();
157        boolean skip = true;
158        while (eventReader.hasNext()) {
159            XMLEvent event = eventReader.nextEvent();
160            if (event.isEndElement() && event.asEndElement().getName()
161                .getLocalPart().equals("repositories-item")) {
162                if (!skip && repoInfo.repoPolicy != RepoPolicy.Unknown) {
163                    repoInfos.put(repoInfo.id, repoInfo);
164                }
165                break;
166            }
167            if (event.isStartElement()) {
168                switch (event.asStartElement().getName().getLocalPart()) {
169                case "id":
170                    repoInfo.id = parseCharacters(eventReader);
171                    break;
172                case "contentResourceURI":
173                    repoInfo.contentResourceUri
174                        = new URI(parseCharacters(eventReader));
175                    break;
176                case "format":
177                    if (parseCharacters(eventReader).equals("maven2")) {
178                        skip = false;
179                    }
180                    break;
181                case "repoPolicy":
182                    switch (parseCharacters(eventReader)) {
183                    case "SNAPSHOT":
184                        repoInfo.repoPolicy = RepoPolicy.Snapshot;
185                        break;
186                    case "RELEASE":
187                        repoInfo.repoPolicy = RepoPolicy.Release;
188                        break;
189                    }
190                    break;
191                }
192            }
193        }
194    }
195
196    /**
197     * Parse a repository detail section. The results are added to attributes
198     * {@link #snapshotRepositories} and {@link #releaseRepositories} 
199     * 
200     * @param eventReader
201     * @throws Exception
202     */
203    private void parseRepositoryDetail(XMLEventReader eventReader)
204            throws Exception {
205        boolean skip = false;
206        while (eventReader.hasNext()) {
207            XMLEvent event = eventReader.nextEvent();
208            if (event.isEndElement() && event.asEndElement().getName()
209                .getLocalPart().equals(
210                    "org.sonatype.nexus.rest.model.NexusNGRepositoryDetail")) {
211                break;
212            }
213        }
214        if (skip) {
215            return;
216        }
217    }
218
219    /**
220     * Parse an artifact description and return the result as a 
221     * set of {@link Revision}s.
222     * 
223     * @param eventReader the input
224     * @return the result
225     * @throws XMLStreamException 
226     */
227    private void parseArtifact(XMLEventReader eventReader)
228            throws XMLStreamException {
229        String groupId = null;
230        String artifactId = null;
231        String version = null;
232        boolean foundClassifierInArtifactLink = false;
233        boolean foundBinJar = false;
234
235        while (eventReader.hasNext()) {
236            XMLEvent event = eventReader.nextEvent();
237            if (event.isEndElement()) {
238                switch (event.asEndElement().getName().getLocalPart()) {
239                case "artifact":
240                    if (foundBinJar) {
241                        artifacts.add(Program.valueOf(groupId, artifactId)
242                            .version(version));
243                    }
244                    return;
245                case "artifactLink":
246                    if (!foundClassifierInArtifactLink) {
247                        foundBinJar = true;
248                    }
249                    break;
250                }
251            }
252            if (event.isStartElement()) {
253                switch (event.asStartElement().getName().getLocalPart()) {
254                case "repositoryId":
255                    String repositoryId = parseCharacters(eventReader);
256                    RepoInfo info = repoInfos.get(repositoryId);
257                    if (info == null) {
258                        logger.warn("Inconsistent search result: reference to "
259                            + "non-existant repository with id " + repositoryId
260                            + ".");
261                        break;
262                    }
263                    info.referenced = true;
264                    break;
265                case "groupId":
266                    groupId = parseCharacters(eventReader);
267                    break;
268                case "artifactId":
269                    artifactId = parseCharacters(eventReader);
270                    break;
271                case "version":
272                    version = parseCharacters(eventReader);
273                    break;
274                case "artifactLink":
275                    foundClassifierInArtifactLink = false;
276                    break;
277                case "classifier":
278                    foundClassifierInArtifactLink = true;
279                    break;
280                }
281            }
282        }
283    }
284
285    private String parseCharacters(XMLEventReader eventReader)
286            throws XMLStreamException {
287        StringBuilder sb = new StringBuilder();
288        while (eventReader.peek().isCharacters()) {
289            XMLEvent event = eventReader.nextEvent();
290            sb.append(event.asCharacters().getData());
291        }
292        return sb.toString();
293    }
294
295    private enum RepoPolicy {
296        Unknown, Snapshot, Release
297    }
298
299    private class RepoInfo {
300        public String id;
301        public RepoPolicy repoPolicy = RepoPolicy.Unknown;
302        public URI contentResourceUri;
303        boolean referenced = false;
304    }
305
306    public class ParseResult {
307        public int totalCount;
308        public int from;
309        public int count;
310        public boolean tooManyResults;
311        public int artifactsInResult;
312    }
313}