Postings3Resource.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.postings;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.ctan.site.domain.catalogue.PkgCaption;
import org.ctan.site.services.content.ContentService;
import org.ctan.site.services.content.ContentService.TeaserType;
import org.ctan.site.services.postings.Posting;
import org.ctan.site.services.postings.PostingCache;
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.Builder;
import lombok.Builder.Default;
import lombok.Getter;
import lombok.NonNull;

/**
 * The class <code>Postings3Resource</code> contains the controller for the
 * postings resource.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/3.0")
@Produces(MediaType.APPLICATION_JSON)
public class Postings3Resource {

    /**
     * The class <code>PackageTo</code> contains the transport object for a
     * package.
     */
    @Getter
    @Builder
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class PackageTo {

        private String pkg;

        private String info;

        private boolean hasTeaser;
    }

    /**
     * The class <code>PostingsPageTo</code> contains the transport object for a
     * posting page.
     */
    @Getter
    @Builder
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class PostingsPageTo {

        private List<PostingsTo> list;

        private int total;
    }

    /**
     * The class <code>PostingsTo</code> contains the transport object for a
     * list of postings.
     */
    @Getter
    @Builder
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class PostingsTo {

        private String id;

        private String date;

        private String subject;

        @JsonInclude(Include.NON_NULL)
        @Default
        private List<String> pkg = null;

        @JsonInclude(Include.NON_NULL)
        @Default
        private List<PackageTo> pkgs = null;
    }

    /**
     * The class <code>PostingTo</code> contains the transport object for a
     * posting.
     */
    @Getter
    @Builder
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class PostingTo {

        private String id;

        private String date;

        private String subject;

        private String body;

        private String teaser;

        private List<String> pkg;
    }

    /**
     * The field <code>store</code> contains the underlying repository.
     */
    private PostingCache service;

    /**
     * The field <code>contentService</code> contains the content service.
     */
    private @NonNull ContentService contentService;

    /**
     * The field <code>pkgStore</code> contains the package store.
     */
    private PkgStore pkgStore;

    /**
     * This is the constructor for the class <code>Postings3Resource</code>.
     *
     * @param service the underlying service
     * @param contentService the content service
     * @param pkgStore the package store
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public Postings3Resource(@NonNull PostingCache service,
        @NonNull ContentService contentService,
        @NonNull PkgStore pkgStore) {

        this.pkgStore = pkgStore;
        this.service = service;
        this.contentService = contentService;
    }

    /**
     * The method <code>getPkgs</code> provides means to get the associated
     * packages of a posting as transport object.
     *
     * @param posting the posting
     * @param lang the language
     *
     * @return the list of packages
     */
    private List<PackageTo> getPkgs(Posting posting, String lang) {

        var packs = posting.getPkg();
        if (packs == null) {
            return List.of();
        }
        var packageList = new ArrayList<PackageTo>();
        for (var it : packs) {
            var p = pkgStore.getByKey(it);
            if (p == null) {
                continue;
            }
            PkgCaption caption = p.getCaption(lang);
            packageList.add(PackageTo.builder()
                .pkg(it)
                .hasTeaser(contentService.hasTeaser(TeaserType.PKG, lang))
                .info(caption == null ? null : caption.getCaption())
                .build());
        }
        return packageList;
    }

    /**
     * The method <code>getPosting</code> provides means to retrieve a single
     * mail posting.
     *
     * @param id the id of the posting
     * @return the posting or throw a 404 error
     */
    @GET
    @Path("/posting/{id}")
    @PermitAll
    public PostingTo getPosting(
        @NonNull @PathParam("id") String id) {

        var posting = service.get(id);
        if (posting == null) {
            throw new WebApplicationException(Status.NOT_FOUND);
        }
        var pkgs = posting.getPkg();
        String teaser = null;
        if (pkgs != null) {
            for (var it : pkgs) {
                var t = contentService.hasTeaser(TeaserType.PKG, it);
                if (t) {
                    teaser = it;
                    break;
                }
            }
        }
        return PostingTo.builder()
            .id(id)
            .teaser(teaser)
            .body(posting.getBodyAsHtml())
            .pkg(pkgs)
            .subject(posting.getSubject())
            .date(posting.getDateFormatted())
            .build();
    }

    /**
     * The method <code>getPostings</code> provides means to retrieve a paged
     * list of email postings. They are sorted in reverse chronological order.
     *
     * @param page the current page
     * @param size the page size
     * @param lang the locale
     * @param data indicator whether to include more data
     * @return the page segment
     */
    @GET
    @Path("/postings")
    @PermitAll
    @UnitOfWork(value = "siteDb")
    public PostingsPageTo getPostings(
        @QueryParam("page") @DefaultValue("0") int page,
        @QueryParam("size") @DefaultValue("16") int size,
        @QueryParam("lang") @DefaultValue("en") String lang,
        @QueryParam("data") @DefaultValue("false") boolean data) {

        if (page < 0) {
            page = 0;
        }
        if (size <= 0) {
            size = 16;
        }
        var postings = service.listPaged(page, size);
        var list = postings
            .stream()
            .map(data
                ? it -> PostingsTo.builder()
                    .id(it.getId())
                    .subject(it.getSubject())
                    .date(it.getDay())
                    .pkgs(getPkgs(it, lang))
                    .build()
                : it -> PostingsTo.builder()
                    .id(it.getId())
                    .subject(it.getSubject())
                    .date(it.getDay())
                    .pkg(it.getPkg())
                    .build())
            .collect(Collectors.toList());
        return PostingsPageTo.builder()
            .list(list)
            .total(service.total())
            .build();
    }

    /**
     * The method <code>getVersion</code> provides means to retrieve the version
     * number of the API.
     *
     * @return a Map with a single attribute <code>version</code> containing the
     *     version number as String.
     */
    @GET
    @Path("/postings/version")
    public Map<String, String> getVersion() {

        return Map.of("version", "3.0");
    }
}