Skip to content

Commit

Permalink
PP-9001: add webhook message model for serialised webhook message body (
Browse files Browse the repository at this point in the history
#44)

* add webhook message model for serialised webhook message body

Addresses TODO to serialise response in expected format
add missing field 'resource_type' serialised from SQS message and added as top level field to message
Serialises/Deserialises timestamp from SQS message as Instant

* use naming strategy to set field names

* get date and event type from WebhookMessageEntity

* add fields to WebhookEntity and use to serialise webhook message body

* restore comment (as resource still needs more transformation)
rename record to make more specific to Webhook message body.

* assorted changes based on PR comments
  • Loading branch information
gidsg authored Jan 18, 2022
1 parent 97dd528 commit 2cffec3
Show file tree
Hide file tree
Showing 14 changed files with 217 additions and 19 deletions.
35 changes: 35 additions & 0 deletions src/main/java/uk/gov/pay/webhooks/message/WebhookMessageBody.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package uk.gov.pay.webhooks.message;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import uk.gov.pay.webhooks.eventtype.EventTypeName;
import uk.gov.pay.webhooks.message.dao.entity.WebhookMessageEntity;
import uk.gov.service.payments.commons.api.json.ApiResponseInstantSerializer;

import java.time.Instant;


@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record WebhookMessageBody(String id,
@JsonSerialize(using = ApiResponseInstantSerializer.class) Instant createdDate,
String resourceId,
Integer apiVersion,
String resourceType,
EventTypeName eventTypeName,
JsonNode resource) {

public static final int API_VERSION = 1;

public static WebhookMessageBody from(WebhookMessageEntity webhookMessage) {
return new WebhookMessageBody(webhookMessage.getExternalId(),
webhookMessage.getEventDate().toInstant(),
webhookMessage.getResourceExternalId(),
API_VERSION,
webhookMessage.getResourceType(),
webhookMessage.getEventType().getName(),
webhookMessage.getResource()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public WebhookMessageSender(HttpClient httpClient,

public HttpResponse<String> sendWebhookMessage(WebhookMessageEntity webhookMessage) throws IOException, InterruptedException, InvalidKeyException {
URI uri = URI.create(webhookMessage.getWebhookEntity().getCallbackUrl());
String body = objectMapper.writeValueAsString(webhookMessage.getResource());
String body = objectMapper.writeValueAsString(WebhookMessageBody.from(webhookMessage));
String signingKey = webhookMessage.getWebhookEntity().getSigningKey();
String signature = webhookMessageSignatureGenerator.generate(body, signingKey);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,17 @@ public void handleInternalEvent(InternalEvent event) throws IOException, Interru
}

private WebhookMessageEntity buildWebhookMessage(WebhookEntity webhook, InternalEvent event, LedgerTransaction ledgerTransaction) {
JsonNode resource = objectMapper.valueToTree(ledgerTransaction); // will probably need some more transformation
JsonNode resource = objectMapper.valueToTree(ledgerTransaction);

var webhookMessageEntity = new WebhookMessageEntity();
webhookMessageEntity.setExternalId(idGenerator.newExternalId());
webhookMessageEntity.setCreatedDate(Date.from(instantSource.instant()));
webhookMessageEntity.setWebhookEntity(webhook);
webhookMessageEntity.setEventDate(Date.from(event.eventDate().toInstant()));
webhookMessageEntity.setEventDate(Date.from(event.eventDate()));
webhookMessageEntity.setEventType(eventTypeDao.findByName(EventMapper.getWebhookEventNameFor(event.eventType())).orElseThrow(IllegalArgumentException::new));
webhookMessageEntity.setResource(resource);
webhookMessageEntity.setResource(objectMapper.valueToTree(resource)); // will probably need some more transformation
webhookMessageEntity.setResourceExternalId(event.resourceExternalId());
webhookMessageEntity.setResourceType(event.resourceType());
return webhookMessageEntity;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ public WebhookMessageEntity() {}
@Column(name = "external_id")
private String externalId;

public String getResourceExternalId() {
return resourceExternalId;
}

public void setResourceExternalId(String resourceExternalId) {
this.resourceExternalId = resourceExternalId;
}

@Column(name = "resource_external_id")
private String resourceExternalId;

public String getResourceType() {
return resourceType;
}

public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}

@Column(name = "resource_type")
private String resourceType;

@Column(name = "created_date")
private Date createdDate;

Expand Down
3 changes: 2 additions & 1 deletion src/main/java/uk/gov/pay/webhooks/queue/EventMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public InternalEvent toInternalEvent() {
eventMessageDto.resourceExternalId(),
eventMessageDto.parentResourceExternalId(),
eventMessageDto.eventData(),
eventMessageDto.eventDate()
eventMessageDto.eventDate(),
eventMessageDto.resourceType()
);
}

Expand Down
7 changes: 4 additions & 3 deletions src/main/java/uk/gov/pay/webhooks/queue/EventMessageDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import uk.gov.service.payments.commons.api.json.MicrosecondPrecisionDateTimeDeserializer;
import uk.gov.pay.webhooks.util.InstantDeserializer;

import java.time.ZonedDateTime;
import java.time.Instant;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record EventMessageDto(@JsonProperty("service_id") String serviceId,
Boolean live,
@JsonProperty("event_date") @JsonDeserialize(using = MicrosecondPrecisionDateTimeDeserializer.class) ZonedDateTime eventDate,
@JsonProperty("event_date") @JsonDeserialize(using = InstantDeserializer.class) Instant eventDate,
@JsonProperty("resource_external_id") String resourceExternalId,
@JsonProperty("parent_resource_external_id") String parentResourceExternalId,
@JsonProperty("event_type") String eventType,
@JsonProperty("resource_type") String resourceType,
@JsonProperty("event_data") JsonNode eventData
) {}
7 changes: 4 additions & 3 deletions src/main/java/uk/gov/pay/webhooks/queue/InternalEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import uk.gov.service.payments.commons.api.json.MicrosecondPrecisionDateTimeSerializer;
import uk.gov.pay.webhooks.util.MicrosecondPrecisionInstantSerializer;

import java.time.ZonedDateTime;
import java.time.Instant;

public record InternalEvent(
String eventType,
Expand All @@ -13,5 +13,6 @@ public record InternalEvent(
String resourceExternalId,
String parentResourceExternalId,
JsonNode eventData,
@JsonSerialize(using = MicrosecondPrecisionDateTimeSerializer.class) ZonedDateTime eventDate
@JsonSerialize(using = MicrosecondPrecisionInstantSerializer.class) Instant eventDate,
String resourceType
) {}
17 changes: 17 additions & 0 deletions src/main/java/uk/gov/pay/webhooks/util/InstantDeserializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package uk.gov.pay.webhooks.util;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;

import java.io.IOException;
import java.time.Instant;

public class InstantDeserializer extends JsonDeserializer<Instant> {

@Override
public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return Instant.parse(p.getText());
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package uk.gov.pay.webhooks.util;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;

import java.io.IOException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.Locale;

public class MicrosecondPrecisionInstantSerializer extends JsonSerializer<Instant> {

public static final DateTimeFormatter MICROSECOND_FORMATTER =
new DateTimeFormatterBuilder()
.appendInstant(6)
.toFormatter(Locale.ENGLISH);

@Override
public void serialize(Instant value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(MICROSECOND_FORMATTER.format(value));
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ CREATE table webhook_messages (
webhook_id INT NOT NULL,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
event_type INT NOT NULL,
resource JSONB NOT NULL
resource JSONB NOT NULL,
resource_external_id VARCHAR(26),
resource_type VARCHAR
);

ALTER TABLE webhook_messages ADD CONSTRAINT fk_webhook_message_webhook_id FOREIGN KEY (webhook_id) REFERENCES webhooks (id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package uk.gov.pay.webhooks.message;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import uk.gov.pay.webhooks.eventtype.EventTypeName;
import uk.gov.pay.webhooks.eventtype.dao.EventTypeEntity;
import uk.gov.pay.webhooks.message.dao.entity.WebhookMessageEntity;

import java.time.Instant;
import java.time.InstantSource;
import java.util.Date;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@ExtendWith(DropwizardExtensionsSupport.class)
class WebhookMessageBodyTest {

private ObjectMapper objectMapper;
private InstantSource instantSource;

@BeforeEach
public void setUp() {
instantSource = InstantSource.fixed(Instant.parse("2019-10-01T08:25:24.00Z"));
objectMapper = new ObjectMapper();
}

@Test
void serialisesWebhookMessageBody() throws JsonProcessingException {
String resource = """
{
"json": "and",
"the": "argonauts"
}
""";
var webhookMessageEntity = new WebhookMessageEntity();
webhookMessageEntity.setExternalId("externalId");
webhookMessageEntity.setEventDate(Date.from(instantSource.instant()));
EventTypeEntity eventTypeEntity = new EventTypeEntity(EventTypeName.CARD_PAYMENT_CAPTURED);
webhookMessageEntity.setEventType(eventTypeEntity);
webhookMessageEntity.setResourceType("payment");
webhookMessageEntity.setResourceExternalId("resource-external-id");
webhookMessageEntity.setResource(objectMapper.readTree(resource));

var body = WebhookMessageBody.from(webhookMessageEntity);;
var expectedJson = """
{
"id": "externalId",
"created_date": "2019-10-01T08:25:24.000Z",
"resource_id": "resource-external-id",
"api_version": 1,
"resource_type": "payment",
"event_type_name": "card_payment_captured",
"resource": {
"json": "and",
"the": "argonauts"
}
}
""";

assertThat(objectMapper.readTree(expectedJson), equalTo(objectMapper.valueToTree(body)));

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import uk.gov.pay.webhooks.eventtype.EventTypeName;
import uk.gov.pay.webhooks.eventtype.dao.EventTypeEntity;
import uk.gov.pay.webhooks.message.dao.entity.WebhookMessageEntity;
import uk.gov.pay.webhooks.webhook.dao.entity.WebhookEntity;

Expand All @@ -18,6 +20,8 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.InvalidKeyException;
import java.sql.Date;
import java.time.Instant;
import java.util.Optional;

import static org.hamcrest.MatcherAssert.assertThat;
Expand All @@ -33,10 +37,18 @@
class WebhookMessageSenderTest {

private static final String PAYLOAD = """
{
{
"id": "externalId",
"created_date": "2022-01-12T17:31:06.809Z",
"resource_id": "foo",
"api_version": 1,
"resource_type": null,
"event_type_name": "card_payment_captured",
"resource": {
"json": "and",
"the": "argonauts"
}
}
""";

private static final URI CALLBACK_URL = URI.create("http://www.callbackurl.test/webhook-handler");
Expand Down Expand Up @@ -69,8 +81,14 @@ void setUp() throws JsonProcessingException, InvalidKeyException {
webhookMessageEntity = new WebhookMessageEntity();
webhookMessageEntity.setWebhookEntity(webhookEntity);
webhookMessageEntity.setResource(jsonPayload);
webhookMessageEntity.setEventDate(Date.from(Instant.parse("2019-10-01T08:25:24.00Z")));
EventTypeEntity eventTypeEntity = new EventTypeEntity(EventTypeName.CARD_PAYMENT_CAPTURED);
webhookMessageEntity.setEventType(eventTypeEntity);
webhookMessageEntity.setResourceExternalId("foo");
webhookMessageEntity.setExternalId("externalId");
webhookMessageEntity.setResourceType("payment");

given(mockWebhookMessageSignatureGenerator.generate(jsonPayload.toString(), SIGNING_KEY)).willReturn(SIGNATURE);
given(mockWebhookMessageSignatureGenerator.generate(objectMapper.writeValueAsString(WebhookMessageBody.from(webhookMessageEntity)), SIGNING_KEY)).willReturn(SIGNATURE);

webhookMessageSender = new WebhookMessageSender(mockHttpClient, objectMapper, mockWebhookMessageSignatureGenerator);
}
Expand Down Expand Up @@ -104,8 +122,8 @@ void propagatesInterruptedException() throws IOException, InterruptedException {
}

@Test
void propagatesInvalidKeyException() throws InvalidKeyException {
given(mockWebhookMessageSignatureGenerator.generate(jsonPayload.toString(), SIGNING_KEY)).willThrow(InvalidKeyException.class);
void propagatesInvalidKeyException() throws InvalidKeyException, JsonProcessingException {
given(mockWebhookMessageSignatureGenerator.generate(objectMapper.writeValueAsString(WebhookMessageBody.from(webhookMessageEntity)), SIGNING_KEY)).willThrow(InvalidKeyException.class);
assertThrows(InvalidKeyException.class, () -> webhookMessageSender.sendWebhookMessage(webhookMessageEntity));
}

Expand Down
8 changes: 6 additions & 2 deletions src/test/java/uk/gov/pay/webhooks/queue/EventQueueIT.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uk.gov.pay.webhooks.queue;

import com.amazonaws.services.sqs.AmazonSQS;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -16,6 +17,8 @@

import java.util.List;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
Expand Down Expand Up @@ -49,7 +52,7 @@ public void shouldReceiveMessageFromTheQueue() throws QueueException {
}

@Test
public void shouldConvertValidMessageFromQueueToEventMessage() throws QueueException {
public void shouldConvertValidMessageFromQueueToEventMessage() throws QueueException, JsonProcessingException {
var sqsMessage = """
{
"Type" : "Notification",
Expand All @@ -58,7 +61,7 @@ public void shouldConvertValidMessageFromQueueToEventMessage() throws QueueExcep
"TopicArn" : "card-payment-events-topic",
"Timestamp" : "2021-12-16T18:52:27.068Z",
"SignatureVersion" : "1",
"Signature" : "qYWtXrARHosfc5wgMSJLKofdn2q+QJIEs+XfpIBCFp94VHOujQBjVRGpQkr8OVF07lONWakkuKvdzcRIwRExtuCNDVyJinJGJgQEAFKpKmnJ9TfBsraTI5cmWAxnHv/wyYkK948QNe3DkdmascRU6ldKSIaZJ2k46cJfjtIkspMZ2tOuen39bd5pASXrGLyi9eq8HsZNY1IhD5OqDBtl+eLZ5DtJNdbroYF6+lg0f9K40fZIiPhFBtJPQoNZjCIwEb3VCG4o+OF7ga1gEOAj0se5BUXiMYWWT2zfusFBoxoecA/nout33sFS4NxGAsoKEkQlqxChSK1Lr/XTvZ77SA==",
"Signature" : "some-signature",
"SigningCertURL" : "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-signing-cert-uuid.pem",
"UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:a-aws-arn"
}
Expand All @@ -80,5 +83,6 @@ public void shouldConvertValidMessageFromQueueToEventMessage() throws QueueExcep

List<EventMessage> result = eventQueue.retrieveEvents();
assertFalse(result.isEmpty());
assertThat(result.get(0).eventMessageDto().resourceType(), is("payment"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public void shouldReturnSubscribedWebhooksForGivenEventTypeServiceIdLivenessTupl
var webhookNotSubscribedToAnyEvents = new WebhookEntity();
when(webhookDao.list(live, serviceId))
.thenReturn(List.of(webhookSubscribedToCaptureEvent, webhookNotSubscribedToAnyEvents));
var event = new InternalEvent("CAPTURE_CONFIRMED", serviceId, live, "resource_id", null, null, ZonedDateTime.now());
var event = new InternalEvent("CAPTURE_CONFIRMED", serviceId, live, "resource_id", null, null, instantSource.instant(), "PAYMENT");

var subscribedWebhooks = webhookService.getWebhooksSubscribedToEvent(event);

Expand Down

0 comments on commit 2cffec3

Please sign in to comment.