Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ public void generateEspiCompliantId(EspiIdGeneratorService idGeneratorService) {
*/
public void ensureSelfLink() {
if (selfLink == null) {
selfLink = new LinkType("self", generateDefaultSelfHref());
// LinkType(href, rel): these arguments were swapped, storing href="self" (#247)
selfLink = new LinkType(generateDefaultSelfHref(), "self");
}
}

Expand All @@ -199,7 +200,7 @@ public void ensureSelfLink() {
*/
public void ensureUpLink() {
if (upLink == null) {
upLink = new LinkType("up", generateDefaultUpHref());
upLink = new LinkType(generateDefaultUpHref(), "up");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
package org.greenbuttonalliance.espi.common.dto.atom;

import jakarta.xml.bind.annotation.*;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import lombok.AllArgsConstructor;
import org.greenbuttonalliance.espi.common.utils.OffsetDateTimeAdapter;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
Expand Down Expand Up @@ -55,16 +57,24 @@ public class AtomFeedDto {
@XmlElement(name = "title")
private String title;

// Without the adapter JAXB cannot map OffsetDateTime and writes an empty element (#247).
@XmlElement(name = "published")
@XmlJavaTypeAdapter(OffsetDateTimeAdapter.class)
private OffsetDateTime published;

@XmlElement(name = "updated")
@XmlJavaTypeAdapter(OffsetDateTimeAdapter.class)
private OffsetDateTime updated;

@XmlElement(name = "link")
private List<LinkDto> links;

@XmlElement(name = "entry")
/**
* Entries are written through their own root element ({@code UsageAtomEntryDto} or
* {@code CustomerAtomEntryDto}, both {@code atom:entry}). A plain {@code @XmlElement} on the abstract
* {@link AtomEntryDto} made JAXB add {@code xsi:type="atom:UsageAtomEntry"} to every entry (#247).
*/
@XmlElementRef
private List<AtomEntryDto> entries;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
@AllArgsConstructor
public class LinkDto {

// DO NOT REORDER these three fields. The GBA CMD certification test requires every atom:link to be
// written as rel=, href=, type= in that sequence, and JAXB writes attributes in field declaration
// order (@XmlType propOrder does not apply to attributes). Guarded by
// AtomFeedExportTest#linkAttributesAreRelHrefType.

@XmlAttribute(name = "rel")
private String rel;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
package org.greenbuttonalliance.espi.common.repositories.usage;

import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -41,6 +43,10 @@ public interface AuthorizationRepository extends JpaRepository<AuthorizationEnti
@Query("SELECT a.id FROM AuthorizationEntity a WHERE a.applicationInformation.id = :applicationInformationId")
List<UUID> findAllIdsByApplicationInformationId(@Param("applicationInformationId") UUID applicationInformationId);

/** Authorizations granted to one Third Party application, by its OAuth client_id (row-level admin scope). */
@Query("SELECT a FROM AuthorizationEntity a WHERE a.applicationInformation.clientId = :clientId")
Page<AuthorizationEntity> findAllByApplicationClientId(@Param("clientId") String clientId, Pageable pageable);

Optional<AuthorizationEntity> findByState(String state);

/** Back-channel idempotency lookup (#202): the Authorization provisioned for an AS grant. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ public ApplicationInformationEntity findByDataCustodianClientId(

void deleteById(UUID id);

void export(List<ApplicationInformationEntity> entities, OutputStream outputStream);
/**
* Exports the entities as an Atom feed (#247).
*
* @param feedHref absolute URL of the feed (its self link, and the source of its UUID v5 id)
*/
void export(String feedHref, List<ApplicationInformationEntity> entities, OutputStream outputStream);

void export(ApplicationInformationEntity entity, OutputStream outputStream);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,27 @@
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import jakarta.xml.bind.annotation.XmlRootElement;
import lombok.extern.slf4j.Slf4j;
import org.greenbuttonalliance.espi.common.domain.common.IdentifiedObject;
import org.greenbuttonalliance.espi.common.domain.common.LinkType;
import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
import org.greenbuttonalliance.espi.common.dto.atom.LinkDto;
import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto;

import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;

/**
* Abstract base class for domain-specific XML export services.
Expand Down Expand Up @@ -161,6 +177,12 @@ protected Marshaller createMarshaller() throws JAXBException {
* @throws RuntimeException if marshalling fails
*/
public void exportDto(Object dto, OutputStream stream) {
if (dto instanceof Collection<?>) {
// A bare collection has no XML root element; JAXB fails with an obscure
// "ImmutableCollections$ListN ... not known to this context" (#247).
throw new IllegalArgumentException("exportDto cannot marshal a " + dto.getClass().getSimpleName()
+ "; wrap collections in an Atom feed with exportFeed(...)");
}
try {
Marshaller marshaller = createMarshaller();
marshaller.marshal(dto, stream);
Expand Down Expand Up @@ -196,4 +218,105 @@ public void exportDtoWithHeader(Object dto, OutputStream stream) {
throw new RuntimeException("Failed to export DTO with header", e);
}
}

/**
* Exports a collection of ESPI resources as an Atom {@code <feed>} (#247).
*
* @param feedHref absolute URL of the feed (its self link, and the source of its UUID v5 id)
* @param title feed title
* @param entities the resources, in feed order; may be empty (an empty feed is still valid)
* @param toContent maps an entity to the ESPI DTO carried in its entry's {@code <content>}
* @param stream the output stream to write XML to
* @param <E> entity type
* @see #buildFeed(String, String, List, Function)
*/
public <E extends IdentifiedObject> void exportFeed(String feedHref, String title, List<E> entities,
Function<? super E, ?> toContent, OutputStream stream) {
exportDto(buildFeed(feedHref, title, entities, toContent), stream);
}

/**
* Builds an Atom feed whose entries carry the ESPI resources, per REQ.21:
* <ul>
* <li>feed: {@code urn:uuid} id (UUID v5 of {@code feedHref}), title, published/updated (now, UTC),
* {@code self} link of type {@code espi-feed/<Resource>};</li>
* <li>entry: {@code urn:uuid:<entity id>}, title (the entity description, else the resource name),
* the entity's published/updated, {@code self} ({@code espi-entry/<Resource>}) and {@code up}
* ({@code espi-feed/<Resource>}) links, and the mapped DTO as {@code <content>}.</li>
* </ul>
* Stored self/up links are used when they are absolute URLs; otherwise they are derived from
* {@code feedHref} ({@code <feedHref>/<id>} and {@code feedHref}).
*/
public <E extends IdentifiedObject> AtomFeedDto buildFeed(String feedHref, String title, List<E> entities,
Function<? super E, ?> toContent) {
Objects.requireNonNull(feedHref, "feedHref");
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS);

List<AtomEntryDto> entries = new ArrayList<>(entities.size());
String resource = null;
for (E entity : entities) {
Object content = toContent.apply(entity);
String entryResource = resourceName(content);
resource = resource == null ? entryResource : resource;

String selfHref = href(entity.getSelfLink(), feedHref + "/" + entity.getId());
String upHref = href(entity.getUpLink(), feedHref);
List<LinkDto> links = List.of(
new LinkDto("self", selfHref, "espi-entry/" + entryResource),
new LinkDto("up", upHref, "espi-feed/" + entryResource));
String entryTitle = entity.getDescription() != null && !entity.getDescription().isBlank()
? entity.getDescription() : entryResource;
entries.add(newEntry("urn:uuid:" + entity.getId(), entryTitle,
utc(entity.getPublished(), entity.getCreated(), now),
utc(entity.getUpdated(), entity.getCreated(), now),
links, content));
}

AtomFeedDto feed = new AtomFeedDto();
feed.setId("urn:uuid:" + ID_GENERATOR.generateEspiId(feedHref));
feed.setTitle(title);
feed.setPublished(now);
feed.setUpdated(now);
feed.setLinks(List.of(new LinkDto("self", feedHref,
resource != null ? "espi-feed/" + resource : null)));
feed.setEntries(entries);
return feed;
}

/**
* Creates the Atom entry for one resource. Usage-domain exporters use {@link UsageAtomEntryDto};
* customer-domain exporters override this to return {@code CustomerAtomEntryDto}, so the entry type
* matches the one registered in their JAXBContext.
*/
protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated,
List<LinkDto> links, Object content) {
return new UsageAtomEntryDto(id, title, published, updated, links, content);
}

private static final EspiIdGeneratorService ID_GENERATOR = new EspiIdGeneratorService();

/** The ESPI resource name: the DTO's {@code @XmlRootElement} name, else its class name without "Dto". */
static String resourceName(Object content) {
XmlRootElement root = content.getClass().getAnnotation(XmlRootElement.class);
if (root != null && !root.name().isBlank() && !"##default".equals(root.name())) {
return root.name();
}
String simple = content.getClass().getSimpleName();
return simple.endsWith("Dto") ? simple.substring(0, simple.length() - 3) : simple;
}

/**
* A stored link is used only when it is an absolute http(s) URL. Default links generated by
* {@link IdentifiedObject#ensureSelfLink()} are host- and context-less paths, and older rows may hold
* {@code href="self"} (swapped arguments, fixed in #247), so those fall back to the derived href.
*/
private static String href(LinkType link, String fallback) {
String href = link != null ? link.getHref() : null;
return href != null && (href.startsWith("https://") || href.startsWith("http://")) ? href : fallback;
}

private static OffsetDateTime utc(LocalDateTime value, LocalDateTime fallback, OffsetDateTime now) {
LocalDateTime chosen = value != null ? value : fallback;
return chosen != null ? chosen.atOffset(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) : now;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@ public void deleteById(UUID id) {
}

@Override
public void export(List<ApplicationInformationEntity> entities, OutputStream outputStream) {
List<ApplicationInformationDto> dtos = entities.stream()
.map(applicationInformationMapper::toDto)
.toList();
applicationInformationExportService.exportDto(dtos, outputStream);
public void export(String feedHref, List<ApplicationInformationEntity> entities, OutputStream outputStream) {
applicationInformationExportService.exportFeed(feedHref, "ApplicationInformation Feed", entities,
applicationInformationMapper::toDto, outputStream);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@

import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.atom.LinkDto;
import org.greenbuttonalliance.espi.common.service.BaseExportService;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;

/**
Expand Down Expand Up @@ -69,4 +74,11 @@ protected JAXBContext createJAXBContext() throws JAXBException {
protected Set<String> getDomainNamespaces() {
return Set.of(ATOM_NAMESPACE, CUSTOMER_NAMESPACE);
}

/** Customer-domain feeds use {@link CustomerAtomEntryDto}, the entry type registered above. */
@Override
protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated,
List<LinkDto> links, Object content) {
return new CustomerAtomEntryDto(id, title, published, updated, links, content);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@

import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.atom.LinkDto;
import org.greenbuttonalliance.espi.common.service.BaseExportService;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;

/**
Expand Down Expand Up @@ -104,4 +109,11 @@ protected JAXBContext createJAXBContext() throws JAXBException {
protected Set<String> getDomainNamespaces() {
return Set.of(ATOM_NAMESPACE, CUSTOMER_NAMESPACE);
}

/** Customer-domain feeds use {@link CustomerAtomEntryDto}, the entry type registered above. */
@Override
protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated,
List<LinkDto> links, Object content) {
return new CustomerAtomEntryDto(id, title, published, updated, links, content);
}
}
Loading
Loading