Atom10Resource.java

/*
 * Copyright © 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.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.ctan.site.CtanConfiguration;
import org.ctan.site.services.DateUtils;
import org.ctan.site.services.postings.PostingCache;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
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 lombok.Builder;
import lombok.Builder.Default;
import lombok.Getter;
import lombok.NonNull;

/**
 * The class <code>Atom10Resource</code> contains the controller for the ATOM
 * feed resource.
 *
 * @see <a href="https://datatracker.ietf.org/doc/html/rfc4287">The Atom
 *     Syndication Format</a>
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Path("/ctan-ann")
@Produces("application/atom+xml")
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Atom10Resource {

    /**
     * The class <code>AtomEntry</code> contains the item node of Atom 1.0.
     */
    @Getter
    @Builder
    @JsonInclude(Include.NON_NULL)
    public static class AtomEntry {

        @JacksonXmlProperty
        private String title;

        @JacksonXmlProperty(localName = "link")
        @JacksonXmlElementWrapper(useWrapping = false)
        private List<AtomLink> links;

        private String id;

        private String updated;

        private String summary;

        private String content;
    }

    /**
     * The class <code>Atom</code> contains the top node of Atom 1.0.
     */
    @Getter
    @Builder
    @JacksonXmlRootElement(localName = "feed")
    @JsonInclude(Include.NON_NULL)
    public static class AtomFeed {

        @JacksonXmlProperty(isAttribute = true)
        @Default
        private String xmlns = "http://www.w3.org/2005/Atom";

        private String title;

        private String subtitle;

        @JacksonXmlProperty(localName = "link")
        @JacksonXmlElementWrapper(useWrapping = false)
        private List<AtomLink> links;

        private String icon;

        private String id;

        private String updated;

        private AtomGenerator generator;

        @JacksonXmlProperty(localName = "entry")
        @JacksonXmlElementWrapper(useWrapping = false)
        private List<AtomEntry> entries;
    }

    /**
     * The class <code>AtomGenerator</code> contains the Generator node of Atom
     * 1.0.
     */
    @Getter
    @Builder
    @JsonInclude(Include.NON_NULL)
    public static class AtomGenerator {

        @JacksonXmlProperty(isAttribute = true)
        String uri;

        @JacksonXmlProperty(isAttribute = true)
        String version;

        @JacksonXmlText
        String value;
    }

    /**
     * The class <code>AtomLink</code> contains the Link node of Atom 1.0.
     */
    @Getter
    @Builder
    @JsonInclude(Include.NON_NULL)
    public static class AtomLink {

        @JacksonXmlProperty(isAttribute = true)
        private String href;

        @JacksonXmlProperty(isAttribute = true)
        private String rel;

        @JacksonXmlProperty(isAttribute = true)
        private String type;
    }

    /**
     * The field <code>URL_PREFIX</code> contains the initial part of the URL.
     */
    private static final String URL_PREFIX = "https://ctan.org/ctan-ann";

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

    /**
     * The field <code>version</code> contains the current version number.
     */
    private String version;

    /**
     * This is the constructor for the class <code>RssResource</code>.
     *
     * @param service the underlying service
     * @param config the configuration
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public Atom10Resource(@NonNull PostingCache service,
        @NonNull CtanConfiguration config) {

        this.service = service;
        this.version = config.getVersion();
    }

    /**
     * The method <code>clip</code> provides means to restrict the length of a
     * string.
     *
     * @param body the text
     * @param len the length
     * @return the clipped text
     */
    String clip(String body, int len) {

        if (body == null) {
            return null;
        }
        if (body.length() > len) {
            var i = body.indexOf('.', len);
            if (i >= 0) {
                body = body.substring(0, i) + "...";
            }
        }
        return body;
    }

    /**
     * The method <code>getAtom10</code> provides means to retrieve the Atom 1.0
     * of the latest postings.
     *
     * @param length the maximal number of items returned
     * @return the Atom feed
     */
    @GET
    @Path("/atom/")
    public AtomFeed getAtom10(
        @QueryParam("length") @DefaultValue("64") int length) {

        List<AtomEntry> entries = new ArrayList<>();
        for (var it : service.listNewest(length)) {
            entries.add(AtomEntry.builder()
                .title(it.getSubject())
                .summary(clip(it.getBody(), 70))
                .content(clip(it.getBody(), 150))
                .links(List.of(
                    AtomLink.builder()
                        .href(URL_PREFIX + "/id/" + it.getId())
                        .build()))
                .updated(
                    DateUtils.formatRfc3339(it.getDate()))
                .build());
        }
        return AtomFeed.builder()
            .title("ctan-ann")
            .subtitle("Announcements of the Comprehensive TeX Archive Network")
            .links(List.of(AtomLink.builder()
                .rel("self")
                .href("https://ctan.org/ctan-ann/rss")
                .build(),
                AtomLink.builder()
                    .href("https://ctan.org")
                    .build()))
            .updated(DateUtils.formatRfc3339(Instant.now()))
            .id("urn:uuid:" + UUID.randomUUID().toString())
            .icon("https://ctan.org/assets/favicon/android-chrome-48x48.png")
            .generator(AtomGenerator.builder()
                .uri(
                    "https://gitlab.com/comprehensive-tex-archive-network/ctan-site")
                .version(version)
                .value("CTAN site")
                .build())
            .entries(entries)
            .build();
    }

    /**
     * The method <code>getAtom10ByPkg</code> provides means to retrieve the
     * Atom 1.0 of the latest postings.
     *
     * @param pkg the CTAN name of the package
     * @param length the maximal number of items returned
     * @return the Atom feed
     */
    @GET
    @Path("/atom/{pkg}.xml")
    public AtomFeed getAtom10ByPkg(
        @NonNull @PathParam("pkg") String pkg,
        @QueryParam("length") @DefaultValue("64") int length) {

        List<AtomEntry> entries = new ArrayList<>();
        for (var it : service.listNewest(pkg, length)) {
            entries.add(AtomEntry.builder()
                .title(it.getSubject())
                .summary(clip(it.getBody(), 70))
                .content(clip(it.getBody(), 150))
                .links(List.of(
                    AtomLink.builder()
                        .href(URL_PREFIX + "/id/" + it.getId())
                        .build()))
                .updated(
                    DateUtils.formatRfc3339(it.getDate()))
                .build());
        }
        return AtomFeed.builder()
            .title("ctan-ann")
            .subtitle("Announcements of the Comprehensive TeX Archive Network")
            .links(List.of(AtomLink.builder()
                .rel("self")
                .href("https://ctan.org/ctan-ann/atom/" + pkg + ".xml")
                .build(),
                AtomLink.builder()
                    .href("https://ctan.org")
                    .build()))
            .updated(DateUtils.formatRfc3339(Instant.now()))
            .id("urn:uuid:" + UUID.randomUUID().toString())
            .icon("https://ctan.org/assets/favicon/android-chrome-48x48.png")
            .generator(AtomGenerator.builder()
                .uri(
                    "https://gitlab.com/comprehensive-tex-archive-network/ctan-site")
                .version(version)
                .value("CTAN site")
                .build())
            .entries(entries)
            .build();
    }
}