Thursday, 10 January 2019

Holiday Price Scraper (Serverless Architecture - FaaS) - Part 2


Phase 2


So this article follows on from part one which you can read here: http://dale-dev.blogspot.com/2019/01/holiday-price-scraper-serverless.html, in phase 2 I want to accomplish the following things...

  • The function in AWS is not working when running when triggered by the timer, this needs resolving.
  • The function in AWS is not persisting the history of retrieved prices, I will be creating a DynamoDB DAO to persist this data.
  • Search criteria to be persisted as saved searches rather than hard coded.


AWS Trigger issue


The timer job I set up in AWS which triggers the function fails, the error from the log shows the following...


 REPORT RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b     Duration: 442.69 ms     Billed Duration: 500 ms Memory Size: 512 MB     Max Memory Used: 98 MB       
 START RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b Version: $LATEST  
 An error occurred during JSON parsing: java.lang.RuntimeException  
 java.lang.RuntimeException: An error occurred during JSON parsing  
 Caused by: java.io.UncheckedIOException: com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.lang.String out of START_OBJECT token  
      at [Source: lambdainternal.util.NativeMemoryAsInputStream@78b729e6; line: 1, column: 1]  
 Caused by: com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.lang.String out of START_OBJECT token  
      at [Source: lambdainternal.util.NativeMemoryAsInputStream@78b729e6; line: 1, column: 1]  
      at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:148)  
      at com.fasterxml.jackson.databind.DeserializationContext.mappingException(DeserializationContext.java:857)  
      at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:62)  
      at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:11)  
      at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:1511)  
      at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1102)  
 END RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b  


From that, it's showing that there is a problem de-serializing the input from the trigger. I didn't actually configure an input, I assumed a null value would be passed and everything would be ok but I assumed wrong (What's that they say about assumptions?).

So going back into AWS, I can see there is an option to configure the input passed from the cloud event so I made the following change





DynamoDB Persistence

Setup


Under DynamoDB, I have selected "Create Table" and enter the following...

Table Name : PriceHistory
Primary key : searchUUID



New DynamoDB DAO


Following this guide: https://www.javaworld.com/article/3248595/application-development/serverless-computing-with-aws-lambda-part-2.amp.html I created the following code

pom.xml

     <dependency>  
       <groupId>com.amazonaws</groupId>  
       <artifactId>aws-java-sdk-dynamodb</artifactId>  
       <version>1.11.475</version>  
     </dependency>  


DynamoDBPriceStoreDao

 package com.daledev.holidaypricescrapper.dao.aws;  
 import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;  
 import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;  
 import com.amazonaws.services.dynamodbv2.document.*;  
 import com.daledev.holidaypricescrapper.dao.PriceStoreDao;  
 import com.daledev.holidaypricescrapper.domain.PriceHistory;  
 import org.slf4j.Logger;  
 import org.slf4j.LoggerFactory;  
 import org.springframework.stereotype.Repository;  
 import java.util.ArrayList;  
 import java.util.List;  
 import java.util.UUID;  
 /**  
  * @author dale.ellis  
  * @since 04/01/2019  
  */  
 @Repository  
 public class DynamoDBPriceStoreDao implements PriceStoreDao {  
   private final Logger log = LoggerFactory.getLogger(this.getClass());  
   @Override  
   public List<PriceHistory> getPriceHistories() {  
     List<PriceHistory> histories = new ArrayList<>();  
     log.debug("Scanning For all PriceHistory items");  
     ItemCollection<ScanOutcome> scanResults = getHistoryTable().scan();  
     for (Item item : scanResults) {  
       log.debug("Item {} from results returned in results", item);  
       PriceHistory priceHistory = PriceHistoryMapper.mapToPriceHistory(item);  
       if (priceHistory != null) {  
         histories.add(priceHistory);  
       }  
     }  
     log.debug("Returning {} price histories", histories.size());  
     return histories;  
   }  
   @Override  
   public PriceHistory getPriceHistory(String searchUuid) {  
     log.debug("Attempting to retrieve PriceHistory with ID : {}", searchUuid);  
     Item persistedHistory = getHistoryTable().getItem(PriceHistoryMapper.PRIMARY_KEY_NAME, searchUuid);  
     if (persistedHistory != null) {  
       log.debug("History found in DynamoDB. {}", persistedHistory.toJSONPretty());  
       return PriceHistoryMapper.mapToPriceHistory(persistedHistory);  
     }  
     return new PriceHistory();  
   }  
   @Override  
   public void storePrice(PriceHistory priceHistory) {  
     log.debug("Storing history with ID : {}", priceHistory.getSearchUUID());  
     Item item = PriceHistoryMapper.mapFromPriceHistory(priceHistory);  
     PutItemOutcome putItemOutcome = getHistoryTable().putItem(item);  
     log.debug("Create response : {}", putItemOutcome);  
   }  
   private Table getHistoryTable() {  
     log.debug("Creating Table API object for table : {}", PriceHistoryMapper.TABLE_NAME);  
     DynamoDB dynamoDB = getDynamoDB();  
     return dynamoDB.getTable(PriceHistoryMapper.TABLE_NAME);  
   }  
   private DynamoDB getDynamoDB() {  
     log.debug("Creating AmazonDynamoDB client");  
     AmazonDynamoDB client = AmazonDynamoDBClientBuilder.defaultClient();  
     log.debug("AmazonDynamoDB client : {}", client);  
     return new DynamoDB(client);  
   }  
 }  


Got the following error when running the function in AWS ...

 User: arn:aws:sts::675239832622:assumed-role/holidayCheckRole/holidayPriceCheck is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:eu-west-2:675239832622:table/PriceHistory (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: AccessDeniedException; Request ID: K8T3CA4G8RMTBINN546UDMI5RFVV4KQNSO5AEMVJF66Q9ASUAAJG): com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException  
 com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException: User: arn:aws:sts::675239832622:assumed-role/holidayCheckRole/holidayPriceCheck is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:eu-west-2:675239832622:table/PriceHistory (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: AccessDeniedException; Request ID: K8T3CA4G8RMTBINN546UDMI5RFVV4KQNSO5AEMVJF66Q9ASUAAJG)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.handleErrorResponse(AmazonHttpClient.java:1695)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeOneRequest(AmazonHttpClient.java:1350)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeHelper(AmazonHttpClient.java:1101)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.doExecute(AmazonHttpClient.java:758)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeWithTimer(AmazonHttpClient.java:732)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.execute(AmazonHttpClient.java:714)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.access$500(AmazonHttpClient.java:674)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutionBuilderImpl.execute(AmazonHttpClient.java:656)  
     at com.amazonaws.http.AmazonHttpClient.execute(AmazonHttpClient.java:520)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.doInvoke(AmazonDynamoDBClient.java:4192)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.invoke(AmazonDynamoDBClient.java:4159)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.executeGetItem(AmazonDynamoDBClient.java:2028)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.getItem(AmazonDynamoDBClient.java:1995)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.doLoadItem(GetItemImpl.java:77)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItemOutcome(GetItemImpl.java:40)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItemOutcome(GetItemImpl.java:99)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItem(GetItemImpl.java:111)  
     at com.amazonaws.services.dynamodbv2.document.Table.getItem(Table.java:624)  
     at com.daledev.holidaypricescrapper.dao.aws.DynamoDBPriceStoreDao.getPriceHistory(DynamoDBPriceStoreDao.java:28)  
     at com.daledev.holidaypricescrapper.service.PriceRetrieverServiceImpl.priceCheck(PriceRetrieverServiceImpl.java:82)  
     at com.daledev.holidaypricescrapper.function.PriceCheckFunction.apply(PriceCheckFunction.java:30)  
     at com.daledev.holidaypricescrapper.function.PriceCheckFunction.apply(PriceCheckFunction.java:15)  
     at org.springframework.cloud.function.core.FluxFunction.lambda$apply$0(FluxFunction.java:47)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:107)  
     at reactor.core.publisher.FluxJust$WeakScalarSubscription.request(FluxJust.java:99)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.request(FluxMapFuseable.java:156)  
     at reactor.core.publisher.BlockingIterable$SubscriberIterator.onSubscribe(BlockingIterable.java:214)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onSubscribe(FluxMapFuseable.java:90)  
     at reactor.core.publisher.FluxJust.subscribe(FluxJust.java:70)  
     at reactor.core.publisher.FluxMapFuseable.subscribe(FluxMapFuseable.java:63)  
     at reactor.core.publisher.Flux.subscribe(Flux.java:6877)  
     at reactor.core.publisher.BlockingIterable.iterator(BlockingIterable.java:80)  
     at org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler.result(SpringBootRequestHandler.java:54)  
     at org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler.handleRequest(SpringBootRequestHandler.java:49)  

The lambda function required the role is assigned to have AmazonDynamoDBFullAccess granted, I added this in the AWS IAM (Identity and Access Management) area.


Persist Search Criteria

I modified the JSON document to also hold the search criteria as well as the history of price changes, example content...

 {  
  "searchUUID" : "495f1431-5526-4083-997d-f91849be966b",  
  "criterion" : {  
   "description" : "Holiday Village in KOS 1 week in May-June 2019",  
   "startDate" : "2019-05-01T00:00:00.000+0000",  
   "endDate" : "2019-06-30T00:00:00.000+0000",  
   "airports" : [ {  
    "code" : "STN",  
    "uniqueId" : null  
   } ],  
   "duration" : 7,  
   "accommodationRef" : "047406:HOTEL",  
   "numberOfAdults" : 2,  
   "childrenAges" : [ 2 ]  
  },  
  "history" : [ {  
   "firstRetrievedDate" : "2019-01-06T21:35:59.700+0000",  
   "lastRetrievedDate" : "2019-01-06T21:53:00.319+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-08T00:00:00.000+0000",  
    "price" : 613.0  
   } ]  
  }, {  
   "firstRetrievedDate" : "2019-01-07T14:16:22.610+0000",  
   "lastRetrievedDate" : "2019-01-07T17:47:00.076+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-08T00:00:00.000+0000",  
    "price" : 611.0  
   } ]  
  }, {  
   "firstRetrievedDate" : "2019-01-08T12:34:23.896+0000",  
   "lastRetrievedDate" : "2019-01-08T12:34:23.896+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-07T23:00:00.000+0000",  
    "price" : 613.0  
   } ]  
  } ],  
  "subscribers" : [ "dale.ellis@netcall.com", "daleellis1983@gmail.com" ],  
  "active" : true  
 }  


Other Changes


I did a pretty major refactor of my project, I split my code into a multi-module project, the issue I was having was that I was bundling the controller code for running the app locally in the AWS build, when running locally, the way I had it setup wouldn't compile as there were AWS dependencies that where mandatory which weren't included in a local build so I was getting errors.

New structure...




Module NameDescription
holiday-price-scraper-awsDynamoDB implementation for persistence, Spring cloud function adapter for AWS.
holiday-price-scraper-bootMain application, business logic
holiday-price-scraper-local-restFile system implementation for persistence, REST API to test locally
holiday-price-scraper-persistenceContains domain objects and DAO interfaces

Source Code



What's Next in Part 3


The desire is to be able to deploy this to other FaaS providers, but before I tackle Azure, I would like to tidy up some loose ends first...
  • AWS fails to send emails via Gmail, I have allowed insecure apps in Gmail enabled and still email fails to send, will setup sending emails using SES.
  • Implement email templates to use when sending an email with all the data that I would like to see.
  • Handle when the holiday is no longer available.
  • Complete README.md file

No comments:

Post a Comment