diff --git a/package.json b/package.json index 0de67f033cde395d4c5a6f2d7628654ec8d6f34f..16b124ded35c2ff50ff0bede6a79fcdec64c0628 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "license": "EPL-2.0", "dependencies": { "axios": "^0.19.0", + "moment": "^2.24.0", "moment-timezone": "^0.5.27", "random-words": "^1.1.0", "uuid": "^3.3.3", diff --git a/src/main/java/org/eclipsefoundation/marketplace/config/JsonBConfig.java b/src/main/java/org/eclipsefoundation/marketplace/config/JsonBConfig.java index 8d8f914cd46f750d39fa737f0ebde03de5f1be15..cb868867ddcffb024a17a5d0c30f87457baf041b 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/config/JsonBConfig.java +++ b/src/main/java/org/eclipsefoundation/marketplace/config/JsonBConfig.java @@ -25,9 +25,10 @@ public class JsonBConfig implements ContextResolver<Jsonb> { @Override public Jsonb getContext(Class<?> type) { JsonbConfig config = new JsonbConfig(); - + // following strategy is defined as default by internal API guidelines - config.withPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CASE_WITH_UNDERSCORES); + config.withPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CASE_WITH_UNDERSCORES) + .withDateFormat("uuuu-MM-dd'T'HH:mm:ssXXX", null); return JsonbBuilder.create(config); } } diff --git a/src/main/java/org/eclipsefoundation/marketplace/dao/impl/DefaultMongoDao.java b/src/main/java/org/eclipsefoundation/marketplace/dao/impl/DefaultMongoDao.java index d44a93fbc0922126b707b01984202132b1315b7c..892660e0314498ac6cf2869a06a491ea3ed51468 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dao/impl/DefaultMongoDao.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dao/impl/DefaultMongoDao.java @@ -15,6 +15,7 @@ import javax.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.reactive.streams.operators.PublisherBuilder; import org.eclipsefoundation.marketplace.dao.MongoDao; import org.eclipsefoundation.marketplace.exception.MaintenanceException; import org.eclipsefoundation.marketplace.model.MongoQuery; @@ -59,10 +60,16 @@ public class DefaultMongoDao implements MongoDao { if (LOGGER.isDebugEnabled()) { LOGGER.debug("Querying MongoDB using the following query: {}", q); } - + LOGGER.debug("Getting aggregate results"); - return getCollection(q.getDocType()).aggregate(q.getPipeline(getLimit(q)), q.getDocType()).limit(getLimit(q)) - .distinct().toList().run(); + // build base query + PublisherBuilder<T> builder = getCollection(q.getDocType()).aggregate(q.getPipeline(getLimit(q)), q.getDocType()); + // check if result set should be limited + if (q.getDTOFilter().useLimit()) { + builder = builder.limit(getLimit(q)); + } + // run the query + return builder.distinct().toList().run(); } @Override diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/Install.java b/src/main/java/org/eclipsefoundation/marketplace/dto/Install.java index ab4a7ac675b94f8a12d8d732d604dc0161de205d..8a914f9d37d430dd4be80bc5bad831f43229057b 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dto/Install.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/Install.java @@ -135,4 +135,28 @@ public class Install { public void setLocale(String locale) { this.locale = locale; } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("Install [id="); + builder.append(id); + builder.append(", installDate="); + builder.append(installDate); + builder.append(", os="); + builder.append(os); + builder.append(", version="); + builder.append(version); + builder.append(", listingId="); + builder.append(listingId); + builder.append(", javaVersion="); + builder.append(javaVersion); + builder.append(", eclipseVersion="); + builder.append(eclipseVersion); + builder.append(", locale="); + builder.append(locale); + builder.append("]"); + return builder.toString(); + } + } diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/InstallMetrics.java b/src/main/java/org/eclipsefoundation/marketplace/dto/InstallMetrics.java new file mode 100644 index 0000000000000000000000000000000000000000..5f2201d69c0cc7131c7af10ec0e6062459e6ec4a --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/InstallMetrics.java @@ -0,0 +1,113 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto; + +import java.util.List; +import java.util.Objects; + +/** + * Holds a set of install metrics for the last year for a given listing. + * + * @author Martin Lowe + * + */ +public class InstallMetrics { + + private String listingId; + private List<MetricPeriod> periods; + private int total; + + public InstallMetrics() { + } + + /** + * @param listingId + * @param periods + */ + public InstallMetrics(String listingId, List<MetricPeriod> periods, int total) { + this.listingId = listingId; + this.periods = periods; + this.total = total; + } + + /** + * @return the listingId + */ + public String getListingId() { + return listingId; + } + + /** + * @param listingId the listingId to set + */ + public void setListingId(String listingId) { + this.listingId = listingId; + } + + /** + * @return the periods + */ + public List<MetricPeriod> getPeriods() { + return periods; + } + + /** + * @param periods the periods to set + */ + public void setPeriods(List<MetricPeriod> periods) { + this.periods = periods; + } + + /** + * @return the total + */ + public int getTotal() { + return total; + } + + /** + * @param total the total to set + */ + public void setTotal(int total) { + this.total = total; + } + + @Override + public int hashCode() { + return Objects.hash(listingId, periods, total); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + InstallMetrics other = (InstallMetrics) obj; + return Objects.equals(listingId, other.listingId) && Objects.equals(periods, other.periods) + && Objects.equals(total, other.total); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("InstallMetrics [listingId="); + builder.append(listingId); + builder.append(", periods="); + builder.append(periods); + builder.append(", total="); + builder.append(total); + builder.append("]"); + return builder.toString(); + } + +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/Listing.java b/src/main/java/org/eclipsefoundation/marketplace/dto/Listing.java index 2a76e21563ef2a4932f55073d02067e4eb8b8c85..d52eb5dade2672742918c458b621d5b0dc49e448 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dto/Listing.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/Listing.java @@ -38,10 +38,10 @@ public class Listing extends NodeBase { private boolean foundationMember; @SortableField(name = "installs_count") - private long installsTotal; + private Integer installsTotal; @SortableField(name = "installs_count_recent") - private long installsRecent; + private Integer installsRecent; @SortableField private long favoriteCount; @@ -179,7 +179,7 @@ public class Listing extends NodeBase { * @return the installsTotal */ @JsonbProperty("installs_count") - public long getInstallsTotal() { + public Integer getInstallsTotal() { return installsTotal; } @@ -187,7 +187,7 @@ public class Listing extends NodeBase { * @param installsTotal the installsTotal to set */ @JsonbTransient - public void setInstallsTotal(long installsTotal) { + public void setInstallsTotal(Integer installsTotal) { this.installsTotal = installsTotal; } @@ -195,7 +195,7 @@ public class Listing extends NodeBase { * @return the installsRecent */ @JsonbProperty("installs_count_recent") - public long getInstallsRecent() { + public Integer getInstallsRecent() { return installsRecent; } @@ -203,7 +203,7 @@ public class Listing extends NodeBase { * @param installsRecent the installsRecent to set */ @JsonbTransient - public void setInstallsRecent(long installsRecent) { + public void setInstallsRecent(Integer installsRecent) { this.installsRecent = installsRecent; } diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/MetricPeriod.java b/src/main/java/org/eclipsefoundation/marketplace/dto/MetricPeriod.java new file mode 100644 index 0000000000000000000000000000000000000000..187f131bdcc0161ff146eb8c707a8a701ef9c684 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/MetricPeriod.java @@ -0,0 +1,98 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto; + +import java.util.Date; + +import javax.json.bind.annotation.JsonbTransient; + +/** + * Represents a set of install metrics for a given listing in a time period. + * This is represented without filters and cannot be filtered down. + * + * @author Martin Lowe + * + */ +public class MetricPeriod { + + private String listingId; + private Integer count; + private Date start; + private Date end; + + /** + * @return the listingId + */ + @JsonbTransient + public String getListingId() { + return listingId; + } + + /** + * @param listingId the listingId to set + */ + public void setListingId(String listingId) { + this.listingId = listingId; + } + + /** + * @return the count + */ + public Integer getCount() { + return count; + } + + /** + * @param count the count to set + */ + public void setCount(Integer count) { + this.count = count; + } + + /** + * @return the start + */ + public Date getStart() { + return start; + } + + /** + * @param start the start to set + */ + public void setStart(Date start) { + this.start = start; + } + + /** + * @return the end + */ + public Date getEnd() { + return end; + } + + /** + * @param end the end to set + */ + public void setEnd(Date end) { + this.end = end; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("InstallMetric [listingId="); + builder.append(listingId); + builder.append(", count="); + builder.append(count); + builder.append(", start="); + builder.append(start); + builder.append(", end="); + builder.append(end); + builder.append("]"); + return builder.toString(); + } +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/InstallMetricsCodec.java b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/InstallMetricsCodec.java new file mode 100644 index 0000000000000000000000000000000000000000..ef3a33ab4e1175227b6a93ca3025ca6775f8771f --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/InstallMetricsCodec.java @@ -0,0 +1,176 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.codecs; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.bson.BsonReader; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.bson.BsonWriter; +import org.bson.Document; +import org.bson.codecs.Codec; +import org.bson.codecs.CollectibleCodec; +import org.bson.codecs.DecoderContext; +import org.bson.codecs.EncoderContext; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; +import org.eclipsefoundation.marketplace.dto.converters.MetricPeriodConverter; +import org.eclipsefoundation.marketplace.namespace.DatabaseFieldNames; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.mongodb.MongoClient; + +/** + * Codec for getting and translating {@linkplain InstallMetrics} objects. + * + * @author Martin Lowe + * + */ +public class InstallMetricsCodec implements CollectibleCodec<InstallMetrics> { + private static final Logger LOGGER = LoggerFactory.getLogger(InstallMetricsCodec.class); + private final Codec<Document> documentCodec; + + private MetricPeriodConverter periodConverter; + + /** + * Creates the codec and initializes the codecs and converters needed to create + * a listing from end to end. + */ + public InstallMetricsCodec() { + this.documentCodec = MongoClient.getDefaultCodecRegistry().get(Document.class); + this.periodConverter = new MetricPeriodConverter(); + } + + @Override + public void encode(BsonWriter writer, InstallMetrics value, EncoderContext encoderContext) { + Document doc = new Document(); + + doc.put(DatabaseFieldNames.DOCID, value.getListingId()); + doc.put(DatabaseFieldNames.PERIOD_COUNT, value.getTotal()); + + // get the periods and sort them + List<MetricPeriod> mps = value.getPeriods(); + mps.sort((o1, o2) -> o2.getEnd().compareTo(o1.getEnd())); + + LOGGER.debug("Parsing periods for {}", value.getListingId()); + // get calendar to check months + Calendar c = Calendar.getInstance(); + int curr = 0; + for (int i = 0; i < 12; i++) { + // get the next period + MetricPeriod period; + if (curr < mps.size()) { + period = mps.get(curr); + LOGGER.debug("Got period: {}", period); + // check that the retrieved period is the same month + Calendar periodCalendar = Calendar.getInstance(); + periodCalendar.setTime(period.getEnd()); + if (periodCalendar.get(Calendar.MONTH) != c.get(Calendar.MONTH)) { + LOGGER.debug("Regenerating period, {}:{}", periodCalendar.get(Calendar.MONTH), + c.get(Calendar.MONTH)); + // if the month doesn't match, get a new month + period = generatePeriod(value.getListingId(), c); + LOGGER.debug("Regenerated period, {}", period); + } else { + // increment the array index pointer once its used + curr++; + // increment after we get a period + c.add(Calendar.MONTH, -1); + } + } else { + period = generatePeriod(value.getListingId(), c); + LOGGER.debug("Generated period: {}", period); + } + // put the period into the document + doc.put(DatabaseFieldNames.MONTH_OFFSET_PREFIX + i, periodConverter.convert(period)); + } + documentCodec.encode(writer, doc, encoderContext); + } + + @Override + public InstallMetrics decode(BsonReader reader, DecoderContext decoderContext) { + Document document = documentCodec.decode(reader, decoderContext); + + InstallMetrics out = new InstallMetrics(); + out.setListingId(document.getString(DatabaseFieldNames.DOCID)); + out.setTotal(document.getInteger(DatabaseFieldNames.PERIOD_COUNT)); + + // get the base calendar for the documents + Calendar c = getBaseCalendar(document); + // create a list of periods + List<MetricPeriod> periods = new ArrayList<>(12); + for (int i = 0; i < 12; i++) { + MetricPeriod period = periodConverter.convert(document.get(DatabaseFieldNames.MONTH_OFFSET_PREFIX + i, Document.class)); + // if there is no period, generate one. otherwise, increment c and continue + if (period == null) { + period = generatePeriod(out.getListingId(), c); + } else { + c.add(Calendar.MONTH, -1); + } + periods.add(period); + } + out.setPeriods(periods); + + return out; + } + + @Override + public Class<InstallMetrics> getEncoderClass() { + return InstallMetrics.class; + } + + @Override + public InstallMetrics generateIdIfAbsentFromDocument(InstallMetrics document) { + if (!documentHasId(document)) { + throw new IllegalArgumentException( + "A listing ID must be set to InstallMetrics objects before writing or they are invalid"); + } + return document; + } + + @Override + public boolean documentHasId(InstallMetrics document) { + return !StringUtils.isBlank(document.getListingId()); + } + + @Override + public BsonValue getDocumentId(InstallMetrics document) { + return new BsonString(document.getListingId()); + } + + private Calendar getBaseCalendar(Document d) { + for (int i = 0; i < 12; i++) { + MetricPeriod period = periodConverter.convert(d.get(DatabaseFieldNames.MONTH_OFFSET_PREFIX + i, Document.class)); + // if we have a period set, get its date + if (period != null) { + Calendar out = Calendar.getInstance(); + out.setTime(period.getEnd()); + // adjust the calendar to the base date + out.add(Calendar.MONTH, i); + return out; + } + } + // fall back to now as the base time as there is no date to compare to + return Calendar.getInstance(); + } + + private MetricPeriod generatePeriod(String listingId, Calendar c) { + MetricPeriod period = new MetricPeriod(); + period.setListingId(listingId); + period.setCount(0); + period.setEnd(c.getTime()); + c.add(Calendar.MONTH, -1); + period.setStart(c.getTime()); + + return period; + } +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/ListingCodec.java b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/ListingCodec.java index c8f956adbfdb9d5d96a26b6ab17579c4cc756daf..9870f5afb0b2121be9a451d77ba3d648c63c9fea 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/ListingCodec.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/ListingCodec.java @@ -72,8 +72,8 @@ public class ListingCodec implements CollectibleCodec<Listing> { doc.put(DatabaseFieldNames.LISTING_BODY, value.getTeaser()); doc.put(DatabaseFieldNames.LISTING_TEASER, value.getBody()); doc.put(DatabaseFieldNames.MARKETPLACE_FAVORITES, value.getFavoriteCount()); - doc.put(DatabaseFieldNames.RECENT_NSTALLS, value.getInstallsRecent()); - doc.put(DatabaseFieldNames.TOTAL_NSTALLS, value.getInstallsTotal()); + doc.put(DatabaseFieldNames.RECENT_INSTALLS, value.getInstallsRecent()); + doc.put(DatabaseFieldNames.TOTAL_INSTALLS, value.getInstallsTotal()); doc.put(DatabaseFieldNames.LICENSE_TYPE, value.getLicense()); doc.put(DatabaseFieldNames.LISTING_STATUS, value.getStatus()); doc.put(DatabaseFieldNames.UPDATE_DATE, DateTimeHelper.toRFC3339(value.getUpdateDate())); @@ -112,8 +112,8 @@ public class ListingCodec implements CollectibleCodec<Listing> { out.setTeaser(document.getString(DatabaseFieldNames.LISTING_TEASER)); out.setBody(document.getString(DatabaseFieldNames.LISTING_BODY)); out.setStatus(document.getString(DatabaseFieldNames.LISTING_STATUS)); - out.setInstallsRecent(document.getLong(DatabaseFieldNames.RECENT_NSTALLS)); - out.setInstallsTotal(document.getLong(DatabaseFieldNames.TOTAL_NSTALLS)); + out.setInstallsRecent(document.getInteger(DatabaseFieldNames.RECENT_INSTALLS)); + out.setInstallsTotal(document.getInteger(DatabaseFieldNames.TOTAL_INSTALLS)); out.setLicense(document.getString(DatabaseFieldNames.LICENSE_TYPE)); out.setFavoriteCount(document.getLong(DatabaseFieldNames.MARKETPLACE_FAVORITES)); out.setFoundationMember(document.getBoolean(DatabaseFieldNames.FOUNDATION_MEMBER_FLAG)); diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/MetricPeriodCodec.java b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/MetricPeriodCodec.java new file mode 100644 index 0000000000000000000000000000000000000000..c4d4905cd63a386add61900e6b9fc614b68b8039 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/codecs/MetricPeriodCodec.java @@ -0,0 +1,81 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.codecs; + +import org.apache.commons.lang3.StringUtils; +import org.bson.BsonReader; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.bson.BsonWriter; +import org.bson.Document; +import org.bson.codecs.Codec; +import org.bson.codecs.CollectibleCodec; +import org.bson.codecs.DecoderContext; +import org.bson.codecs.EncoderContext; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; +import org.eclipsefoundation.marketplace.dto.converters.MetricPeriodConverter; + +import com.mongodb.MongoClient; + +/** + * Codec for getting and translating {@linkplain MetricPeriod} objects. These do + * not represent a table but a section of the {@linkplain InstallMetrics} that + * are generated from the install table. + * + * @author Martin Lowe + * + */ +public class MetricPeriodCodec implements CollectibleCodec<MetricPeriod> { + private final Codec<Document> documentCodec; + + private MetricPeriodConverter periodConverter; + + /** + * Creates the codec and initializes the codecs and converters needed to create + * a listing from end to end. + */ + public MetricPeriodCodec() { + this.documentCodec = MongoClient.getDefaultCodecRegistry().get(Document.class); + this.periodConverter = new MetricPeriodConverter(); + } + + @Override + public void encode(BsonWriter writer, MetricPeriod value, EncoderContext encoderContext) { + documentCodec.encode(writer, periodConverter.convert(value), encoderContext); + } + + @Override + public Class<MetricPeriod> getEncoderClass() { + return MetricPeriod.class; + } + + @Override + public MetricPeriod decode(BsonReader reader, DecoderContext decoderContext) { + return periodConverter.convert(documentCodec.decode(reader, decoderContext)); + } + + @Override + public MetricPeriod generateIdIfAbsentFromDocument(MetricPeriod document) { + if (!documentHasId(document)) { + throw new IllegalArgumentException( + "A listing ID must be set to MetricPeriod objects before writing or they are invalid"); + } + return document; + } + + @Override + public boolean documentHasId(MetricPeriod document) { + return !StringUtils.isBlank(document.getListingId()); + } + + @Override + public BsonValue getDocumentId(MetricPeriod document) { + return new BsonString(document.getListingId()); + } + +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/converters/MetricPeriodConverter.java b/src/main/java/org/eclipsefoundation/marketplace/dto/converters/MetricPeriodConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..9a1fe87b11a1e2fe2d5f851bc1745ae747f6f8a0 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/converters/MetricPeriodConverter.java @@ -0,0 +1,40 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.converters; + +import org.bson.Document; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; +import org.eclipsefoundation.marketplace.namespace.DatabaseFieldNames; + +/** + * Converter implementation for the {@link MetricPeriod} object. + * + * @author Martin Lowe + */ +public class MetricPeriodConverter implements Converter<MetricPeriod> { + + @Override + public MetricPeriod convert(Document src) { + MetricPeriod out = new MetricPeriod(); + out.setListingId(src.getString(DatabaseFieldNames.DOCID)); + out.setStart(src.getDate(DatabaseFieldNames.PERIOD_START)); + out.setEnd(src.getDate(DatabaseFieldNames.PERIOD_END)); + out.setCount(src.getInteger(DatabaseFieldNames.PERIOD_COUNT)); + return out; + } + + @Override + public Document convert(MetricPeriod src) { + Document doc = new Document(); + doc.put(DatabaseFieldNames.DOCID, src.getListingId()); + doc.put(DatabaseFieldNames.PERIOD_START, src.getStart()); + doc.put(DatabaseFieldNames.PERIOD_END, src.getEnd()); + doc.put(DatabaseFieldNames.PERIOD_COUNT, src.getCount()); + return doc; + } + +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/DtoFilter.java b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/DtoFilter.java index c642a7d6de5155e07c6d15df645dd7361f9d3c4b..c2b2448b1e50769a68c4a777a0d155de2d95fad7 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/DtoFilter.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/DtoFilter.java @@ -71,4 +71,15 @@ public interface DtoFilter<T> { default String getPath(String root, String fieldName) { return StringUtils.isBlank(root) ? fieldName : root + '.' + fieldName; } + + /** + * Whether this type of data should be restrained to a limited set, or return + * all data that is found. + * + * @return true if limit should be used, false otherwise. + */ + default boolean useLimit() { + return true; + } + } diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/InstallMetricsFilter.java b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/InstallMetricsFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..38e75c081cbd92a0b6feed2be1c50b6bff033e56 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/InstallMetricsFilter.java @@ -0,0 +1,57 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.filter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.enterprise.context.ApplicationScoped; + +import org.bson.conversions.Bson; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; +import org.eclipsefoundation.marketplace.model.RequestWrapper; +import org.eclipsefoundation.marketplace.namespace.DatabaseFieldNames; +import org.eclipsefoundation.marketplace.namespace.UrlParameterNames; + +import com.mongodb.client.model.Filters; + +/** + * Filter implementation for the {@linkplain InstallMetrics} class. + * + * @author Martin Lowe + * + */ +@ApplicationScoped +public class InstallMetricsFilter implements DtoFilter<InstallMetrics> { + + @Override + public List<Bson> getFilters(RequestWrapper wrap, String root) { + List<Bson> filters = new ArrayList<>(); + // perform following checks only if there is no doc root + if (root == null) { + // ID check + Optional<String> id = wrap.getFirstParam(UrlParameterNames.ID); + if (id.isPresent()) { + filters.add(Filters.eq(DatabaseFieldNames.DOCID, id.get())); + } + } + return filters; + } + + @Override + public List<Bson> getAggregates(RequestWrapper wrap) { + return Collections.emptyList(); + } + + @Override + public Class<InstallMetrics> getType() { + return InstallMetrics.class; + } + +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/ListingFilter.java b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/ListingFilter.java index ceac34a0e0a45267c44246f530ea1257b9c8ea9f..d773e722dcd49ff73772357fd0865474d2425329 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/ListingFilter.java +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/ListingFilter.java @@ -22,7 +22,9 @@ import org.eclipsefoundation.marketplace.namespace.DtoTableNames; import org.eclipsefoundation.marketplace.namespace.UrlParameterNames; import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.Field; import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Projections; /** * Filter implementation for the {@linkplain Listing} class. @@ -80,13 +82,21 @@ public class ListingFilter implements DtoFilter<Listing> { aggs.add(Aggregates.lookup(DtoTableNames.LISTING_VERSION.getTableName(), DatabaseFieldNames.DOCID, DatabaseFieldNames.LISTING_ID, DatabaseFieldNames.LISTING_VERSIONS)); aggs.addAll(listingVersionFilter.wrapFiltersToAggregate(wrap, DatabaseFieldNames.LISTING_VERSIONS)); - aggs.add(Aggregates.lookup(DtoTableNames.CATEGORY.getTableName(), DatabaseFieldNames.CATEGORY_IDS, DatabaseFieldNames.DOCID, - DatabaseFieldNames.LISTING_CATEGORIES)); + aggs.add(Aggregates.lookup(DtoTableNames.CATEGORY.getTableName(), DatabaseFieldNames.CATEGORY_IDS, + DatabaseFieldNames.DOCID, DatabaseFieldNames.LISTING_CATEGORIES)); List<String> marketIds = wrap.getParams(UrlParameterNames.MARKET_IDS); if (!marketIds.isEmpty()) { aggs.add(Aggregates.match(Filters.in("categories.market_ids", marketIds))); } - + // adds a $lookup aggregate, joining install metrics on ids as "installs" + aggs.add(Aggregates.lookup(DtoTableNames.INSTALL_METRIC.getTableName(), DatabaseFieldNames.DOCID, + DatabaseFieldNames.DOCID, "installs")); + // unwinds the installs out of arrays + aggs.add(Aggregates.unwind("$installs")); + // push the installs counts to the listing, and remove the installs merged in + aggs.add(Aggregates.addFields(new Field<String>(DatabaseFieldNames.RECENT_INSTALLS, "$installs.offset_0.count"), + new Field<String>(DatabaseFieldNames.TOTAL_INSTALLS, "$installs.count"))); + aggs.add(Aggregates.project(Projections.exclude("installs"))); return aggs; } diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/filter/MetricPeriodFilter.java b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/MetricPeriodFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..1f6b9bbc8e7f1f3911eb7d58318a5eecf3b1547a --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/filter/MetricPeriodFilter.java @@ -0,0 +1,99 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.filter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.enterprise.context.ApplicationScoped; + +import org.bson.BsonArray; +import org.bson.BsonDocument; +import org.bson.BsonString; +import org.bson.conversions.Bson; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; +import org.eclipsefoundation.marketplace.model.RequestWrapper; +import org.eclipsefoundation.marketplace.namespace.DatabaseFieldNames; +import org.eclipsefoundation.marketplace.namespace.UrlParameterNames; + +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.BsonField; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Projections; + +/** + * Filter implementation for the {@linkplain MetricPeriod} class. + * + * @author Martin Lowe + * + */ +@ApplicationScoped +public class MetricPeriodFilter implements DtoFilter<MetricPeriod> { + + @Override + public List<Bson> getFilters(RequestWrapper wrap, String root) { + return Collections.emptyList(); + } + + @Override + public List<Bson> getAggregates(RequestWrapper wrap) { + // check that we have required fields first + Optional<String> startDate = wrap.getFirstParam(UrlParameterNames.START); + Optional<String> endDate = wrap.getFirstParam(UrlParameterNames.END); + List<Bson> aggregates = new ArrayList<>(); + if (startDate.isPresent() && endDate.isPresent()) { + // check for all listings that are after the start date + BsonArray startDateComparison = new BsonArray(); + startDateComparison.add(new BsonString("$" + DatabaseFieldNames.INSTALL_DATE)); + BsonDocument startDoc = new BsonDocument(); + startDoc.append("dateString", new BsonString(startDate.get())); + startDoc.append("format", new BsonString("%Y-%m-%dT%H:%M:%SZ")); + // build doc to convert string to date to be used in query + BsonDocument startDateConversion = new BsonDocument("$dateFromString", startDoc); + startDateComparison.add(startDateConversion); + + // check for all listings that are before the end date + BsonArray endDateComparison = new BsonArray(); + endDateComparison.add(new BsonString("$" + DatabaseFieldNames.INSTALL_DATE)); + BsonDocument endDoc = new BsonDocument(); + endDoc.append("dateString", new BsonString(endDate.get())); + endDoc.append("format", new BsonString("%Y-%m-%dT%H:%M:%SZ")); + // build doc to convert string to date to be used in query + BsonDocument endDateConversion = new BsonDocument("$dateFromString", endDoc); + endDateComparison.add(endDateConversion); + + // add the 2 date comparisons to the pipeline + aggregates.add(Aggregates.match(Filters + .expr(Filters.eq("$and", new BsonArray(Arrays.asList(new BsonDocument("$gte", startDateComparison), + new BsonDocument("$lte", endDateComparison))))))); + // group the results by listing ID + aggregates.add(Aggregates.group("$listing_id", new BsonField(DatabaseFieldNames.PERIOD_COUNT, Filters.eq("$sum", 1)))); + // project the start + end date into the end result + aggregates.add(Aggregates.project(Projections.fields(Projections.include(DatabaseFieldNames.PERIOD_COUNT), + Projections.computed(DatabaseFieldNames.PERIOD_START, startDateConversion), + Projections.computed(DatabaseFieldNames.PERIOD_END, endDateConversion)))); + } else { + // count all existing installs and group them by listing ID + aggregates.add(Aggregates.group("$listing_id", new BsonField(DatabaseFieldNames.PERIOD_COUNT, Filters.eq("$sum", 1)))); + } + + return aggregates; + } + + @Override + public Class<MetricPeriod> getType() { + return MetricPeriod.class; + } + + @Override + public boolean useLimit() { + return false; + } +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/providers/InstallMetricsCodecProvider.java b/src/main/java/org/eclipsefoundation/marketplace/dto/providers/InstallMetricsCodecProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..b83a0c795b37eb7772a05e26284ee962e98ac8b1 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/providers/InstallMetricsCodecProvider.java @@ -0,0 +1,35 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.providers; + +import org.bson.codecs.Codec; +import org.bson.codecs.configuration.CodecProvider; +import org.bson.codecs.configuration.CodecRegistry; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; +import org.eclipsefoundation.marketplace.dto.codecs.InstallMetricsCodec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides the {@link InstallMetricsCodec} to MongoDB for conversions of + * {@link InstallMetrics} objects. + * + * @author Martin Lowe + */ +public class InstallMetricsCodecProvider implements CodecProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(InstallMetricsCodecProvider.class); + + @SuppressWarnings("unchecked") + @Override + public <T> Codec<T> get(Class<T> clazz, CodecRegistry registry) { + if (clazz == InstallMetrics.class) { + LOGGER.debug("Registering custom InstallMetrics class MongoDB codec"); + return (Codec<T>) new InstallMetricsCodec(); + } + return null; + } +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/dto/providers/MetricPeriodCodecProvider.java b/src/main/java/org/eclipsefoundation/marketplace/dto/providers/MetricPeriodCodecProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..445a48a6e614f942f2e2375b4b3cca7f72273fb9 --- /dev/null +++ b/src/main/java/org/eclipsefoundation/marketplace/dto/providers/MetricPeriodCodecProvider.java @@ -0,0 +1,35 @@ +/* Copyright (c) 2019 Eclipse Foundation and others. + * This program and the accompanying materials are made available + * under the terms of the Eclipse Public License 2.0 + * which is available at http://www.eclipse.org/legal/epl-v20.html, + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipsefoundation.marketplace.dto.providers; + +import org.bson.codecs.Codec; +import org.bson.codecs.configuration.CodecProvider; +import org.bson.codecs.configuration.CodecRegistry; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; +import org.eclipsefoundation.marketplace.dto.codecs.MetricPeriodCodec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides the {@link MetricPeriodCodec} to MongoDB for conversions of + * {@link MetricPeriod} objects. + * + * @author Martin Lowe + */ +public class MetricPeriodCodecProvider implements CodecProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricPeriodCodecProvider.class); + + @SuppressWarnings("unchecked") + @Override + public <T> Codec<T> get(Class<T> clazz, CodecRegistry registry) { + if (clazz == MetricPeriod.class) { + LOGGER.debug("Registering custom MetricPeriod class MongoDB codec"); + return (Codec<T>) new MetricPeriodCodec(); + } + return null; + } +} diff --git a/src/main/java/org/eclipsefoundation/marketplace/helper/DateTimeHelper.java b/src/main/java/org/eclipsefoundation/marketplace/helper/DateTimeHelper.java index d6b4157d6c445e202a21fb8f5f9e6221417d3ac1..16df77d4628f107444fd5ad9db6403a75107d253 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/helper/DateTimeHelper.java +++ b/src/main/java/org/eclipsefoundation/marketplace/helper/DateTimeHelper.java @@ -43,7 +43,7 @@ public class DateTimeHelper { return null; } } - + /** * Converts passed date to RFC 3339 compliant date string. Time is adjusted to * be in UTC time. diff --git a/src/main/java/org/eclipsefoundation/marketplace/model/MongoQuery.java b/src/main/java/org/eclipsefoundation/marketplace/model/MongoQuery.java index d792d51a0d4a9d27e785fc8f1b01fac02894296f..1b487325947c97c789dc3f6fb21fa5b1d793956e 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/model/MongoQuery.java +++ b/src/main/java/org/eclipsefoundation/marketplace/model/MongoQuery.java @@ -61,13 +61,13 @@ public class MongoQuery<T> { // clear old values if set to default this.filter = null; this.sort = null; - this.order= SortOrder.NONE; + this.order = SortOrder.NONE; this.aggregates = new ArrayList<>(); // get the filters for the current DTO List<Bson> filters = new ArrayList<>(); filters.addAll(dtoFilter.getFilters(wrapper, null)); - + // get fields that make up the required fields to enable pagination and check Optional<String> sortOpt = wrapper.getFirstParam(UrlParameterNames.SORT); if (sortOpt.isPresent()) { @@ -85,7 +85,7 @@ public class MongoQuery<T> { this.filter = Filters.and(filters); } this.aggregates = dtoFilter.getAggregates(wrapper); - + if (LOGGER.isDebugEnabled()) { LOGGER.debug("MongoDB query initialized with filter: {}", this.filter); } @@ -110,7 +110,7 @@ public class MongoQuery<T> { // add base aggregates (joins) out.addAll(aggregates); // add sample if we aren't sorting - if (sort == null || SortOrder.RANDOM.equals(order)) { + if ((sort == null || SortOrder.RANDOM.equals(order)) && dtoFilter.useLimit()) { out.add(Aggregates.sample(limit)); } if (sort != null) { @@ -136,7 +136,7 @@ public class MongoQuery<T> { private void setSort(String sortField, String sortOrder, List<Bson> filters) { Optional<String> lastOpt = wrapper.getFirstParam(UrlParameterNames.LAST_SEEN); - + List<Sortable<?>> fields = SortableHelper.getSortableFields(getDocType()); Optional<Sortable<?>> fieldContainer = SortableHelper.getSortableFieldByName(fields, sortField); if (fieldContainer.isPresent()) { @@ -180,6 +180,13 @@ public class MongoQuery<T> { return this.filter; } + /** + * @return the DTO filter + */ + public DtoFilter<T> getDTOFilter() { + return this.dtoFilter; + } + /** * @return the docType */ diff --git a/src/main/java/org/eclipsefoundation/marketplace/model/RequestWrapper.java b/src/main/java/org/eclipsefoundation/marketplace/model/RequestWrapper.java index 0b473baad4f8fa5b416af7fae7c6a7f396be12e0..aec134a4a2ebf5f92680293a873c5e6c9f66b2eb 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/model/RequestWrapper.java +++ b/src/main/java/org/eclipsefoundation/marketplace/model/RequestWrapper.java @@ -100,7 +100,6 @@ public class RequestWrapper { * Adds the given value for the given key, preserving previous values if they * exist. * - * @param wrapper map containing parameters to update * @param key string key to add the value to, must not be null * @param value the value to add to the key */ @@ -112,6 +111,23 @@ public class RequestWrapper { getParams().computeIfAbsent(key, k -> new ArrayList<>()).add(value); } + /** + * Sets the value as the value for the given key, removing previous values if + * they exist. + * + * @param key string key to add the value to, must not be null + * @param value the value to add to the key + */ + public void setParam(String key, String value) { + if (StringUtils.isBlank(key)) { + throw new IllegalArgumentException(EMPTY_KEY_MESSAGE); + } + Objects.requireNonNull(value); + // remove current value, and add new value in its place + getParams().remove(key); + addParam(key, value); + } + /** * Returns this QueryParams object as a Map of param values indexed by the param * name. diff --git a/src/main/java/org/eclipsefoundation/marketplace/namespace/DatabaseFieldNames.java b/src/main/java/org/eclipsefoundation/marketplace/namespace/DatabaseFieldNames.java index a205252b94b30705869b6599f07bc7e65ae6c370..c27f12756193b8ab8a37a0fc72fb7f199e3298ec 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/namespace/DatabaseFieldNames.java +++ b/src/main/java/org/eclipsefoundation/marketplace/namespace/DatabaseFieldNames.java @@ -30,8 +30,8 @@ public final class DatabaseFieldNames { public static final String LISTING_AUTHORS = "authors"; public static final String LISTING_STATUS = "status"; public static final String LISTING_ORGANIZATIONS = "organization"; - public static final String RECENT_NSTALLS = "installs_recent"; - public static final String TOTAL_NSTALLS = "installs_total"; + public static final String RECENT_INSTALLS = "installs_recent"; + public static final String TOTAL_INSTALLS = "installs_total"; public static final String MARKETPLACE_FAVORITES = "favorite_count"; public static final String LICENSE_TYPE = "license_type"; public static final String PLATFORMS = "platforms"; @@ -78,6 +78,15 @@ public final class DatabaseFieldNames { public static final String ECLIPSE_VERSION = "eclipse_version"; public static final String LOCALE = "locale"; + // metric period fields + public static final String PERIOD_END = "period_end"; + public static final String PERIOD_START = "period_start"; + public static final String PERIOD_COUNT = "count"; + + // install metric fields + public static final String METRIC_PERIODS = "periods"; + public static final String MONTH_OFFSET_PREFIX = "offset_"; + private DatabaseFieldNames() { } } diff --git a/src/main/java/org/eclipsefoundation/marketplace/namespace/DtoTableNames.java b/src/main/java/org/eclipsefoundation/marketplace/namespace/DtoTableNames.java index 6c3042818388b1e63c5e86c950e4d9c26be70b99..a3d92ef242999a641a750c1f35fd8526f72119d7 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/namespace/DtoTableNames.java +++ b/src/main/java/org/eclipsefoundation/marketplace/namespace/DtoTableNames.java @@ -10,9 +10,11 @@ import org.eclipsefoundation.marketplace.dto.Catalog; import org.eclipsefoundation.marketplace.dto.Category; import org.eclipsefoundation.marketplace.dto.ErrorReport; import org.eclipsefoundation.marketplace.dto.Install; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; import org.eclipsefoundation.marketplace.dto.Listing; import org.eclipsefoundation.marketplace.dto.ListingVersion; import org.eclipsefoundation.marketplace.dto.Market; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; /** * Mapping of DTO classes to their respective tables in the DB. @@ -23,6 +25,7 @@ import org.eclipsefoundation.marketplace.dto.Market; public enum DtoTableNames { LISTING(Listing.class, "listings"), CATEGORY(Category.class, "categories"), CATALOG(Catalog.class, "catalogs"), MARKET(Market.class, "markets"), ERRORREPORT(ErrorReport.class, "errorreports"), INSTALL(Install.class, "installs"), + INSTALL_METRIC(InstallMetrics.class, "install_metrics"), METRIC_PERIOD(MetricPeriod.class, "installs"), LISTING_VERSION(ListingVersion.class, "listing_versions"); private Class<?> baseClass; diff --git a/src/main/java/org/eclipsefoundation/marketplace/namespace/UrlParameterNames.java b/src/main/java/org/eclipsefoundation/marketplace/namespace/UrlParameterNames.java index 5148395fc5118a9b08312ea8a8e7f8895d056a64..a9352a63a1fb046f7a309752f9c1f58839b75a55 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/namespace/UrlParameterNames.java +++ b/src/main/java/org/eclipsefoundation/marketplace/namespace/UrlParameterNames.java @@ -32,6 +32,8 @@ public final class UrlParameterNames { public static final String FEATURE_ID = "feature_id"; public static final String VERSION = "version"; public static final String DATE_FROM = "from"; + public static final String END = "end"; + public static final String START = "start"; private UrlParameterNames() { } diff --git a/src/main/java/org/eclipsefoundation/marketplace/resource/InstallResource.java b/src/main/java/org/eclipsefoundation/marketplace/resource/InstallResource.java index 01559ea81fc180a355167271b3c0898efb790248..8c1562d81d84b1df9593b4dbe56a3652bef9bdff 100644 --- a/src/main/java/org/eclipsefoundation/marketplace/resource/InstallResource.java +++ b/src/main/java/org/eclipsefoundation/marketplace/resource/InstallResource.java @@ -6,10 +6,17 @@ */ package org.eclipsefoundation.marketplace.resource; -import java.sql.Date; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import javax.annotation.security.PermitAll; import javax.annotation.security.RolesAllowed; @@ -26,7 +33,10 @@ import javax.ws.rs.core.Response.Status; import org.eclipsefoundation.marketplace.dao.MongoDao; import org.eclipsefoundation.marketplace.dto.Install; +import org.eclipsefoundation.marketplace.dto.InstallMetrics; +import org.eclipsefoundation.marketplace.dto.MetricPeriod; import org.eclipsefoundation.marketplace.dto.filter.DtoFilter; +import org.eclipsefoundation.marketplace.helper.DateTimeHelper; import org.eclipsefoundation.marketplace.helper.StreamHelper; import org.eclipsefoundation.marketplace.model.Error; import org.eclipsefoundation.marketplace.model.MongoQuery; @@ -53,32 +63,38 @@ public class InstallResource { MongoDao dao; @Inject RequestWrapper wrapper; + + // insert required filters for different objects + states @Inject DtoFilter<Install> dtoFilter; + @Inject + DtoFilter<MetricPeriod> periodFilter; + @Inject + DtoFilter<InstallMetrics> metricFilter; // Inject 2 caching service references, as we want to cache count results. @Inject CachingService<Long> countCache; @Inject - CachingService<List<Install>> installCache; + CachingService<List<InstallMetrics>> installCache; /** - * Endpoint for /listing/\<listingId\>/installs to retrieve install metrics for - * a specific listing from the database. + * Endpoint for /installs/${listingId} to retrieve install counts for a specific + * listing from the database with given filters. * - * @param listingId int version of the listing ID + * @param listingId the listing ID * @return response for the browser */ @GET @PermitAll @Path("/{listingId}") - public Response selectInstallMetrics(@PathParam("listingId") String listingId) { + public Response selectInstallCount(@PathParam("listingId") String listingId) { wrapper.addParam(UrlParameterNames.ID, listingId); - MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, installCache); + MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, null); Optional<Long> cachedResults = countCache.get(listingId, wrapper, () -> StreamHelper.awaitCompletionStage(dao.count(q))); if (!cachedResults.isPresent()) { - LOGGER.error("Error while retrieving cached listing for ID {}", listingId); + LOGGER.error("Error while retrieving cached install metrics for ID {}", listingId); return Response.serverError().build(); } @@ -88,21 +104,20 @@ public class InstallResource { /** * - * Endpoint for /listing/\<listingId\>/installs/\<version\> to retrieve install - * metrics for a specific listing version from the database. + * Endpoint for /installs/${listingId}/${version} to retrieve install counts for + * a specific listing version from the database. * - * @param listingId int version of the listing ID - * @param version int version of the listing version number + * @param listingId the listing ID + * @param version the listing version number * @return response for the browser */ @GET @PermitAll @Path("/{listingId}/{version}") - public Response selectInstallMetrics(@PathParam("listingId") String listingId, - @PathParam("version") String version) { + public Response selectInstallCount(@PathParam("listingId") String listingId, @PathParam("version") String version) { wrapper.addParam(UrlParameterNames.ID, listingId); wrapper.addParam(UrlParameterNames.VERSION, version); - MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, installCache); + MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, null); Optional<Long> cachedResults = countCache.get(getCompositeKey(listingId, version), wrapper, () -> StreamHelper.awaitCompletionStage(dao.count(q))); if (!cachedResults.isPresent()) { @@ -115,11 +130,35 @@ public class InstallResource { } /** - * Endpoint for /listing/\<listingId\>/installs/\<version\> to post install - * metrics for a specific listing version to a database. + * Endpoint for /installs/${listingId}/metrics to retrieve install metrics for a + * specific listing from the database. * - * @param listingId int version of the listing ID - * @param version int version of the listing version number + * @param listingId the listing ID + * @return response for the browser + */ + @GET + @PermitAll + @Path("/{listingId}/metrics") + public Response selectInstallMetrics(@PathParam("listingId") String listingId) { + wrapper.addParam(UrlParameterNames.ID, listingId); + MongoQuery<InstallMetrics> q = new MongoQuery<>(wrapper, metricFilter, null); + Optional<List<InstallMetrics>> cachedResults = installCache.get(listingId, wrapper, + () -> StreamHelper.awaitCompletionStage(dao.get(q))); + if (!cachedResults.isPresent()) { + LOGGER.error("Error while retrieving cached install metrics for ID {}", listingId); + return Response.serverError().build(); + } + + // return the results as a response + return Response.ok(cachedResults.get()).build(); + } + + /** + * Endpoint for /installs/${listingId}/${version} to post install metrics for a + * specific listing version to a database. + * + * @param listingId the listing ID + * @param version the listing version number * @return response for the browser */ @POST @@ -145,12 +184,11 @@ public class InstallResource { } // update the install details to reflect the current request - record.setInstallDate(new Date(System.currentTimeMillis())); record.setListingId(listingId); record.setVersion(version); // create the query wrapper to pass to DB dao - MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, installCache); + MongoQuery<Install> q = new MongoQuery<>(wrapper, dtoFilter, null); // add the object, and await the result StreamHelper.awaitCompletionStage(dao.add(q, Arrays.asList(record))); @@ -159,6 +197,80 @@ public class InstallResource { return Response.ok().build(); } + /** + * Regenerates the install_metrics table, using the install table as the base. + * Creates 12 columns for metric periods, to provide users with a count of + * installs over the past year. For months with no installs, an empty metric + * period is generated to avoid gaps in the stats. + * + * TODO: This should be moved to a separate job resource and be callable through + * a service that tracks last run time. https://github.com/EclipseFdn/marketplace-rest-api/issues/54 + * + * @return an OK response when finished + */ + @GET + @RolesAllowed("marketplace_admin_access") + @Path("/generate_metrics") + public Response generateInstallStats() { + List<CompletionStage<List<MetricPeriod>>> stages = new ArrayList<>(); + // get total install count for all listings available + Map<String, Integer> overallCounts = new HashMap<>(); + CompletionStage<List<MetricPeriod>> stage = dao.get(new MongoQuery<>(wrapper, periodFilter, null)); + stage.whenComplete((metrics, e) -> { + // if theres an error, immediately stop processing + if (e != null) { + throw new RuntimeException(e); + } + // for each metric, insert total count into the map + for (MetricPeriod metric : metrics) { + overallCounts.put(metric.getListingId(), metric.getCount()); + } + }); + stages.add(stage); + + // use thread safe map impl for storing metrics + Map<String, List<MetricPeriod>> r = new ConcurrentHashMap<>(); + // get the last 12 months of stats for installs asynchronously + Calendar c = Calendar.getInstance(); + for (int m = 0; m < 12; m++) { + // set up the date ranges for the current call + String end = DateTimeHelper.toRFC3339(c.getTime()); + c.add(Calendar.MONTH, -1); + String start = DateTimeHelper.toRFC3339(c.getTime()); + wrapper.setParam(UrlParameterNames.END, end); + wrapper.setParam(UrlParameterNames.START, start); + + // create the query wrapper to pass to DB dao. No cache needed as this info + // won't be cached + MongoQuery<MetricPeriod> q = new MongoQuery<>(wrapper, periodFilter, null); + // run query, and set up a completion activity to record data + CompletionStage<List<MetricPeriod>> statStage = dao.get(q); + statStage.whenComplete((metrics, e) -> { + // if theres an error, immediately stop processing + if (e != null) { + throw new RuntimeException(e); + } + // for each metric, insert into the map + for (MetricPeriod metric : metrics) { + r.computeIfAbsent(metric.getListingId(), k -> new ArrayList<MetricPeriod>()).add(metric); + } + }); + // keep stage reference to check when complete + stages.add(statStage); + } + // wrap futures and await all calls to finish + StreamHelper.awaitCompletionStage(CompletableFuture.allOf(stages.toArray(new CompletableFuture[] {}))); + + // convert the map to a list of install metric objects, adding in total count + List<InstallMetrics> installMetrics = r.entrySet().stream().map(entry -> new InstallMetrics(entry.getKey(), + entry.getValue(), overallCounts.getOrDefault(entry.getKey(), 0))).collect(Collectors.toList()); + + // push the content to the database, and await for it to finish + StreamHelper.awaitCompletionStage(dao.add(new MongoQuery<>(wrapper, metricFilter, null), installMetrics)); + // return the results as a response + return Response.ok().build(); + } + private String getCompositeKey(String listingId, String version) { return listingId + ':' + version; } diff --git a/src/main/node/index.js b/src/main/node/index.js index bb88462e57c5451747ee3d7d7516ea5f758eceaa..58b46c0b420a47dadefca838a274e54dd0eff7e2 100644 --- a/src/main/node/index.js +++ b/src/main/node/index.js @@ -1,6 +1,7 @@ const axios = require('axios'); +const time = require('moment'); const instance = axios.create({ - timeout: 2500, + timeout: 5000, headers: {'User-Agent': 'mpc/0.0.0'} }); const randomWords = require('random-words'); @@ -190,7 +191,10 @@ function generateInstallJSON(listing,versions) { var javaVersions = Array.from(javaVs).splice(javaVs.indexOf(version["min_java_version"])); var eclipseVersions = Array.from(eclipseVs).splice(eclipseVs.indexOf(version["eclipse_version"])); + var daysAgo = Math.floor(Math.random() * (365*2)); + return { + "install_date": time().subtract(daysAgo, 'days').format(), "listing_id": listing.id, "version": version.version, "java_version": shuff(javaVersions)[0],