Topic3Resource.java
/*
* Copyright © 2023-2025 The CTAN Team and individual authors
*
* This file is distributed under the 3-clause BSD license.
* See file LICENSE for details.
*/
package org.ctan.site.resources.catalogue;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.ctan.site.CtanConfiguration.CtanConfig;
import org.ctan.site.domain.catalogue.PkgCaption;
import org.ctan.site.domain.catalogue.Topic;
import org.ctan.site.services.content.ContentService;
import org.ctan.site.services.content.ContentService.TeaserType;
import org.ctan.site.services.util.ConfigUtils;
import org.ctan.site.stores.TopicStore;
import org.ctan.site.stores.VoteStore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.dropwizard.hibernate.UnitOfWork;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response.Status;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
/**
* The class <code>Topic3Resource</code> contains the controller for the topic
* resource.
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@Path("/3.0")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public class Topic3Resource {
/**
* The class <code>TopicInfoTo</code> contains the transport object for
* topic infos.
*/
@Getter
@Builder
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
protected static class TopicInfoTo {
private String details;
private boolean hasTeaser;
private String key;
private long size;
private String title;
}
/**
* The class <code>TopicPkgTo</code> contains the transport object for
* packages.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class TopicPkgTo {
private String caption;
private String captionLang;
private String key;
private String name;
private boolean isObsolete;
private boolean isOrphaned;
private String update;
private int stars;
private int count;
}
/**
* The class <code>TopicSummaryTo</code> contains the transport object for
* the topic resource in the summary list.
*/
@Getter
@Builder
@AllArgsConstructor
@JsonInclude(Include.NON_NULL)
protected static class TopicSummaryTo {
private String description;
private String detail;
private String key;
private String name;
private Long number;
}
/**
* The class <code>TopicTo</code> contains the transport object for the
* topic resource.
*/
@Getter
@Builder
@AllArgsConstructor
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
protected static class TopicTo {
private String description;
private String detail;
private String key;
private String name;
private Long number;
private List<TopicPkgTo> pkgs;
private String teaser;
private boolean hasTeaser;
private String title;
}
/**
* The field <code>config</code> contains the configuration.
*/
private CtanConfig config;
/**
* The field <code>contentService</code> contains the content service.
*/
private @NonNull ContentService contentService;
/**
* The field <code>store</code> contains the underlying repository.
*/
private TopicStore store;
/**
* The field <code>topics</code> contains the list of topics.
*/
private List<Topic> topics = null;
/**
* The field <code>voteStore</code> contains the vote store.
*/
private VoteStore voteStore;
/**
* This is the constructor for the class <code>Topic3Resource</code>.
*
* @param config the configuration
* @param store the underlying store
* @param contentService the content service
*/
@SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
public Topic3Resource(@NonNull CtanConfig config,
@NonNull TopicStore store,
@NonNull ContentService contentService,
@NonNull VoteStore voteStore) {
this.config = config;
this.store = store;
this.contentService = contentService;
this.voteStore = voteStore;
}
/**
* The method <code>getRandomTopics</code> provides means to retrieve a
* random list of topics.
*
* @return the configuration
*/
@GET
@Path("/rnd/topics")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<TopicInfoTo> getRandomTopics(
@QueryParam("size") @DefaultValue("3") int count,
@QueryParam("lang") @DefaultValue("en") String lang) {
if (topics == null) {
// TODO update from time to time
topics = store.findAll();
Collections.shuffle(topics);
}
if (Math.random() < .01) {
Collections.shuffle(topics);
}
List<TopicInfoTo> result = new ArrayList<TopicInfoTo>();
var n = 1;
for (var it : topics) {
result.add(TopicInfoTo.builder()
.key(it.getKey())
.title(it.getTitle(lang))
.details(it.getDetails(lang))
.hasTeaser(contentService.hasTeaser(TeaserType.TOPIC, lang))
.size(it.getNumber())
.build());
if (n++ >= count) {
break;
}
}
return result;
}
/**
* The method <code>getTopic</code> provides means to retrieve a topic by
* its id.
*
* @param id the id
* @param lang the language
* @return the topic or {@code null}
*/
@GET
@Path("/topic/{id}")
@PermitAll
@UnitOfWork(value = "siteDb")
public TopicTo getTopicByKey(@NonNull @PathParam("id") String id,
@QueryParam("lang") String lang) {
try {
lang = ConfigUtils.fallbackLanguage(config, lang);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
var it = store.getByKey(id);
if (it == null) {
throw new WebApplicationException(Status.NOT_FOUND);
}
final var loc = lang;
return TopicTo.builder()
.key(it.getKey())
.name(it.getTitle(lang))
.detail(it.getDetails(lang))
.hasTeaser(contentService.hasTeaser(TeaserType.TOPIC, it.getKey()))
.teaser(it.getTeaser(lang))
.title(it.getTitle(lang))
.description(it.getDescription(lang))
.number(it.getNumber())
.pkgs(it.getPackages().stream()
.map(p -> {
var stars = voteStore.count(p.getKey());
PkgCaption caption = p.getCaption(loc);
return TopicPkgTo.builder()
.key(p.getKey())
.name(p.getName())
.caption(caption == null ? "" : caption.getCaption())
.captionLang(caption == null ? "" : caption.getLang())
.isObsolete(p.isObsolete())
.isOrphaned(p.isOrphaned())
.update(p.getVersionDate())
.stars(stars.getSum())
.count(stars.getCount())
.build();
})
.collect(Collectors.toList()))
.build();
}
/**
* The method <code>getTopics</code> provides means to retrieve a list of
* authors starting with a given pattern.
*
* @param pattern the initial string of the name
* @param lang the language code
* @param page the page
* @param size the page size
* @return a list of matching author summaries
*/
@GET
@Path("/topics/{pattern}")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<TopicSummaryTo> getTopics(
@PathParam("pattern") String pattern,
@QueryParam("lang") String lang,
@DefaultValue("0") @QueryParam("page") long page,
@DefaultValue("16") @QueryParam("size") long size) {
try {
lang = ConfigUtils.fallbackLanguage(config, lang);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
final var language = lang;
if (size < 0L) {
throw new WebApplicationException("negative size",
Status.BAD_REQUEST);
}
final var p = pattern == null ? "" : pattern.toLowerCase();
var stream = store.findAll()
.stream();
if (!"".equals(p)) {
stream = stream
.filter(a -> a.getTitle(language).startsWith(p));
}
return stream
.sorted((a, b) -> a.getTitle(language)
.compareToIgnoreCase(b.getTitle(language)))
.skip(page * size)
.limit(size)
.map(a -> {
return TopicSummaryTo.builder()
.key(a.getKey())
.name(a.getTitle(language))
.detail(a.getDetails(language))
.description(a.getDescription(language))
.number(a.getNumber())
.build();
})
.collect(Collectors.toList());
}
/**
* The method <code>getTopics0</code> provides means to retrieve a list of
* topics starting with a given pattern.
*
* @param lang the language code
* @return a list of matching topics
*/
@GET
@Path("/topics")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<TopicSummaryTo> getTopics0(
@QueryParam("lang") String lang) {
try {
lang = ConfigUtils.fallbackLanguage(config, lang);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
final var language = lang;
var stream = store.findAll()
.stream();
return stream
.sorted((a, b) -> a.getTitle(language)
.compareToIgnoreCase(b.getTitle(language)))
.map(a -> {
return TopicSummaryTo.builder()
.key(a.getKey())
.name(a.getTitle(language))
.detail(a.getDetails(language))
.description(a.getDescription(language))
.number(a.getNumber())
.build();
})
.collect(Collectors.toList());
}
/**
* The method <code>getTopicsList</code> provides means to retrieve a list
* of topics starting with a given pattern.
*
* @param lang the language code
* @return a list of matching topics
*/
@GET
@Path("/topic/list")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<TopicSummaryTo> getTopicsList(
@QueryParam("lang") String lang) {
try {
lang = ConfigUtils.fallbackLanguage(config, lang);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
final var language = lang;
var stream = store.findAll()
.stream();
return stream
.sorted((a, b) -> a.getTitle(language)
.compareToIgnoreCase(b.getTitle(language)))
.map(a -> {
return TopicSummaryTo.builder()
.key(a.getKey())
.name(a.getTitle(language))
.build();
})
.collect(Collectors.toList());
}
}