diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java
index e46b98d0..6ea48c43 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java
@@ -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");
}
}
@@ -199,7 +200,7 @@ public void ensureSelfLink() {
*/
public void ensureUpLink() {
if (upLink == null) {
- upLink = new LinkType("up", generateDefaultUpHref());
+ upLink = new LinkType(generateDefaultUpHref(), "up");
}
}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java
index 8ec8cbe1..0be283cf 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java
@@ -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;
@@ -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 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 entries;
/**
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java
index 1b0d0437..73ef37d2 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java
@@ -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;
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java
index 502bf69d..b3848536 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java
@@ -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;
@@ -41,6 +43,10 @@ public interface AuthorizationRepository extends JpaRepository 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 findAllByApplicationClientId(@Param("clientId") String clientId, Pageable pageable);
+
Optional findByState(String state);
/** Back-channel idempotency lookup (#202): the Authorization provisioned for an AS grant. */
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java
index b454ea47..8754ada2 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java
@@ -64,7 +64,12 @@ public ApplicationInformationEntity findByDataCustodianClientId(
void deleteById(UUID id);
- void export(List 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 entities, OutputStream outputStream);
void export(ApplicationInformationEntity entity, OutputStream outputStream);
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java
index f955de73..41af22ca 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java
@@ -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.
@@ -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);
@@ -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 } (#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 }
+ * @param stream the output stream to write XML to
+ * @param entity type
+ * @see #buildFeed(String, String, List, Function)
+ */
+ public void exportFeed(String feedHref, String title, List 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:
+ *
+ * - feed: {@code urn:uuid} id (UUID v5 of {@code feedHref}), title, published/updated (now, UTC),
+ * {@code self} link of type {@code espi-feed/};
+ * - entry: {@code urn:uuid:}, title (the entity description, else the resource name),
+ * the entity's published/updated, {@code self} ({@code espi-entry/}) and {@code up}
+ * ({@code espi-feed/}) links, and the mapped DTO as {@code }.
+ *
+ * Stored self/up links are used when they are absolute URLs; otherwise they are derived from
+ * {@code feedHref} ({@code /} and {@code feedHref}).
+ */
+ public AtomFeedDto buildFeed(String feedHref, String title, List entities,
+ Function super E, ?> toContent) {
+ Objects.requireNonNull(feedHref, "feedHref");
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS);
+
+ List 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 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 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;
+ }
}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java
index bd7c5aca..1b954f9e 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java
@@ -117,11 +117,9 @@ public void deleteById(UUID id) {
}
@Override
- public void export(List entities, OutputStream outputStream) {
- List dtos = entities.stream()
- .map(applicationInformationMapper::toDto)
- .toList();
- applicationInformationExportService.exportDto(dtos, outputStream);
+ public void export(String feedHref, List entities, OutputStream outputStream) {
+ applicationInformationExportService.exportFeed(feedHref, "ApplicationInformation Feed", entities,
+ applicationInformationMapper::toDto, outputStream);
}
@Override
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java
index e99a7bba..fe41680a 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java
@@ -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;
/**
@@ -69,4 +74,11 @@ protected JAXBContext createJAXBContext() throws JAXBException {
protected Set 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 links, Object content) {
+ return new CustomerAtomEntryDto(id, title, published, updated, links, content);
+ }
}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java
index 6bac02ee..2c5705ee 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java
@@ -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;
/**
@@ -104,4 +109,11 @@ protected JAXBContext createJAXBContext() throws JAXBException {
protected Set 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 links, Object content) {
+ return new CustomerAtomEntryDto(id, title, published, updated, links, content);
+ }
}
\ No newline at end of file
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java
new file mode 100644
index 00000000..f044f791
--- /dev/null
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2026 Green Button Alliance, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.greenbuttonalliance.espi.common.service;
+
+import org.greenbuttonalliance.espi.common.domain.common.LinkType;
+import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity;
+import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity;
+import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto;
+import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto;
+import org.greenbuttonalliance.espi.common.service.impl.CustomerExportService;
+import org.greenbuttonalliance.espi.common.service.impl.UsageExportService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * #247: collections of ESPI resources are exported as an Atom feed, using the real exporters
+ * (the controller tests mock them, which is how the feed endpoints' 500s went unnoticed).
+ */
+@DisplayName("Atom feed export (#247)")
+class AtomFeedExportTest {
+
+ private static final String FEED = "https://dc.example/DataCustodian/espi/1_1/resource/UsagePoint";
+
+ private static UsageExportService usage;
+ private static CustomerExportService customer;
+
+ @BeforeAll
+ static void initExporters() {
+ usage = new UsageExportService();
+ usage.init();
+ customer = new CustomerExportService();
+ customer.init();
+ }
+
+ @Test
+ @DisplayName("a list of usage points becomes a feed with one entry per resource")
+ void usagePointsBecomeAFeed() {
+ UUID first = UUID.fromString("48c2a019-5598-5e16-b0f9-49e4ff27f5fb");
+ UUID second = UUID.fromString("5d6f1b42-0a6e-5f0e-9c3e-2a1d7b8c9e01");
+ UsagePointEntity stored = usagePoint(first, "Front of house meter");
+ stored.setSelfLink(new LinkType(FEED + "/" + first, "self"));
+ stored.setPublished(LocalDateTime.of(2026, 1, 2, 3, 4, 5));
+ UsagePointEntity derived = usagePoint(second, null);
+
+ String xml = export(usage, List.of(stored, derived));
+ System.out.println(xml);
+
+ String feedId = usage.buildFeed(FEED, "t", List.of(), u -> u).getId();
+ assertThat(xml)
+ .contains("" + feedId + "", "UsagePoint Feed")
+ .contains("rel=\"self\" href=\"" + FEED + "\" type=\"espi-feed/UsagePoint\"")
+ .contains("urn:uuid:" + first + "", "Front of house meter")
+ .contains("href=\"" + FEED + "/" + first + "\" type=\"espi-entry/UsagePoint\"")
+ .contains("urn:uuid:" + second + "", "UsagePoint")
+ .contains("href=\"" + FEED + "/" + second + "\"")
+ .contains("rel=\"up\" href=\"" + FEED + "\" type=\"espi-feed/UsagePoint\"")
+ .contains("2026-01-02T03:04:05Z")
+ .contains("", "");
+ assertThat(feedId).matches("urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}");
+ assertThat(count(xml, "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")
+ .doesNotContain(" new CustomerDto(), out);
+ String xml = out.toString(StandardCharsets.UTF_8);
+ System.out.println(xml);
+
+ assertThat(xml)
+ .contains(" links = java.util.regex.Pattern.compile("]*/>").matcher(xml)
+ .results().map(java.util.regex.MatchResult::group).toList();
+
+ // 2 feeds' self links + 1 entry's self and up links
+ assertThat(links).hasSize(4)
+ .allSatisfy(link -> assertThat(link)
+ .matches(""));
+ assertThat(links).filteredOn(link -> link.contains("type=")).hasSize(3);
+ }
+
+ @Test
+ @DisplayName("exportDto rejects a bare collection with a pointer to exportFeed")
+ void exportDtoRejectsCollections() {
+ assertThatThrownBy(() -> usage.exportDto(List.of(new UsagePointDto()), new ByteArrayOutputStream()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exportFeed");
+ }
+
+ private static UsagePointEntity usagePoint(UUID id, String description) {
+ UsagePointEntity entity = new UsagePointEntity();
+ entity.setId(id);
+ entity.setDescription(description);
+ return entity;
+ }
+
+ private static String export(UsageExportService exporter, List entities) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ exporter.exportFeed(FEED, "UsagePoint Feed", entities, e -> new UsagePointDto(), out);
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ private static int count(String haystack, String needle) {
+ return haystack.split(needle, -1).length - 1;
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java
new file mode 100644
index 00000000..c0636fc6
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026 Green Button Alliance, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.greenbuttonalliance.espi.datacustodian.web.api;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+
+import java.util.Optional;
+
+/**
+ * Row-level scope for the ESPI admin resources (ApplicationInformation, Authorization).
+ *
+ * {@code SCOPE_DataCustodian_Admin_Access} is the Data Custodian's own administrator and sees every
+ * record. {@code SCOPE_ThirdParty_Admin_Access} is a Third Party's client_credentials token: it sees only
+ * the records of its own application, matched on the token's {@code client_id}. Without that check any
+ * Third Party admin could read every other Third Party's Authorizations and ApplicationInformation,
+ * including their {@code client_secret} and {@code registration_access_token}.
+ *
+ * Fails closed: a caller that is not a DataCustodian admin and has no {@code client_id} sees nothing.
+ */
+final class AdminCaller {
+
+ static final String DATA_CUSTODIAN_ADMIN = "SCOPE_DataCustodian_Admin_Access";
+
+ private AdminCaller() {
+ }
+
+ static boolean isDataCustodianAdmin(Authentication authentication) {
+ return authentication != null && authentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .anyMatch(DATA_CUSTODIAN_ADMIN::equals);
+ }
+
+ /** The OAuth client the token was issued to, from the introspection response's {@code client_id}. */
+ static Optional clientId(Authentication authentication) {
+ if (authentication != null && authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal) {
+ Object clientId = principal.getAttribute("client_id");
+ if (clientId != null && !clientId.toString().isBlank()) {
+ return Optional.of(clientId.toString());
+ }
+ }
+ return Optional.empty();
+ }
+
+ /** Whether the caller may see a record owned by the application with {@code ownerClientId}. */
+ static boolean canSee(Authentication authentication, String ownerClientId) {
+ if (isDataCustodianAdmin(authentication)) {
+ return true;
+ }
+ return ownerClientId != null && clientId(authentication).map(ownerClientId::equals).orElse(false);
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java
index 8bb73d6e..79261951 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java
@@ -35,8 +35,10 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -75,11 +77,16 @@ public class ApplicationInformationRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden")
})
@PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_ThirdParty_Admin_Access')")
- public ResponseEntity getAllApplicationInformation() {
- List entities = applicationInformationService.findAll();
+ public ResponseEntity getAllApplicationInformation(Authentication authentication) {
+ // Row-level scope (AdminCaller): ApplicationInformation carries client_secret and
+ // registration_access_token, so a Third Party admin must only ever see its own application.
+ List entities = applicationInformationService.findAll().stream()
+ .filter(entity -> AdminCaller.canSee(authentication, entity.getClientId()))
+ .toList();
ByteArrayOutputStream out = new ByteArrayOutputStream();
- applicationInformationService.export(entities, out);
+ applicationInformationService.export(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ entities, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
@@ -105,10 +112,12 @@ public ResponseEntity getAllApplicationInformation() {
@PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_ThirdParty_Admin_Access')")
public ResponseEntity getApplicationInformation(
@Parameter(description = "Unique identifier of the ApplicationInformation", required = true)
- @PathVariable UUID applicationInformationId) {
+ @PathVariable UUID applicationInformationId,
+ Authentication authentication) {
ApplicationInformationEntity entity = applicationInformationService.findById(applicationInformationId);
- if (entity == null) {
+ // Another application's record is reported as not found, so its existence is not disclosed.
+ if (entity == null || !AdminCaller.canSee(authentication, entity.getClientId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "ApplicationInformation not found");
}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java
index c5444b08..4339b62c 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java
@@ -26,52 +26,59 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
+import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.usage.AuthorizationDto;
+import org.greenbuttonalliance.espi.common.mapper.usage.AuthorizationMapper;
+import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository;
+import org.greenbuttonalliance.espi.common.service.impl.UsageExportService;
import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.UUID;
/**
- * Modern REST Controller for ESPI Authorization resources.
- *
- * This controller implements the NAESB ESPI 1.0 REST API for OAuth2 Authorizations,
- * using modern Spring Boot 4.x patterns with DTOs and MapStruct mappers.
- *
- * Supported endpoints:
- * - GET /espi/1_1/resource/Authorization - List all authorizations
- * - GET /espi/1_1/resource/Authorization/{authorizationId} - Get specific authorization
+ * ESPI Authorization resources (admin access only).
+ *
+ * - {@code GET /espi/1_1/resource/Authorization}: Atom feed of Authorizations; an empty feed when
+ * there are none.
+ * - {@code GET /espi/1_1/resource/Authorization/{authorizationId}}: one Authorization as an Atom
+ * entry, or 404.
+ *
+ * Both previously returned {@code null}, i.e. HTTP 200 with an empty body (#247). Token values,
+ * authorization codes and state never appear in the XML: those {@link AuthorizationDto} fields are
+ * {@code @XmlTransient}.
+ * Row-level scope ({@link AdminCaller}): a {@code DataCustodian_Admin_Access} caller sees every
+ * Authorization; a {@code ThirdParty_Admin_Access} caller only those granted to its own application.
*/
-
-//todo - complete implementation
@RestController
@RequestMapping("/espi/1_1/resource")
@Tag(name = "Authorizations", description = "OAuth2 Authorization Management API")
@SecurityRequirement(name = "oauth2")
+@RequiredArgsConstructor
public class AuthorizationController {
-// private final AuthorizationRepository authorizationRepository;
-// private final AuthorizationMapper authorizationMapper;
-//
-// public AuthorizationController(AuthorizationRepository authorizationRepository, AuthorizationMapper authorizationMapper) {
-// this.authorizationRepository = authorizationRepository;
-// this.authorizationMapper = authorizationMapper;
-// }
+ private final AuthorizationRepository authorizationRepository;
+ private final AuthorizationMapper authorizationMapper;
+ private final UsageExportService usageExportService;
/**
* Get all Authorizations (admin access only).
- * This endpoint is restricted to DataCustodian administrators.
*/
@GetMapping(value = "/Authorization", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE})
@Operation(
summary = "Get all Authorizations",
- description = "Retrieve all OAuth2 Authorizations (admin access only)",
+ description = "Retrieve all OAuth2 Authorizations as an Atom feed (admin access only)",
responses = {
@ApiResponse(responseCode = "200", description = "Authorizations retrieved successfully",
content = @Content(schema = @Schema(implementation = AuthorizationDto.class))),
@@ -80,26 +87,32 @@ public class AuthorizationController {
}
)
@PreAuthorize("hasAnyAuthority('SCOPE_DataCustodian_Admin_Access', 'SCOPE_ThirdParty_Admin_Access')")
- public ResponseEntity> getAllAuthorizations(
+ public ResponseEntity getAllAuthorizations(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@Parameter(description = "Offset for pagination", example = "0")
@RequestParam(defaultValue = "0") int offset,
Authentication authentication) {
-
- Pageable pageable = PageRequest.of(offset / limit, limit);
-// List authorizationEntities = authorizationRepository.findAll(pageable).getContent();
-// List authorizations = authorizationEntities.stream()
-// .map(authorizationMapper::toDto)
-// .toList();
- return null;
- // return ResponseEntity.ok(authorizations);
+ // Row-level scope: a DataCustodian admin sees all Authorizations; a Third Party admin only those
+ // of its own application (token client_id); anyone else nothing. See AdminCaller.
+ PageRequest page = PageRequest.of(offset / limit, limit);
+ List authorizations = AdminCaller.isDataCustodianAdmin(authentication)
+ ? authorizationRepository.findAll(page).getContent()
+ : AdminCaller.clientId(authentication)
+ .map(clientId -> authorizationRepository.findAllByApplicationClientId(clientId, page).getContent())
+ .orElse(List.of());
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "Authorization Feed", authorizations, authorizationMapper::toDto, out);
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_XML)
+ .body(out.toByteArray());
}
/**
- * Get specific Authorization by ID.
- * Access restricted based on ownership and scope.
+ * Get specific Authorization by ID (admin access only).
*/
@GetMapping(value = "/Authorization/{authorizationId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE})
@Operation(
@@ -114,15 +127,29 @@ public ResponseEntity> getAllAuthorizations(
}
)
@PreAuthorize("hasAnyAuthority('SCOPE_DataCustodian_Admin_Access', 'SCOPE_ThirdParty_Admin_Access')")
- public ResponseEntity getAuthorization(
+ public ResponseEntity getAuthorization(
@Parameter(description = "Unique identifier of the Authorization", required = true)
@PathVariable UUID authorizationId,
Authentication authentication) {
- return null;
-// return authorizationRepository.findById(authorizationId)
-// .map(authorizationMapper::toDto)
-// .map(authorization -> ResponseEntity.ok(authorization))
-// .orElse(ResponseEntity.notFound().build());
+ // Another application's Authorization is reported as not found, so its existence is not disclosed.
+ AuthorizationEntity authorization = authorizationRepository.findById(authorizationId)
+ .filter(a -> AdminCaller.canSee(authentication,
+ a.getApplicationInformation() != null ? a.getApplicationInformation().getClientId() : null))
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
+ "Authorization not found for id: " + authorizationId));
+
+ String self = ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString();
+ String up = self.substring(0, self.lastIndexOf('/'));
+ // One entry, built like a feed entry so it carries the same id, links and timestamps.
+ UsageAtomEntryDto entry = (UsageAtomEntryDto) usageExportService
+ .buildFeed(up, "Authorization", List.of(authorization), authorizationMapper::toDto)
+ .getEntries().getFirst();
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ usageExportService.exportDto(entry, out);
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_XML)
+ .body(out.toByteArray());
}
-}
\ No newline at end of file
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
index 231aa7a4..7873706b 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
@@ -37,6 +37,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -81,12 +82,10 @@ public ResponseEntity getCustomerAccountCollection(
@Parameter(description = "Offset for pagination", example = "0")
@RequestParam(defaultValue = "0") int offset) {
- List dtos = customerAccountRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(customerAccountMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- customerAccountExportService.exportDto(dtos, out);
+ customerAccountExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "CustomerAccount Feed", customerAccountRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ customerAccountMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
index 132b3092..82256b4d 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
@@ -37,6 +37,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -81,12 +82,10 @@ public ResponseEntity getCustomerCollection(
@Parameter(description = "Offset for pagination", example = "0")
@RequestParam(defaultValue = "0") int offset) {
- List dtos = customerRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(customerMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- customerExportService.exportDto(dtos, out);
+ customerExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "Customer Feed", customerRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ customerMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
index 102e2828..1c65a176 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
@@ -38,6 +38,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -87,12 +88,11 @@ public ResponseEntity getElectricPowerQualitySummaryCollection(
@Parameter(description = "Offset for pagination", example = "0")
@RequestParam(defaultValue = "0") int offset) {
- List summaries = electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(electricPowerQualitySummaryMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- electricPowerQualitySummaryExportService.exportDto(summaries, out);
+ electricPowerQualitySummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "ElectricPowerQualitySummary Feed",
+ electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ electricPowerQualitySummaryMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
@@ -156,12 +156,11 @@ public ResponseEntity getSubscriptionElectricPowerQualitySummaries(
// TODO: Implement subscription-based filtering when subscription relationship is available
// For now, return all summaries with pagination as a temporary solution
- List summaries = electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(electricPowerQualitySummaryMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- electricPowerQualitySummaryExportService.exportDto(summaries, out);
+ electricPowerQualitySummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "ElectricPowerQualitySummary Feed",
+ electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ electricPowerQualitySummaryMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
index 4b6ec409..4b0b1d74 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
@@ -38,6 +38,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -81,12 +82,10 @@ public ResponseEntity getIntervalBlockCollection(
@RequestParam(defaultValue = "0") int offset,
Authentication authentication) {
- List dtos = intervalBlockRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(intervalBlockMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- intervalBlockExportService.exportDto(dtos, out);
+ intervalBlockExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "IntervalBlock Feed", intervalBlockRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ intervalBlockMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
@@ -148,12 +147,10 @@ public ResponseEntity getSubscriptionIntervalBlocks(
Authentication authentication) {
// Use the specialized repository method for hierarchical access
- List dtos = intervalBlockRepository.findAllByMeterReadingId(meterReadingId).stream()
- .map(intervalBlockMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- intervalBlockExportService.exportDto(dtos, out);
+ intervalBlockExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "IntervalBlock Feed", intervalBlockRepository.findAllByMeterReadingId(meterReadingId),
+ intervalBlockMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
index ff8e1e58..0d2e6a4a 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
@@ -38,6 +38,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -86,12 +87,10 @@ public ResponseEntity getAllMeterReadings(
@RequestParam(defaultValue = "0") int offset,
Authentication authentication) {
- List meterReadings = meterReadingRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(meterReadingMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- meterReadingExportService.exportDto(meterReadings, out);
+ meterReadingExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "MeterReading Feed", meterReadingRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ meterReadingMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
index a47f3200..539f56ec 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
@@ -38,6 +38,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -84,12 +85,10 @@ public ResponseEntity getReadingTypeCollection(
@RequestParam(defaultValue = "0") int offset,
Authentication authentication) {
- List readingTypes = readingTypeRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(readingTypeMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- readingTypeExportService.exportDto(readingTypes, out);
+ readingTypeExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "ReadingType Feed", readingTypeRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ readingTypeMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
index b35b5924..e6e2049b 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
@@ -37,6 +37,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -88,12 +89,10 @@ public UsagePointController(UsagePointRepository usagePointRepository, UsagePoin
public ResponseEntity getAllUsagePoints(@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset) {
- List usagePoints = usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(usagePointMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- usageExportService.exportDto(usagePoints, out);
+ usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "UsagePoint Feed", usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ usagePointMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
@@ -168,12 +167,10 @@ public ResponseEntity getSubscriptionUsagePoints(
// TODO: Implement subscription-based filtering when subscription relationship is available
// For now, return all usage points with pagination as a temporary solution
- List usagePoints = usagePointRepository.findAll( PageRequest.of(offset, limit)).getContent().stream()
- .map(usagePointMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- usageExportService.exportDto(usagePoints, out);
+ usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "UsagePoint Feed", usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ usagePointMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
index a686556e..6b8068bc 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
@@ -38,6 +38,7 @@
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.ByteArrayOutputStream;
import java.util.List;
@@ -85,12 +86,10 @@ public ResponseEntity getUsageSummaryCollection(
@RequestParam(defaultValue = "0") int offset,
Authentication authentication) {
- List usageSummaries = usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(usageSummaryMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- usageSummaryExportService.exportDto(usageSummaries, out);
+ usageSummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "UsageSummary Feed", usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ usageSummaryMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
@@ -151,12 +150,10 @@ public ResponseEntity getSubscriptionUsageSummaries(
Authentication authentication) {
// TODO: Implement subscription and usage point based filtering when relationships are available
- List usageSummaries = usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream()
- .map(usageSummaryMapper::toDto)
- .toList();
-
ByteArrayOutputStream out = new ByteArrayOutputStream();
- usageSummaryExportService.exportDto(usageSummaries, out);
+ usageSummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(),
+ "UsageSummary Feed", usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(),
+ usageSummaryMapper::toDto, out);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(out.toByteArray());
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java
index b5170503..9ebd4131 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java
@@ -60,6 +60,15 @@
* against another to see the effect of a change. Within a run, filenames are stable per
* method+path (a repeated call overwrites with the latest response). The response body is always
* copied through to the client unchanged.
+ *
+ * Failures are captured too (#247): a response with status 400 or above, or a request that ends
+ * in an exception, writes {@code _.error.txt} next to the XML, with the status, the
+ * request, the full exception chain (for example a JAXB marshalling error) and the response body if
+ * any. The exception is still rethrown, so error handling is unchanged.
+ *
+ * A relative {@code espi.dev.xml-capture.dir} is resolved against the repository root (the
+ * nearest directory containing {@code .git}), so captures land in the same place whether the
+ * application is started from the repository root or from the module directory.
*/
@Component
@Profile({"dev-mysql", "dev-postgresql", "local", "dev"})
@@ -79,7 +88,21 @@ public EspiXmlCaptureFilter(
this.enabled = enabled;
// One sub-directory per application run, so successive runs can be compared rather than
// overwriting each other.
- this.runDir = Path.of(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID));
+ this.runDir = resolveCaptureDir(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID));
+ }
+
+ /** A relative capture dir is anchored at the repository root, else at the working directory. */
+ static Path resolveCaptureDir(String captureDir) {
+ Path dir = Path.of(captureDir);
+ if (dir.isAbsolute()) {
+ return dir;
+ }
+ for (Path p = Path.of("").toAbsolutePath(); p != null; p = p.getParent()) {
+ if (Files.exists(p.resolve(".git"))) {
+ return p.resolve(dir);
+ }
+ }
+ return dir.toAbsolutePath();
}
@Override
@@ -91,10 +114,15 @@ protected boolean shouldNotFilter(HttpServletRequest request) {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
+ Throwable failure = null;
try {
filterChain.doFilter(request, wrapper);
+ } catch (ServletException | IOException | RuntimeException | Error e) {
+ failure = e;
+ throw e;
} finally {
capture(request, wrapper);
+ captureFailure(request, wrapper, failure);
wrapper.copyBodyToResponse();
}
}
@@ -102,23 +130,61 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
private void capture(HttpServletRequest request, ContentCachingResponseWrapper wrapper) {
byte[] body = wrapper.getContentAsByteArray();
String contentType = wrapper.getContentType();
- if (body.length == 0 || contentType == null
+ if (wrapper.getStatus() >= 400 || body.length == 0 || contentType == null
|| !contentType.toLowerCase().contains("xml")) {
return;
}
+ write(fileName(request, ".xml"), body, "captured ESPI XML");
+ }
+
+ /** Writes {@code _.error.txt} for a 4xx/5xx response or an exception (#247). */
+ private void captureFailure(HttpServletRequest request, ContentCachingResponseWrapper wrapper,
+ Throwable failure) {
+ if (failure == null && wrapper.getStatus() < 400) {
+ return;
+ }
+ StringBuilder report = new StringBuilder()
+ .append("Time: ").append(LocalDateTime.now()).append('\n')
+ .append("Request: ").append(request.getMethod()).append(' ').append(request.getRequestURI())
+ .append(request.getQueryString() != null ? "?" + request.getQueryString() : "").append('\n')
+ .append("Status: ").append(failure != null ? "unhandled exception (the container answers 500)"
+ : String.valueOf(wrapper.getStatus())).append('\n');
+ if (failure != null) {
+ report.append("\nException chain:\n");
+ for (Throwable t = failure; t != null; t = t.getCause() == t ? null : t.getCause()) {
+ report.append(" ").append(t.getClass().getName()).append(": ").append(t.getMessage()).append('\n');
+ }
+ }
+ byte[] body = wrapper.getContentAsByteArray();
+ if (body.length > 0) {
+ report.append("\nResponse body (").append(wrapper.getContentType()).append("):\n")
+ .append(new String(body, java.nio.charset.StandardCharsets.UTF_8)).append('\n');
+ }
+ write(fileName(request, ".error.txt"),
+ report.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "captured ESPI failure");
+ }
+
+ private void write(String fileName, byte[] content, String what) {
try {
Files.createDirectories(runDir);
- Path file = runDir.resolve(fileName(request));
- Files.write(file, body);
- log.info("[dev] captured ESPI XML ({} bytes) -> {}", body.length, file.toAbsolutePath());
+ // The name comes from the request path. fileName() already strips path separators; this
+ // containment check makes it explicit that nothing can be written outside the run directory.
+ Path base = runDir.toAbsolutePath().normalize();
+ Path file = base.resolve(fileName).normalize();
+ if (!file.startsWith(base) || !base.equals(file.getParent())) {
+ log.warn("[dev] ESPI capture skipped: '{}' resolves outside {}", fileName, base);
+ return;
+ }
+ Files.write(file, content);
+ log.info("[dev] {} ({} bytes) -> {}", what, content.length, file.toAbsolutePath());
} catch (IOException e) {
// dev aid only — never let a capture failure affect the response
- log.warn("[dev] ESPI XML capture failed: {}", e.getMessage());
+ log.warn("[dev] ESPI capture failed: {}", e.getMessage());
}
}
/** Stable per-run filename from the request, e.g. {@code GET_UsagePoint_503888.xml}. */
- private String fileName(HttpServletRequest request) {
+ private String fileName(HttpServletRequest request, String suffix) {
String path = request.getRequestURI();
int idx = path.indexOf(RESOURCE_PATH);
String tail = idx >= 0 ? path.substring(idx + RESOURCE_PATH.length()) : path;
@@ -126,6 +192,6 @@ private String fileName(HttpServletRequest request) {
if (safe.isBlank()) {
safe = "root";
}
- return request.getMethod() + "_" + safe + ".xml";
+ return request.getMethod() + "_" + safe + suffix;
}
}
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java
new file mode 100644
index 00000000..7ff95234
--- /dev/null
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2026 Green Button Alliance, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.greenbuttonalliance.espi.datacustodian.web.api;
+
+import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity;
+import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto;
+import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Row-level scope on the ESPI admin resources (AdminCaller): a DataCustodian admin sees every record;
+ * a ThirdParty admin sees only its own application's records (token client_id); a ThirdParty admin
+ * token without client_id sees nothing; another application's record is a 404.
+ */
+@DisplayName("Admin resources: row-level scope by client_id")
+class AdminRowScopeTest extends AbstractControllerMockTest {
+
+ @MockitoBean
+ private AuthorizationRepository authorizationRepository;
+
+ private static final String TP_A = "third_party_a";
+ private static final String TP_B = "third_party_b";
+
+ private static RequestPostProcessor dcAdmin() {
+ return opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_DataCustodian_Admin_Access"))
+ .attributes(a -> a.put("client_id", "data_custodian_admin"));
+ }
+
+ private static RequestPostProcessor tpAdmin(String clientId) {
+ return opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_ThirdParty_Admin_Access"))
+ .attributes(a -> {
+ if (clientId != null) {
+ a.put("client_id", clientId);
+ }
+ });
+ }
+
+ private static ApplicationInformationEntity application(String clientId) {
+ ApplicationInformationEntity app = new ApplicationInformationEntity();
+ app.setId(UUID.nameUUIDFromBytes(clientId.getBytes()));
+ app.setClientId(clientId);
+ return app;
+ }
+
+ @Nested
+ @DisplayName("ApplicationInformation (carries client_secret and registration_access_token)")
+ class ApplicationInformationScope {
+
+ private final ApplicationInformationEntity appA = application(TP_A);
+ private final ApplicationInformationEntity appB = application(TP_B);
+
+ @SuppressWarnings("unchecked")
+ private List exported() {
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(applicationInformationService).export(anyString(), captor.capture(), any());
+ return captor.getValue();
+ }
+
+ @Test
+ @DisplayName("feed: a ThirdParty admin gets only its own application")
+ void thirdPartyAdminFeedIsScoped() throws Exception {
+ when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB));
+
+ mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(tpAdmin(TP_A)))
+ .andExpect(status().isOk());
+
+ assertThat(exported()).containsExactly(appA);
+ }
+
+ @Test
+ @DisplayName("feed: a DataCustodian admin gets every application")
+ void dataCustodianAdminFeedIsUnscoped() throws Exception {
+ when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB));
+
+ mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(dcAdmin()))
+ .andExpect(status().isOk());
+
+ assertThat(exported()).containsExactly(appA, appB);
+ }
+
+ @Test
+ @DisplayName("feed: a ThirdParty admin token without client_id gets nothing")
+ void thirdPartyAdminWithoutClientIdGetsNothing() throws Exception {
+ when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB));
+
+ mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(tpAdmin(null)))
+ .andExpect(status().isOk());
+
+ assertThat(exported()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("entry: another application's record is 404; its own is 200")
+ void thirdPartyAdminEntryIsScoped() throws Exception {
+ when(applicationInformationService.findById(appB.getId())).thenReturn(appB);
+ when(applicationInformationService.findById(appA.getId())).thenReturn(appA);
+
+ mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + appB.getId()).with(tpAdmin(TP_A)))
+ .andExpect(status().isNotFound());
+ mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + appA.getId()).with(tpAdmin(TP_A)))
+ .andExpect(status().isOk());
+
+ verify(applicationInformationService, never()).export(eq(appB), any());
+ }
+ }
+
+ @Nested
+ @DisplayName("Authorization")
+ class AuthorizationScope {
+
+ private AuthorizationEntity authorizationOf(String clientId) {
+ AuthorizationEntity authorization = new AuthorizationEntity();
+ authorization.setId(UUID.randomUUID());
+ authorization.setApplicationInformation(application(clientId));
+ return authorization;
+ }
+
+ @Test
+ @DisplayName("feed: a ThirdParty admin is queried by its own client_id, never findAll")
+ void thirdPartyAdminFeedQueriesByClientId() throws Exception {
+ when(authorizationRepository.findAllByApplicationClientId(eq(TP_A), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(authorizationOf(TP_A))));
+
+ mockMvc.perform(get("/espi/1_1/resource/Authorization").with(tpAdmin(TP_A)))
+ .andExpect(status().isOk());
+
+ verify(authorizationRepository).findAllByApplicationClientId(eq(TP_A), any(Pageable.class));
+ verify(authorizationRepository, never()).findAll(any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("feed: a DataCustodian admin sees every Authorization")
+ void dataCustodianAdminFeedUsesFindAll() throws Exception {
+ when(authorizationRepository.findAll(any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(authorizationOf(TP_A), authorizationOf(TP_B))));
+
+ mockMvc.perform(get("/espi/1_1/resource/Authorization").with(dcAdmin()))
+ .andExpect(status().isOk());
+
+ verify(authorizationRepository).findAll(any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("feed: a ThirdParty admin token without client_id queries nothing")
+ void thirdPartyAdminWithoutClientIdQueriesNothing() throws Exception {
+ mockMvc.perform(get("/espi/1_1/resource/Authorization").with(tpAdmin(null)))
+ .andExpect(status().isOk());
+
+ verify(authorizationRepository, never()).findAll(any(Pageable.class));
+ verify(authorizationRepository, never()).findAllByApplicationClientId(anyString(), any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("entry: another application's Authorization is 404; its own is 200")
+ void thirdPartyAdminEntryIsScoped() throws Exception {
+ AuthorizationEntity foreign = authorizationOf(TP_B);
+ AuthorizationEntity own = authorizationOf(TP_A);
+ when(authorizationRepository.findById(foreign.getId())).thenReturn(Optional.of(foreign));
+ when(authorizationRepository.findById(own.getId())).thenReturn(Optional.of(own));
+ AtomFeedDto oneEntry = new AtomFeedDto();
+ oneEntry.setEntries(List.of(new UsageAtomEntryDto()));
+ when(usageExportService.buildFeed(anyString(), anyString(), any(), any())).thenReturn(oneEntry);
+
+ mockMvc.perform(get("/espi/1_1/resource/Authorization/" + foreign.getId()).with(tpAdmin(TP_A)))
+ .andExpect(status().isNotFound());
+ mockMvc.perform(get("/espi/1_1/resource/Authorization/" + own.getId()).with(tpAdmin(TP_A)))
+ .andExpect(status().isOk());
+ }
+ }
+}
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java
index 61490b9a..c9feae86 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java
@@ -61,7 +61,9 @@ void shouldReturn200ForAdmin() throws Exception {
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML));
- verify(applicationInformationService).export(anyList(), any());
+ // #247: the feed is built from the request URL (its self link and UUID v5 id)
+ verify(applicationInformationService).export(
+ eq("http://localhost/espi/1_1/resource/ApplicationInformation"), anyList(), any());
}
@Test
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java
new file mode 100644
index 00000000..4e77ec68
--- /dev/null
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2026 Green Button Alliance, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.greenbuttonalliance.espi.datacustodian.web.api;
+
+import org.greenbuttonalliance.espi.common.domain.common.ServiceCategory;
+import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity;
+import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * #247: feed (collection) endpoints with real data and the REAL export services. The controller
+ * mock tests stub every exporter, which is how the 500s on every non-empty feed went unnoticed.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Transactional
+@DisplayName("ESPI feed endpoints with real exporters (#247)")
+class FeedEndpointsIntegrationTest {
+
+ private static final String BASE = "http://localhost/espi/1_1/resource";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private UsagePointRepository usagePointRepository;
+
+ @Test
+ @DisplayName("GET /UsagePoint with data returns an Atom feed with one entry per usage point")
+ void usagePointFeedWithData() throws Exception {
+ UUID id = UUID.fromString("48c2a019-5598-5e16-b0f9-49e4ff27f5fb");
+ UsagePointEntity usagePoint = new UsagePointEntity();
+ usagePoint.setId(id);
+ usagePoint.setDescription("Front of house meter");
+ usagePoint.setServiceCategory(ServiceCategory.ELECTRICITY);
+ usagePointRepository.saveAndFlush(usagePoint);
+
+ String xml = mockMvc.perform(get("/espi/1_1/resource/UsagePoint").with(admin()))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ assertThat(xml)
+ .contains("UsagePoint Feed")
+ .contains("rel=\"self\" href=\"" + BASE + "/UsagePoint\" type=\"espi-feed/UsagePoint\"")
+ .contains("urn:uuid:" + id + "", "Front of house meter")
+ .contains("href=\"" + BASE + "/UsagePoint/" + id + "\" type=\"espi-entry/UsagePoint\"")
+ .contains("", "href=\"self\"");
+ }
+
+ @Test
+ @DisplayName("GET /Authorization with no authorizations returns an empty Atom feed, not an empty body")
+ void emptyAuthorizationFeed() throws Exception {
+ String xml = mockMvc.perform(get("/espi/1_1/resource/Authorization").with(admin()))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+
+ assertThat(xml)
+ .contains("Authorization Feed",
+ "rel=\"self\" href=\"" + BASE + "/Authorization\"")
+ .doesNotContain(" files(Path base, String suffix) throws Exception {
+ if (!Files.exists(base)) {
+ return List.of();
+ }
+ try (var paths = Files.walk(base)) {
+ return paths.filter(p -> p.toString().endsWith(suffix)).toList();
+ }
+ }
+
+ @Test
+ @DisplayName("#247: an exception writes an .error.txt with the exception chain, then is rethrown")
+ void capturesExceptionChain(@TempDir Path dir) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/UsagePoint");
+ request.setQueryString("limit=5");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ RuntimeException failure = new RuntimeException("Failed to export DTO",
+ new IllegalStateException("class java.util.ImmutableCollections$ListN nor any of its super class is known"));
+
+ assertThatThrownBy(() -> new EspiXmlCaptureFilter(true, dir.toString())
+ .doFilter(request, response, (req, res) -> { throw failure; }))
+ .isSameAs(failure);
+
+ List errors = files(dir, ".error.txt");
+ assertThat(errors).singleElement()
+ .satisfies(p -> assertThat(p.getFileName().toString()).isEqualTo("GET_UsagePoint.error.txt"))
+ .satisfies(p -> assertThat(Files.readString(p))
+ .contains("Request: GET /espi/1_1/resource/UsagePoint?limit=5", "unhandled exception",
+ "java.lang.RuntimeException: Failed to export DTO",
+ "java.lang.IllegalStateException: class java.util.ImmutableCollections$ListN"));
+ assertThat(xmlFiles(dir)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("#247: a 4xx/5xx response writes an .error.txt with the status and body, not an .xml")
+ void captures4xxResponse(@TempDir Path dir) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/Authorization/42");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ new EspiXmlCaptureFilter(true, dir.toString()).doFilter(request, response, (req, res) -> {
+ ((jakarta.servlet.http.HttpServletResponse) res).setStatus(404);
+ res.setContentType("application/xml");
+ res.getOutputStream().write("not found".getBytes());
+ });
+
+ assertThat(xmlFiles(dir)).isEmpty();
+ assertThat(files(dir, ".error.txt")).singleElement()
+ .satisfies(p -> assertThat(Files.readString(p))
+ .contains("Status: 404", "not found"));
+ assertThat(response.getContentAsString()).isEqualTo("not found");
+ }
+
+ @Test
+ @DisplayName("a path-traversal request path cannot write outside the run directory")
+ void traversalStaysInsideRunDir(@TempDir Path dir) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
+ "/espi/1_1/resource/../../../../outside/evil");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ new EspiXmlCaptureFilter(true, dir.resolve("capture").toString())
+ .doFilter(request, response, chain("application/atom+xml", XML));
+
+ try (var paths = Files.walk(dir)) {
+ assertThat(paths.filter(Files::isRegularFile).toList())
+ .allSatisfy(p -> assertThat(p.getParent().getParent()).isEqualTo(dir.resolve("capture")));
+ }
+ assertThat(dir.resolve("outside")).doesNotExist();
+ }
+
+ @Test
+ @DisplayName("a relative capture dir is anchored at the repository root")
+ void relativeDirResolvesToRepoRoot() {
+ Path resolved = EspiXmlCaptureFilter.resolveCaptureDir("dev-xml-capture");
+
+ // endsWithRaw: plain endsWith canonicalises the path and fails when the directory does not
+ // exist yet, as on a fresh CI checkout
+ assertThat(resolved).isAbsolute().endsWithRaw(Path.of("dev-xml-capture"));
+ assertThat(resolved.getParent().resolve(".git")).exists();
+ }
}
diff --git a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java
index 17f7294b..e1649351 100755
--- a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java
+++ b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java
@@ -33,6 +33,7 @@
import org.springframework.stereotype.Repository;
import org.springframework.web.reactive.function.client.WebClient;
import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto;
import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto;
import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper;
@@ -61,7 +62,9 @@ public class UsagePointRESTRepositoryImpl implements UsagePointRESTRepository {
public UsagePointRESTRepositoryImpl() {
try {
- this.jaxbContext = JAXBContext.newInstance(AtomFeedDto.class);
+ // AtomFeedDto.entries is an @XmlElementRef to the abstract AtomEntryDto (#247), so the concrete
+ // entry type must be in the context; the Third Party reads usage-domain feeds.
+ this.jaxbContext = JAXBContext.newInstance(AtomFeedDto.class, UsageAtomEntryDto.class);
} catch (JAXBException e) {
throw new RuntimeException("Failed to initialize JAXB context", e);
}