Pkg3Resource.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.Pkg;
import org.ctan.site.domain.catalogue.PkgCaption;
import org.ctan.site.domain.catalogue.PkgDescription;
import org.ctan.site.services.catalogue.BibtexService;
import org.ctan.site.services.content.ContentService;
import org.ctan.site.services.content.ContentService.TeaserType;
import org.ctan.site.services.postings.PostingsService;
import org.ctan.site.services.util.ConfigUtils;
import org.ctan.site.stores.PkgAliasStore;
import org.ctan.site.stores.PkgStore;
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.Builder.Default;
import lombok.Getter;
import lombok.NonNull;
/**
* The class <code>Pkg3Resource</code> contains the controller for the package
* resource.
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@Path("/3.0")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Pkg3Resource {
/**
* The author summary.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class AuthorSummaryTo {
private String key;
private String name;
private String gender;
@Default
private Boolean died = Boolean.FALSE;
@Default
private Boolean active = Boolean.TRUE;
}
/**
* The document summary.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class DocSummaryTo {
private String details;
private String href;
private String lang;
private String title;
}
/**
* The license summary.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class LicenseSummaryTo {
private String key;
private String name;
private Boolean free;
}
/**
* The class <code>PkgCtanTo</code> contains the package CTAN path transport
* object.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class PkgCtanTo {
private String path;
private boolean file;
}
/**
* The class <code>PkgInfoTo</code> contains the package info transport
* object.
*/
@Getter
@Builder
protected static class PkgInfoTo {
private String pkg;
private String title;
private boolean hasTeaser;
private boolean isObsolete;
private boolean isOrphaned;
}
/**
* The class <code>PkgSummaryTo</code> contains the transport object for the
* package resource in the summary list.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class PkgSummaryTo {
private String key;
private String name;
private String caption;
private String captionLang;
private String description;
private String descriptionLang;
private boolean obsolete;
private boolean orphaned;
}
/**
* The class <code>PkgTo</code> contains the transport object for the
* package resource.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class PkgTo {
private Long id;
private String name;
private String key;
private String vers;
private String caption;
private String captionLang;
private Boolean download;
private String description;
private String descriptionLang;
private List<AuthorSummaryTo> authors;
private String source;
private List<String> copyright;
private List<LicenseSummaryTo> licenses;
private String home;
private String bugs;
private String support;
private String repository;
private String announce;
private String development;
private PkgCtanTo ctan;
private Boolean hasTeaser;
private List<TopicSummaryTo> topics;
private List<DocSummaryTo> docs;
private String miktexLocation;
private String texliveLocation;
private String tlcontribLocation;
private String bibtex;
private List<PostingsSummaryTo> postings;
private String alias;
}
/**
* The postings summary.
*/
@Getter
@Builder
@AllArgsConstructor
@JsonInclude(Include.NON_NULL)
protected static class PostingsSummaryTo {
private String key;
private String subject;
private String date;
}
/**
* The topic summary.
*/
@Getter
@Builder
@AllArgsConstructor
protected static class TopicSummaryTo {
private String key;
private String name;
}
/**
* The field <code>service</code> contains the underlying service.
*/
private BibtexService bibtexService;
/**
* The field <code>store</code> contains the underlying repository.
*/
private PkgStore store;
/**
* The field <code>aliasStore</code> contains the underlying repository for
* aliases.
*/
private PkgAliasStore aliasStore;
/**
* The field <code>defaultLanguage</code> contains the fallback value for
* the language if nothing else fits.
*/
private String defaultLanguage;
/**
* The field <code>contentService</code> contains the content service.
*/
private ContentService contentService;
/**
* The field <code>config</code> contains the configuration.
*/
private CtanConfig config;
/**
* The field <code>postingService</code> contains the posting cache.
*/
private PostingsService postingService;
/**
* The field <code>randomPackageCache</code> contains a list of random
* packages.
*/
private List<Pkg> randomPackageCache = null;
/**
* This is the constructor for <code>Pkg3Resource</code>.
*
* @param config the configuration
* @param store the underlying store
* @param contentService the content service
* @param bibtexService the BibTeX service
* @param postingService the posting cache
* @param aliasStore the store for package aliases
*/
@SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
public Pkg3Resource(@NonNull CtanConfig config,
@NonNull PkgStore store,
@NonNull ContentService contentService,
@NonNull BibtexService bibtexService,
@NonNull PostingsService postingService,
@NonNull PkgAliasStore aliasStore) {
this.config = config;
this.store = store;
this.contentService = contentService;
this.defaultLanguage = ConfigUtils.defaultLanguage(config);
this.bibtexService = bibtexService;
this.postingService = postingService;
this.aliasStore = aliasStore;
}
/**
* The method <code>captionOrNull</code> provides means to check whether the
* caption is present.
*
* @param pkg the package
* @param locale the locale
* @return the caption or <code>null</code>
*/
private String captionOrNull(Pkg pkg, String locale) {
PkgCaption caption = pkg.getCaption(locale, "en");
return caption == null ? null : caption.getCaption();
}
/**
* The method <code>descriptionOrNull</code> provides means to check whether
* the description is present.
*
* @param pkg the package
* @param locale the locale
* @return the description or <code>null</code>
*/
private String descriptionOrNull(Pkg pkg, String locale) {
PkgDescription description = pkg.getDescription(locale, "en");
return description == null ? null : description.getDescription();
}
/**
* The method <code>getPkgAlias</code> provides means to retrieve an alias.
*
* @param key the key
* @return the transport object with the alias
* @throws WebApplicationException in case that the key is not an alias
*/
private PkgTo getPkgAlias(String key) {
var alias = aliasStore.getByKey(key);
if (alias == null) {
throw new WebApplicationException(Status.NOT_FOUND);
}
return PkgTo.builder()
.key(alias.getKey())
.alias(alias.getPkg().getKey())
.build();
}
/**
* The method <code>getPkgByKey</code> provides means to retrieve a package
* by its id.
*
* @param key the id
* @param language the locale code
* @return the package or {@code null}
*/
@GET
@Path("/pkg/{id}")
@PermitAll
@UnitOfWork(value = "siteDb")
public PkgTo getPkgByKey(@NonNull @PathParam("id") String key,
@QueryParam("lang") String language) {
try {
language = ConfigUtils.fallbackLanguage(config, language);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
final var lang = language;
var pkg = store.getByKey(key);
if (pkg == null) {
return getPkgAlias(key);
// throw new WebApplicationException(Status.NOT_FOUND);
}
var topics = pkg.getTopics().stream()
.map(t -> TopicSummaryTo.builder()
.key(t.getKey())
.name(t.getTitle(lang))
.build())
.collect(Collectors.toList());
var docs = pkg.getDocs().stream()
.map(d -> DocSummaryTo.builder()
.href(d.getHref())
.lang(d.getLang())
.details(d.getDetails())
.title(d.getTitle())
.build())
.collect(Collectors.toList());
var licenses = pkg.getLicenses().stream()
.map(d -> LicenseSummaryTo.builder()
.key(d.getKey())
.name(d.getName())
.build())
.collect(Collectors.toList());
var authors = pkg.getAuthors().stream()
.map((a) -> {
var author = a.getAuthor();
return AuthorSummaryTo.builder()
.key(author.getKey())
.name(author.toString())
.gender(author.getGender().getValue())
.died(author.getDied())
.active(a.isActive())
.build();
})
.collect(Collectors.toList());
var copyright = pkg.getCopy().stream()
.map(c -> c.getYear() + " " + c.getOwner())
.collect(Collectors.toList());
List<PostingsSummaryTo> postings = postingService
.listByPackage(key, 255)
.stream()
.map(it -> PostingsSummaryTo.builder()
.key(it.getId())
.subject(it.getSubject())
.date(it.getDateFormatted())
.build())
.collect(Collectors.toList());
PkgCaption caption = pkg.getCaption(lang, defaultLanguage);
PkgDescription description = pkg.getDescription(lang, defaultLanguage);
return PkgTo.builder()
.key(pkg.getKey())
.name(pkg.getName())
.caption(caption == null ? null : caption.getCaption())
.captionLang(caption == null ? null : caption.getLang())
.vers(pkg.getVers())
.download(pkg.isCtanZip())
.description(
description == null ? null : description.getDescription())
.descriptionLang(description == null ? null : description.getLang())
.authors(authors)
.copyright(copyright)
.licenses(licenses)
.home(pkg.getHome())
.bugs(pkg.getBugs())
.support(pkg.getSupport())
.repository(pkg.getRepository())
.announce(pkg.getAnnounce())
.development(pkg.getDevelopment())
.hasTeaser(contentService.hasTeaser(TeaserType.PKG, key))
.ctan(PkgCtanTo.builder()
.path(pkg.getCtanPath())
.file(pkg.isCtanFile())
.build())
.topics(topics)
.docs(docs)
.miktexLocation(pkg.getMiktexLocation())
.texliveLocation(pkg.getTexliveLocation())
.tlcontribLocation(pkg.getTlContribLocation())
.bibtex(bibtexService.toBibtex(null, pkg))
.postings(postings)
.build();
}
/**
* The method <code>getPkgs</code> provides means to retrieve a list of all
* packages.
*
* @param lang the language code
* @return a list of matching package summaries
*/
@GET
@Path("/pkgs")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<PkgSummaryTo> getPkgs(
@QueryParam("lang") String lang) {
return getPkgs(null, lang);
}
/**
* The method <code>getPkgs</code> provides means to retrieve a list of
* packages starting with a given pattern.
*
* @param pattern the initial string of the key
* @param lang the language code
* @return a list of matching package summaries
*/
@GET
@Path("/pkgs/{pattern}")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<PkgSummaryTo> getPkgs(
@PathParam("pattern") String pattern,
@QueryParam("lang") String lang) {
try {
lang = ConfigUtils.fallbackLanguage(config, lang);
} catch (IllegalArgumentException e) {
throw new WebApplicationException(e.getMessage(),
Status.BAD_REQUEST);
}
final var locale = lang;
return store.findAllByNameStartingWith(pattern != null ? pattern : "")
.stream()
.map(pkg -> {
return PkgSummaryTo.builder()
.name(pkg.getName())
.key(pkg.getKey())
.caption(captionOrNull(pkg, locale))
.description(descriptionOrNull(pkg, locale))
.obsolete(pkg.getCtanPath() != null
&& pkg.getCtanPath().contains("obsolete"))
.orphaned(pkg.isOrphaned())
.build();
})
.collect(Collectors.toList());
}
/**
* The method <code>getRandomPackages</code> provides means to retrieve a
* random list of packages.
*
* @param count the number of packages
* @param lang the locale
* @param obsolete the indicator whether to include obsolete packages
* @param orphaned the indicator whether to include orphaned packages
* @return the configuration
*/
@GET
@Path("/rnd/pkgs")
@PermitAll
@UnitOfWork(value = "siteDb")
public List<PkgInfoTo> getRandomPackages(
@QueryParam("size") @DefaultValue("3") int count,
@QueryParam("lang") @DefaultValue("en") String lang,
@QueryParam("obsolete") @DefaultValue("false") boolean obsolete,
@QueryParam("orphaned") @DefaultValue("true") boolean orphaned) {
if (randomPackageCache == null) {
randomPackageCache = store.findAll();
Collections.shuffle(randomPackageCache);
} else if (Math.random() < .01) {
Collections.shuffle(randomPackageCache);
}
List<PkgInfoTo> result = new ArrayList<PkgInfoTo>();
var n = 0;
for (var it : randomPackageCache) {
if (!obsolete && it.isObsolete()) {
continue;
}
if (!orphaned && it.isOrphaned()) {
continue;
}
result.add(PkgInfoTo.builder()
.pkg(it.getKey())
.title(captionOrNull(it, lang))
.hasTeaser(contentService.hasTeaser(TeaserType.PKG, lang))
.isObsolete(obsolete)
.isOrphaned(it.isOrphaned())
.build());
if (++n >= count) {
break;
}
}
return result;
}
}