Skip to content

Commit 68a1251

Browse files
dfcoffinclaude
andauthored
feat(#177): admin compose & send ESPI BatchList notification (DC sender + TP receiver) (#178)
Data Custodian (sender): - NotificationService.notifyBatchList(uri, resourceUris): synchronous send — marshal BatchListDto via BatchListXmlCodec, POST application/atom+xml; validates the URI and >=1 resource (blanks dropped); surfaces failures to the caller. - BatchNotificationController (/custodian/notifications GET + /send POST): admin enters the Third Party notify URL (suggested from registered ApplicationInformation endpoints) and the BatchList resource URLs (ApplicationInformation, Authorization feed, Authorization entry, Subscription); flash success/failure. ROLE_CUSTODIAN. Dashboard card + nav item. Third Party (receiver), corrected to the ESPI flow: - For each <resource> URL the TP, as an OAuth client, performs an authenticated GET on the URL to obtain the data, then hands it to import (per-resource failures logged, don't abort the batch). Per-resource token selection = #146; unmarshal/persist of the payload = #89. - Inbound POST /espi/1_1/Notification is permitAll: the DC delivers without an OAuth token (transport-secured by TLS); the token is used only on the outbound fetch. - Removed the legacy sftp:// branch + Runtime.exec (ESPI no longer permits SFTP; injection risk). Unparseable payload -> 400. Verified live (with the #146/#179 TP bring-up applied): DC admin notify page -> DC POSTs BatchList -> TP receives, persists the BatchList, attempts an authenticated GET per resource (DC returns 401 until a token is wired) -> "Successfully processed notification with 4 resources". Tests: common 4/0, datacustodian 160/0, thirdparty 47/0. (TP boot bring-up split into #146/#179.) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c071c96 commit 68a1251

9 files changed

Lines changed: 362 additions & 124 deletions

File tree

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/NotificationService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.springframework.stereotype.Service;
2626

2727
import javax.xml.datatype.XMLGregorianCalendar;
28+
import java.util.List;
2829

2930
@Service
3031
public interface NotificationService {
@@ -38,4 +39,16 @@ void notify(RetailCustomerEntity retailCustomer, XMLGregorianCalendar startDate,
3839

3940
void notify(ApplicationInformationEntity applicationInformation, Long bulkId);
4041

42+
/**
43+
* Send an ad-hoc ESPI {@code BatchList} of resource URLs to a Third Party notification endpoint,
44+
* <strong>synchronously</strong> (#177). Unlike the fire-and-forget {@code notify(...)} methods,
45+
* this surfaces failures to the caller so an admin UI can report success/error.
46+
*
47+
* @param thirdPartyNotificationUri the Third Party endpoint to POST the BatchList to
48+
* @param resourceUris the resource URLs to include; blank entries are ignored
49+
* @throws IllegalArgumentException if the URI is blank or no usable resource URL is supplied
50+
* @throws RuntimeException if the POST fails (connection/HTTP error)
51+
*/
52+
void notifyBatchList(String thirdPartyNotificationUri, List<String> resourceUris);
53+
4154
}

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImpl.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,33 @@ public void notifyAllNeed() {
260260
}
261261

262262

263+
@Override
264+
public void notifyBatchList(String thirdPartyNotificationUri, List<String> resourceUris) {
265+
if (thirdPartyNotificationUri == null || thirdPartyNotificationUri.isBlank()) {
266+
throw new IllegalArgumentException("Third Party notification URL is required");
267+
}
268+
List<String> resources = (resourceUris == null ? List.<String>of() : resourceUris).stream()
269+
.filter(uri -> uri != null && !uri.isBlank())
270+
.map(String::trim)
271+
.toList();
272+
if (resources.isEmpty()) {
273+
throw new IllegalArgumentException("At least one resource URL is required");
274+
}
275+
276+
// Synchronous send so the caller (admin UI) gets the outcome. retrieve() throws on a 4xx/5xx
277+
// response and the underlying client throws on a connection failure.
278+
String xml = BatchListXmlCodec.marshal(new BatchListDto(resources));
279+
restClient.post()
280+
.uri(thirdPartyNotificationUri)
281+
.contentType(MediaType.APPLICATION_ATOM_XML)
282+
.body(xml)
283+
.retrieve()
284+
.toBodilessEntity();
285+
if (log.isInfoEnabled()) {
286+
log.info("notifyBatchList: POSTed {} resource(s) to {}", resources.size(), thirdPartyNotificationUri);
287+
}
288+
}
289+
263290
@Override
264291
public void notify(ApplicationInformationEntity applicationInformation,
265292
Long bulkId) {

openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,52 @@ void notifyPostsConformantBatchList() throws Exception {
126126
.satisfies(uri ->
127127
assertThat(EspiBatchUri.subscriptionId(uri)).contains(subscriptionId.toString()));
128128
}
129+
130+
@Test
131+
@DisplayName("notifyBatchList() POSTs all supplied resource URLs as one BatchList (#177)")
132+
void notifyBatchListPostsAllResources() throws Exception {
133+
String notifyUri = "http://" + stub.getAddress().getHostString()
134+
+ ":" + stub.getAddress().getPort() + "/ThirdParty/espi/1_1/Notification";
135+
136+
java.util.List<String> urls = java.util.List.of(
137+
DC_RESOURCE_BASE + "/ApplicationInformation/app-1",
138+
DC_RESOURCE_BASE + "/Authorization",
139+
DC_RESOURCE_BASE + "/Authorization/auth-1",
140+
DC_RESOURCE_BASE + "/Subscription/sub-1");
141+
142+
NotificationServiceImpl service =
143+
new NotificationServiceImpl(RestClient.builder(), null, null, null);
144+
// Blank entries are dropped; the four real URLs must all be carried.
145+
java.util.List<String> withBlank = new java.util.ArrayList<>(urls);
146+
withBlank.add(" ");
147+
service.notifyBatchList(notifyUri, withBlank);
148+
149+
assertThat(received.await(5, TimeUnit.SECONDS))
150+
.as("TP stub must have received the notification POST").isTrue();
151+
assertThat(capturedMethod.get()).isEqualTo("POST");
152+
assertThat(capturedContentType.get()).startsWith("application/atom+xml");
153+
154+
BatchListDto sent = BatchListXmlCodec.unmarshal(capturedBody.get());
155+
assertThat(sent.getResources()).containsExactlyElementsOf(urls);
156+
}
157+
158+
@Test
159+
@DisplayName("notifyBatchList() rejects a blank notification URI (#177)")
160+
void notifyBatchListRejectsBlankUri() {
161+
NotificationServiceImpl service =
162+
new NotificationServiceImpl(RestClient.builder(), null, null, null);
163+
assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
164+
() -> service.notifyBatchList(" ", java.util.List.of("http://x/Subscription/1"))))
165+
.hasMessageContaining("notification URL");
166+
}
167+
168+
@Test
169+
@DisplayName("notifyBatchList() rejects an empty resource list (#177)")
170+
void notifyBatchListRejectsEmptyResources() {
171+
NotificationServiceImpl service =
172+
new NotificationServiceImpl(RestClient.builder(), null, null, null);
173+
assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
174+
() -> service.notifyBatchList("http://tp/Notification", java.util.List.of(" "))))
175+
.hasMessageContaining("resource URL");
176+
}
129177
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
*
3+
* Copyright (c) 2025 Green Button Alliance, Inc.
4+
*
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*
18+
*/
19+
20+
package org.greenbuttonalliance.espi.datacustodian.web.custodian;
21+
22+
import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity;
23+
import org.greenbuttonalliance.espi.common.service.ApplicationInformationService;
24+
import org.greenbuttonalliance.espi.common.service.NotificationService;
25+
import org.springframework.core.env.Environment;
26+
import org.springframework.security.access.prepost.PreAuthorize;
27+
import org.springframework.stereotype.Controller;
28+
import org.springframework.ui.Model;
29+
import org.springframework.web.bind.annotation.GetMapping;
30+
import org.springframework.web.bind.annotation.ModelAttribute;
31+
import org.springframework.web.bind.annotation.PostMapping;
32+
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
33+
34+
import java.util.List;
35+
import java.util.stream.Stream;
36+
37+
/**
38+
* Custodian "Notify Third Party" page (#177). Lets an admin compose and send an ESPI
39+
* {@code BatchList} (Atom) of resource URLs — ApplicationInformation, Authorization feed,
40+
* Authorization entry, Subscription — to a Third Party notification endpoint, reusing the #158
41+
* notification contract via {@link NotificationService#notifyBatchList}.
42+
*/
43+
@Controller
44+
@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
45+
public class BatchNotificationController {
46+
47+
private static final String DEFAULT_TP_NOTIFY_URI =
48+
"http://localhost:8082/ThirdParty/espi/1_1/Notification";
49+
50+
private final NotificationService notificationService;
51+
private final ApplicationInformationService applicationInformationService;
52+
private final Environment environment;
53+
54+
public BatchNotificationController(NotificationService notificationService,
55+
ApplicationInformationService applicationInformationService,
56+
Environment environment) {
57+
this.notificationService = notificationService;
58+
this.applicationInformationService = applicationInformationService;
59+
this.environment = environment;
60+
}
61+
62+
@GetMapping("/custodian/notifications")
63+
public String form(Model model) {
64+
List<ApplicationInformationEntity> apps = applicationInformationService.findAll();
65+
if (!model.containsAttribute("notifyForm")) {
66+
model.addAttribute("notifyForm", defaultForm(apps));
67+
}
68+
// Notification URLs the admin can pick from (the registered third parties' notify endpoints).
69+
List<String> notifyUris = apps.stream()
70+
.map(ApplicationInformationEntity::getThirdPartyNotifyUri)
71+
.filter(u -> u != null && !u.isBlank())
72+
.distinct()
73+
.toList();
74+
model.addAttribute("notifyUris", notifyUris);
75+
return "custodian/notifications";
76+
}
77+
78+
@PostMapping("/custodian/notifications/send")
79+
public String send(@ModelAttribute("notifyForm") NotifyForm form, RedirectAttributes redirectAttributes) {
80+
List<String> resources = Stream.of(
81+
form.getApplicationInformationUrl(),
82+
form.getAuthorizationFeedUrl(),
83+
form.getAuthorizationEntryUrl(),
84+
form.getSubscriptionUrl())
85+
.filter(u -> u != null && !u.isBlank())
86+
.toList();
87+
try {
88+
notificationService.notifyBatchList(form.getNotificationUri(), resources);
89+
redirectAttributes.addFlashAttribute("message",
90+
"BatchList sent to " + form.getNotificationUri() + " (" + resources.size() + " resource(s)).");
91+
redirectAttributes.addFlashAttribute("messageType", "success");
92+
}
93+
catch (Exception e) {
94+
redirectAttributes.addFlashAttribute("message", "Failed to send BatchList: " + e.getMessage());
95+
redirectAttributes.addFlashAttribute("messageType", "danger");
96+
}
97+
// Preserve what the admin typed so they can correct and resend.
98+
redirectAttributes.addFlashAttribute("notifyForm", form);
99+
return "redirect:/custodian/notifications";
100+
}
101+
102+
private NotifyForm defaultForm(List<ApplicationInformationEntity> apps) {
103+
String base = environment.getProperty("espi.datacustodian.base-url",
104+
"http://localhost:8081/DataCustodian");
105+
String resourceBase = base + "/espi/1_1/resource";
106+
NotifyForm f = new NotifyForm();
107+
f.setNotificationUri(apps.stream()
108+
.map(ApplicationInformationEntity::getThirdPartyNotifyUri)
109+
.filter(u -> u != null && !u.isBlank())
110+
.findFirst()
111+
.orElse(DEFAULT_TP_NOTIFY_URI));
112+
f.setApplicationInformationUrl(resourceBase + "/ApplicationInformation/{applicationInformationId}");
113+
f.setAuthorizationFeedUrl(resourceBase + "/Authorization");
114+
f.setAuthorizationEntryUrl(resourceBase + "/Authorization/{authorizationId}");
115+
f.setSubscriptionUrl(resourceBase + "/Subscription/{subscriptionId}");
116+
return f;
117+
}
118+
119+
/** Backing form for the notify page. */
120+
public static class NotifyForm {
121+
private String notificationUri;
122+
private String applicationInformationUrl;
123+
private String authorizationFeedUrl;
124+
private String authorizationEntryUrl;
125+
private String subscriptionUrl;
126+
127+
public String getNotificationUri() { return notificationUri; }
128+
public void setNotificationUri(String notificationUri) { this.notificationUri = notificationUri; }
129+
public String getApplicationInformationUrl() { return applicationInformationUrl; }
130+
public void setApplicationInformationUrl(String v) { this.applicationInformationUrl = v; }
131+
public String getAuthorizationFeedUrl() { return authorizationFeedUrl; }
132+
public void setAuthorizationFeedUrl(String v) { this.authorizationFeedUrl = v; }
133+
public String getAuthorizationEntryUrl() { return authorizationEntryUrl; }
134+
public void setAuthorizationEntryUrl(String v) { this.authorizationEntryUrl = v; }
135+
public String getSubscriptionUrl() { return subscriptionUrl; }
136+
public void setSubscriptionUrl(String v) { this.subscriptionUrl = v; }
137+
}
138+
}

openespi-datacustodian/src/main/resources/templates/custodian/home.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ <h5 class="card-title mt-3">Data Upload</h5>
5151
</div>
5252
</div>
5353

54+
<div class="col-lg-3 col-md-6 mb-4">
55+
<div class="card h-100">
56+
<div class="card-body text-center d-flex flex-column">
57+
<i class="bi bi-send fs-1 text-danger"></i>
58+
<h5 class="card-title mt-3">Notify Third Party</h5>
59+
<p class="card-text flex-grow-1">Send an ESPI BatchList of resource URLs to a third party.</p>
60+
<a th:href="@{/custodian/notifications}" class="btn btn-danger">Notify</a>
61+
</div>
62+
</div>
63+
</div>
64+
5465
<div class="col-lg-3 col-md-6 mb-4">
5566
<div class="card h-100">
5667
<div class="card-body text-center d-flex flex-column">
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!DOCTYPE html>
2+
<html lang="en" xmlns:th="http://www.thymeleaf.org">
3+
<head th:replace="~{fragments/layout :: head}">
4+
<title>Custodian Portal - Notify Third Party</title>
5+
</head>
6+
7+
<body>
8+
<nav th:replace="~{fragments/layout :: custodianHeader}"></nav>
9+
10+
<div class="container">
11+
<h2 class="mt-4">Notify Third Party</h2>
12+
<p class="text-muted">
13+
Compose an ESPI <code>BatchList</code> of resource URLs and POST it to a Third Party's
14+
notification endpoint. Leave a field blank to omit that resource.
15+
</p>
16+
17+
<div th:if="${message}" class="alert"
18+
th:classappend="${messageType == 'success'} ? 'alert-success' : 'alert-danger'"
19+
th:text="${message}">result</div>
20+
21+
<div class="card mt-3">
22+
<div class="card-body">
23+
<form th:action="@{/custodian/notifications/send}" th:object="${notifyForm}" method="post">
24+
<div class="mb-3">
25+
<label for="notificationUri" class="form-label">Third Party notification URL</label>
26+
<input type="url" id="notificationUri" class="form-control"
27+
th:field="*{notificationUri}" list="notifyUriOptions" required/>
28+
<datalist id="notifyUriOptions">
29+
<option th:each="uri : ${notifyUris}" th:value="${uri}"></option>
30+
</datalist>
31+
<div class="form-text">Registered third-party endpoints are suggested; you may edit.</div>
32+
</div>
33+
34+
<hr>
35+
<h6 class="text-muted">BatchList resources</h6>
36+
37+
<div class="mb-3">
38+
<label for="applicationInformationUrl" class="form-label">ApplicationInformation URL</label>
39+
<input type="text" id="applicationInformationUrl" class="form-control"
40+
th:field="*{applicationInformationUrl}"/>
41+
</div>
42+
<div class="mb-3">
43+
<label for="authorizationFeedUrl" class="form-label">Authorization feed URL</label>
44+
<input type="text" id="authorizationFeedUrl" class="form-control"
45+
th:field="*{authorizationFeedUrl}"/>
46+
</div>
47+
<div class="mb-3">
48+
<label for="authorizationEntryUrl" class="form-label">Authorization entry URL</label>
49+
<input type="text" id="authorizationEntryUrl" class="form-control"
50+
th:field="*{authorizationEntryUrl}"/>
51+
</div>
52+
<div class="mb-3">
53+
<label for="subscriptionUrl" class="form-label">Subscription URL</label>
54+
<input type="text" id="subscriptionUrl" class="form-control"
55+
th:field="*{subscriptionUrl}"/>
56+
</div>
57+
58+
<div class="d-flex gap-2">
59+
<button type="submit" class="btn btn-primary">
60+
<i class="bi bi-send"></i> Send BatchList
61+
</button>
62+
<a th:href="@{/custodian/home}" class="btn btn-outline-secondary">Cancel</a>
63+
</div>
64+
</form>
65+
</div>
66+
</div>
67+
68+
<hr class="my-5">
69+
<footer th:replace="~{fragments/layout :: footer}"></footer>
70+
</div>
71+
</body>
72+
</html>

openespi-datacustodian/src/main/resources/templates/fragments/layout.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@
143143
<li class="nav-item">
144144
<a class="nav-link" th:href="@{/custodian/upload}">Data Upload</a>
145145
</li>
146+
<li class="nav-item">
147+
<a class="nav-link" th:href="@{/custodian/notifications}">Notify Third Party</a>
148+
</li>
146149
<li class="nav-item">
147150
<a class="nav-link" th:href="@{/custodian/settings}">Settings</a>
148151
</li>

openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/SecurityConfiguration.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.springframework.context.annotation.Bean;
2323
import org.springframework.context.annotation.Configuration;
24+
import org.springframework.http.HttpMethod;
2425
import org.springframework.security.config.Customizer;
2526
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
2627
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -58,6 +59,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5859
http
5960
.authorizeHttpRequests(authz -> authz
6061
.requestMatchers("/", "/home", "/login", "/css/**", "/js/**", "/images/**").permitAll()
62+
// ESPI notification receipt: the Data Custodian POSTs a BatchList here without an
63+
// OAuth token — this endpoint is secured at the transport layer (TLS), not with an
64+
// OAuth access token. (The Third Party, as an OAuth client, presents its access token
65+
// only when it later fetches the source URLs carried in the BatchList.)
66+
.requestMatchers(HttpMethod.POST, "/espi/1_1/Notification").permitAll()
6167
.requestMatchers("/h2-console/**").hasRole("ADMIN")
6268
.anyRequest().authenticated()
6369
)

0 commit comments

Comments
 (0)