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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Cross-service wire contracts (AS ↔ DC)
# Cross-service wire contracts (AS ↔ DC, DC → TP)

Canonical JSON examples of the HTTP wire formats exchanged between the Authorization
Server and the Data Custodian. These files are the **single source of truth** both
services bind to in their own tests — a lightweight, framework-free consumer-driven
contract suited to standalone Spring Boot services (deployed independently on EC2),
**not** a Spring Cloud microservice mesh.
Canonical examples of the HTTP wire formats exchanged between the OpenESPI services:
the **Authorization Server ↔ Data Custodian** token/introspection format (JSON) and the
**Data Custodian → Third Party** notification format (XML). These files are the
**single source of truth** both services bind to in their own tests — a lightweight,
framework-free consumer-driven contract suited to standalone Spring Boot services
(deployed independently on EC2), **not** a Spring Cloud microservice mesh.

## How the contract is enforced (no Spring Cloud, no Pact broker)

Expand Down Expand Up @@ -40,3 +41,23 @@ reproduce that standard shape for the two grant types:

UUIDs/ids/tokens in the fixtures are illustrative; the contract is the **shape** — field names,
the `Batch/{Subscription|Bulk|RetailCustomer}` URI forms, and the ESPI `FB=...` scope grammar.

### DC → TP notification (BatchList, XML)

When new/updated data is available, the Data Custodian POSTs an ESPI `BatchList` document to
the Third Party's registered `thirdPartyNotifyUri` (`POST {tpBase}/espi/1_1/Notification`,
`Content-Type: application/atom+xml`, `200 OK` on success). The body lists the canonical
`Batch/...` resource URIs the TP should fetch back.

| File | Endpoint | Content-Type |
|------|----------|--------------|
| `notification-batchlist.xml` | `POST {tpBase}/espi/1_1/Notification` | `application/atom+xml` |

- **Producer** (DC `NotificationServiceImpl`) builds the resource URI via `EspiBatchUri`
(`Batch/Subscription/{id}?published-min=…&published-max=…`) and serializes the `BatchListDto`
through `BatchListXmlCodec` — the single canonical BatchList wire codec.
- **Consumer** (TP `NotificationController`) parses the document through the same
`BatchListXmlCodec` and parses ids out of each resource URI via `EspiBatchUri`.

The wire type is the JAXB-annotated `BatchListDto` (root `<BatchList xmlns="http://naesb.org/espi">`,
repeating `<resources>` elements), never the JPA entity — per the project's JAXB/JPA separation rule.
4 changes: 4 additions & 0 deletions contracts/notification-batchlist.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<BatchList xmlns="http://naesb.org/espi">
<resources>https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Subscription/503888?published-min=2025-01-01T00:00:00Z&amp;published-max=2025-02-01T00:00:00Z</resources>
</BatchList>
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@

package org.greenbuttonalliance.espi.common.service.impl;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.greenbuttonalliance.espi.common.domain.usage.*;
import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto;
import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository;
import org.greenbuttonalliance.espi.common.service.AuthorizationService;
import org.greenbuttonalliance.espi.common.service.NotificationService;
import org.greenbuttonalliance.espi.common.service.SubscriptionService;
import org.greenbuttonalliance.espi.common.uri.EspiBatchUri;
import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.client.RestClient;

import javax.xml.datatype.XMLGregorianCalendar;
import java.util.*;
Expand All @@ -42,26 +46,51 @@
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class NotificationServiceImpl implements NotificationService {

private final RestTemplate restTemplate;
private final RestClient restClient;
private final AuthorizationRepository authorizationRepository;
private final AuthorizationService authorizationService;
private final SubscriptionService subscriptionService;

/**
* Production constructor. Builds a standalone {@link RestClient} for posting ESPI
* {@code BatchList} notifications to Third Party {@code thirdPartyNotifyUri} endpoints.
* Replaces the legacy {@code RestTemplate} (#158).
*/
@Autowired
public NotificationServiceImpl(AuthorizationRepository authorizationRepository,
AuthorizationService authorizationService,
SubscriptionService subscriptionService) {
this(RestClient.create(), authorizationRepository, authorizationService, subscriptionService);
}

/**
* Test/seam constructor allowing a pre-configured {@link RestClient} (e.g. pointed at a
* stub receiver) to be injected.
*/
public NotificationServiceImpl(RestClient restClient,
AuthorizationRepository authorizationRepository,
AuthorizationService authorizationService,
SubscriptionService subscriptionService) {
this.restClient = restClient;
this.authorizationRepository = authorizationRepository;
this.authorizationService = authorizationService;
this.subscriptionService = subscriptionService;
}

@Override
public void notify(SubscriptionEntity subscription,
XMLGregorianCalendar startDate, XMLGregorianCalendar endDate) {

String thirdPartyNotificationURI = subscription
.getApplicationInformation().getThirdPartyNotifyUri();
String separator = "?";
String subscriptionURI = subscription.getApplicationInformation()
.getDataCustodianResourceEndpoint()
+ "/Batch/Subscription/"
+ subscription.getId();
// Canonical ESPI Batch/Subscription form, built via the single source of truth (#160).
String subscriptionURI = EspiBatchUri.batchSubscription(
subscription.getApplicationInformation().getDataCustodianResourceEndpoint(),
String.valueOf(subscription.getId()));

if (startDate != null) {
subscriptionURI = subscriptionURI + separator + "published-min="
+ startDate.toXMLFormat();
Expand Down Expand Up @@ -120,7 +149,14 @@ protected void notifyInternal(String thirdPartyNotificationURI,

if(thirdPartyNotificationURI != null){
try {
restTemplate.postForLocation(thirdPartyNotificationURI, batchList);
// Wire type is the JAXB DTO, serialized through the single canonical codec (#158).
String xml = BatchListXmlCodec.marshal(new BatchListDto(batchList.getResources()));
restClient.post()
.uri(thirdPartyNotificationURI)
.contentType(MediaType.APPLICATION_ATOM_XML)
.body(xml)
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
if(log.isErrorEnabled()) {
log.info("NotificationServiceImpl: notifyInternal - POST for " + thirdPartyNotificationURI +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
*
* Copyright (c) 2025 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
*
* http://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.xml;

import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import jakarta.xml.bind.Unmarshaller;
import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto;

import javax.xml.transform.stream.StreamSource;
import java.io.StringReader;
import java.io.StringWriter;

/**
* Single canonical codec for the ESPI {@code BatchList} notification wire format (#158).
*
* <p>The Data Custodian→Third Party notification seam exchanges a {@code BatchList} XML document
* (a list of resource URIs to fetch). This codec is the <em>single source of truth</em> for that
* serialization: the DC sender ({@code NotificationServiceImpl}) marshals through it and the TP
* receiver ({@code NotificationController}) unmarshals through it, so the two sides cannot drift.
* It is the BatchList analogue of {@link org.greenbuttonalliance.espi.common.uri.EspiBatchUri}
* (the canonical Batch-URI codec) and is likewise a stateless static utility — no Spring wiring.</p>
*
* <p>Per the project's JAXB rule the wire type is the JAXB-annotated {@link BatchListDto}
* ({@code @XmlRootElement(name = "BatchList", namespace = "http://naesb.org/espi")}), never the
* JPA entity. The {@link JAXBContext} is created once and cached (contexts are thread-safe;
* marshaller/unmarshaller instances are created per call as they are not).</p>
*/
public final class BatchListXmlCodec {

/** ESPI namespace of the {@code BatchList} root element. */
public static final String ESPI_NAMESPACE = "http://naesb.org/espi";

/** Local name of the root element. */
public static final String ROOT_ELEMENT = "BatchList";

private static final JAXBContext CONTEXT = createContext();

private BatchListXmlCodec() {
// static utility
}

private static JAXBContext createContext() {
try {
return JAXBContext.newInstance(BatchListDto.class);
} catch (JAXBException e) {
throw new IllegalStateException("Failed to initialize BatchList JAXB context", e);
}
}

/**
* Marshals a {@link BatchListDto} to its ESPI {@code BatchList} XML string.
*
* @param batchList the batch list to marshal (must not be {@code null})
* @return the UTF-8 XML document as a string
* @throws IllegalArgumentException if {@code batchList} is {@code null}
* @throws IllegalStateException if marshalling fails
*/
public static String marshal(BatchListDto batchList) {
if (batchList == null) {
throw new IllegalArgumentException("batchList must not be null");
}
try {
Marshaller marshaller = CONTEXT.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_ENCODING, "UTF-8");
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
StringWriter writer = new StringWriter();
marshaller.marshal(batchList, writer);
return writer.toString();
} catch (JAXBException e) {
throw new IllegalStateException("Failed to marshal BatchList", e);
}
}

/**
* Unmarshals an ESPI {@code BatchList} XML string to a {@link BatchListDto}.
*
* @param xml the XML document (must not be {@code null})
* @return the parsed batch list
* @throws IllegalArgumentException if {@code xml} is {@code null}
* @throws IllegalStateException if unmarshalling fails
*/
public static BatchListDto unmarshal(String xml) {
if (xml == null) {
throw new IllegalArgumentException("xml must not be null");
}
try {
Unmarshaller unmarshaller = CONTEXT.createUnmarshaller();
return unmarshaller.unmarshal(
new StreamSource(new StringReader(xml)), BatchListDto.class).getValue();
} catch (JAXBException e) {
throw new IllegalStateException("Failed to unmarshal BatchList", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
*
* Copyright (c) 2025 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
*
* http://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.impl;

import com.sun.net.httpserver.HttpServer;
import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity;
import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto;
import org.greenbuttonalliance.espi.common.uri.EspiBatchUri;
import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;

import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Producer side of the DC→TP notification wire contract (#158).
*
* <p>Drives the real {@link NotificationServiceImpl} sender against a minimal standalone
* TP-receiver stub (a JDK {@link HttpServer} — no WireMock, no Spring, no Docker) and asserts
* the bytes actually put on the wire conform to the ESPI {@code BatchList} notification contract:
* the endpoint path, {@code application/atom+xml} content type, and a body that the canonical
* {@link BatchListXmlCodec} parses back to the expected {@code Batch/Subscription} resource URI.</p>
*/
@DisplayName("DC→TP notification wire contract — DC producer + TP-receiver stub (#158)")
class NotificationServiceImplWireContractTest {

private static final String DC_RESOURCE_BASE =
"https://dc.example.com/DataCustodian/espi/1_1/resource";

private HttpServer stub;
private final AtomicReference<String> capturedMethod = new AtomicReference<>();
private final AtomicReference<String> capturedPath = new AtomicReference<>();
private final AtomicReference<String> capturedContentType = new AtomicReference<>();
private final AtomicReference<String> capturedBody = new AtomicReference<>();
private CountDownLatch received;

@BeforeEach
void startStub() throws Exception {
received = new CountDownLatch(1);
stub = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
stub.createContext("/ThirdParty/espi/1_1/Notification", exchange -> {
capturedMethod.set(exchange.getRequestMethod());
capturedPath.set(exchange.getRequestURI().getPath());
capturedContentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
capturedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
exchange.sendResponseHeaders(200, -1);
exchange.close();
received.countDown();
});
stub.start();
}

@AfterEach
void stopStub() {
if (stub != null) {
stub.stop(0);
}
}

@Test
@DisplayName("notify() POSTs a conformant ESPI BatchList to the TP notify URI")
void notifyPostsConformantBatchList() throws Exception {
UUID subscriptionId = UUID.fromString("11111111-2222-5333-8444-555555555555");
String notifyUri = "http://" + stub.getAddress().getHostString()
+ ":" + stub.getAddress().getPort() + "/ThirdParty/espi/1_1/Notification";

ApplicationInformationEntity appInfo = new ApplicationInformationEntity();
appInfo.setThirdPartyNotifyUri(notifyUri);
appInfo.setDataCustodianResourceEndpoint(DC_RESOURCE_BASE);

SubscriptionEntity subscription = new SubscriptionEntity(subscriptionId);
subscription.setApplicationInformation(appInfo);

DatatypeFactory dtf = DatatypeFactory.newInstance();
XMLGregorianCalendar start = dtf.newXMLGregorianCalendar("2025-01-01T00:00:00Z");
XMLGregorianCalendar end = dtf.newXMLGregorianCalendar("2025-02-01T00:00:00Z");

// repositories/services are unused by the subscription notify path
NotificationServiceImpl service =
new NotificationServiceImpl(RestClient.create(), null, null, null);

service.notify(subscription, start, end);

assertThat(received.await(5, TimeUnit.SECONDS))
.as("TP stub must have received the notification POST").isTrue();
assertThat(capturedMethod.get()).isEqualTo("POST");
assertThat(capturedPath.get()).isEqualTo("/ThirdParty/espi/1_1/Notification");
assertThat(capturedContentType.get()).startsWith("application/atom+xml");

BatchListDto sent = BatchListXmlCodec.unmarshal(capturedBody.get());
assertThat(sent.getResources())
.singleElement(org.assertj.core.api.InstanceOfAssertFactories.STRING)
.contains("/Batch/Subscription/" + subscriptionId)
.contains("published-min=2025-01-01T00:00:00Z")
.contains("published-max=2025-02-01T00:00:00Z")
.satisfies(uri ->
assertThat(EspiBatchUri.subscriptionId(uri)).contains(subscriptionId.toString()));
}
}
Loading
Loading