Skip to content

Commit

Permalink
Provide a Lambda to update our cache of Feast subscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
tomwadeson committed Mar 21, 2024
1 parent 308f67b commit f87e0a2
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 5 deletions.
44 changes: 44 additions & 0 deletions typescript/src/feast/update-subs/updatesubs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { SQSEvent, SQSRecord } from "aws-lambda";
import { validateReceipt } from "../../services/appleValidateReceipts";
import { AppleSubscriptionReference } from "../../models/subscriptionReference";
import { toAppleSubscription } from "../../update-subs/apple";
import { Subscription } from "../../models/subscription";
import { dynamoMapper } from "../../utils/aws";
import { App } from "../../models/app"

const decodeSubscriptionReference =
(record: SQSRecord): AppleSubscriptionReference => {
return JSON.parse(record.body) as AppleSubscriptionReference;
}

const defaultFetchSubscriptionsFromApple =
(reference: AppleSubscriptionReference): Promise<Subscription[]> => {
return validateReceipt(reference.receipt, {sandboxRetry: false}, App.Feast).then(subs => subs.map(toAppleSubscription))
}

const defaultStoreSubscriptionInDynamo =
(subscription: Subscription): Promise<void> => {
return dynamoMapper.put({item: subscription}).then(_ => {})
}

export function buildHandler(
fetchSubscriptionsFromApple: (reference: AppleSubscriptionReference) => Promise<Subscription[]> = defaultFetchSubscriptionsFromApple,
storeSubscriptionInDynamo: (subscription: Subscription) => Promise<void> = defaultStoreSubscriptionInDynamo
): (event: SQSEvent) => Promise<string> {
return async (event: SQSEvent) => {
const work =
event.Records.map(async record => {
const reference =
decodeSubscriptionReference(record)

const subscriptions =
await fetchSubscriptionsFromApple(reference)

await Promise.all(subscriptions.map(storeSubscriptionInDynamo))
})

return Promise.all(work).then(_ => "OK")
}
}

export const handler = buildHandler();
4 changes: 4 additions & 0 deletions typescript/src/models/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum App {
Live = "live",
Feast = "feast"
}
18 changes: 14 additions & 4 deletions typescript/src/services/appleValidateReceipts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {Option} from "../utils/option";
import {restClient} from "../utils/restClient";
import {IHttpClientResponse} from "typed-rest-client/Interfaces";
import {GracefulProcessingError} from "../models/GracefulProcessingError";
import {App} from "../models/app"

export interface PendingRenewalInfo {
auto_renew_product_id?: string,
Expand Down Expand Up @@ -77,9 +78,18 @@ const sandboxReceiptEndpoint = "https://sandbox.itunes.apple.com/verifyReceipt";
const prodReceiptEndpoint = "https://buy.itunes.apple.com/verifyReceipt";
const receiptEndpoint = (Stage === "PROD") ? prodReceiptEndpoint : sandboxReceiptEndpoint;

function callValidateReceipt(receipt: string, forceSandbox: boolean = false): Promise<IHttpClientResponse> {
function passwordForApp(app: App): Promise<string> {
switch (app) {
case App.Live:
return getConfigValue<string>("apple.password")
case App.Feast:
return getConfigValue<string>("feast.apple.password")
}
}

function callValidateReceipt(receipt: string, forceSandbox: boolean = false, app: App = App.Live): Promise<IHttpClientResponse> {
const endpoint = forceSandbox ? sandboxReceiptEndpoint : receiptEndpoint;
return getConfigValue<string>("apple.password")
return passwordForApp(app)
.then(password => {
const body = JSON.stringify({
"receipt-data": receipt,
Expand Down Expand Up @@ -225,8 +235,8 @@ async function retryInSandboxIfNecessary(parsedResponse: AppleValidationServerRe
}
}

export function validateReceipt(receipt: string, options: ValidationOptions): Promise<AppleValidationResponse[]> {
return callValidateReceipt(receipt)
export function validateReceipt(receipt: string, options: ValidationOptions, app: App = App.Live): Promise<AppleValidationResponse[]> {
return callValidateReceipt(receipt, false, app)
.then(response => response.readBody())
.then(body => JSON.parse(body))
.then(body => body as AppleValidationServerResponse)
Expand Down
2 changes: 1 addition & 1 deletion typescript/src/update-subs/apple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {AppleValidationResponse, validateReceipt} from "../services/appleValidat
import {fromAppleBundle} from "../services/appToPlatform";
import {PRODUCT_BILLING_PERIOD} from "../services/productBillingPeriod";

function toAppleSubscription(response: AppleValidationResponse): Subscription {
export function toAppleSubscription(response: AppleValidationResponse): Subscription {
const latestReceiptInfo = response.latestReceiptInfo;

let autoRenewStatus: boolean = false;
Expand Down
69 changes: 69 additions & 0 deletions typescript/tests/feast/update-subs/updatesubs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@

import { SQSEvent } from "aws-lambda";
import { buildHandler } from "../../../src/feast/update-subs/updatesubs";
import { Subscription } from "../../../src/models/subscription";
import { AppleSubscriptionReference } from "../../../src/models/subscriptionReference";

describe("The Feast (Apple) subscription updater", () => {
it("Should fetch the subscription(s) associated with the reference from Apple and persist them to Dynamo", async () => {
const event =
buildSqsEvent(["TEST_RECEIPT_1", "TEST_RECEIPT_2"])

const handler =
buildHandler(stubFetchSubscriptionsFromApple, mockStoreSubscriptionInDynamo)

const result =
await handler(event)

expect(mockStoreSubscriptionInDynamo.mock.calls.length).toEqual(3)

const storedSubscriptionIds =
mockStoreSubscriptionInDynamo.mock.calls.map(call => call[0].subscriptionId)

// The receipts `"TEST_RECEIPT_1"` & `"TEST_RECEIPT_2"` together reference the subscriptions with IDs
// `"sub-1"`, `"sub-2"` & `"sub-3"`. Importantly, `"sub-4"` is referenced by the receipt `"TEST_RECEIPT_3"`,
// which is not present in the event payload and therefore not looked up and persisted to the Dynamo table.
expect(storedSubscriptionIds).toEqual(["sub-1", "sub-2", "sub-3"])
});
});

const buildSqsEvent = (receipts: string[]): SQSEvent => {
const records = receipts.map(receipt =>
({
messageId: "",
receiptHandle: "",
body: JSON.stringify({ receipt: receipt }),
attributes: {
ApproximateReceiveCount: "",
SentTimestamp: "",
SenderId: "",
ApproximateFirstReceiveTimestamp: "",
},
messageAttributes: {},
md5OfBody: "",
eventSource: "",
eventSourceARN: "",
awsRegion: "",
}))

return {
Records: records
}
}

const mockStoreSubscriptionInDynamo =
jest.fn((subscription: Subscription) => Promise.resolve());

const stubFetchSubscriptionsFromApple =
(reference: AppleSubscriptionReference) => Promise.resolve(subscriptions.filter(s => s.receipt == reference.receipt));

const subscription =
(id: string, product: string, receipt: string) =>
new Subscription(id, "", "", "", false, product, "ios-feast", false, "6M", null, receipt, null)

const subscriptions = [
subscription( "sub-1", "prod-1", "TEST_RECEIPT_1"),
subscription( "sub-2", "prod-1", "TEST_RECEIPT_2"),
subscription( "sub-3", "prod-2", "TEST_RECEIPT_2"),
subscription( "sub-4", "prod-1", "TEST_RECEIPT_3")
]

0 comments on commit f87e0a2

Please sign in to comment.