How can I make an Idempotent Callable Function with Firebase Firestore? - javascript

Sometimes I'm getting duplicated documents from a callable function that looks like this:
const { default: Big } = require('big.js');
const { firestore } = require('firebase-admin');
const functions = require('firebase-functions');
const { createLog } = require('./utils/createLog');
const { payCart } = require('./utils/payCart');
const { unlockCart } = require('./utils/unlockCart');
exports.completeRechargedTransaction = functions.https.onCall(
async (data, context) => {
try {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'unauthenticated'
);
}
const requiredProperties = [
'foo',
'bar',
'etc'
];
const isDataValid = requiredProperties.every(prop => {
return Object.keys(data).includes(prop);
});
if (!isDataValid) {
throw new functions.https.HttpsError(
'failed-precondition',
'failed-precondition'
);
}
const transactionRef = firestore()
.collection('transactions')
.doc(data.transactionID);
const userRef = firestore().collection('users').doc(data.paidBy.userID);
let currentTransaction = null;
await firestore().runTransaction(async transaction => {
try {
const transactionSnap = await transaction.get(transactionRef);
if (!transactionSnap.exists) {
throw new functions.https.HttpsError(
'not-found',
'not-found'
);
}
const transactionData = transactionSnap.data();
if (transactionData.status !== 'recharged') {
throw new functions.https.HttpsError(
'invalid-argument',
'invalid-argument'
);
}
if (transactionData.type !== 'recharge') {
throw new functions.https.HttpsError(
'invalid-argument',
'invalid-argument'
);
}
if (transactionData.paidBy === null) {
throw new functions.https.HttpsError(
'invalid-argument',
'invalid-argument',
);
}
const userSnap = await transaction.get(userRef);
if (!userSnap.exists) {
throw new functions.https.HttpsError(
'not-found',
'not-found',
);
}
const userData = userSnap.data();
const newUserPoints = new Big(userData.points).plus(data.points);
if (!data.isGoldUser) {
transaction.update(userRef, {
points: parseFloat(newUserPoints.toFixed(2))
});
}
currentTransaction = {
...data,
remainingBalance: parseFloat(newUserPoints.toFixed(2)),
status: 'completed'
};
transaction.update(transactionRef, currentTransaction);
} catch (error) {
console.error(error);
throw error;
}
});
const { paymentMethod } = data.rechargeDetails;
let cashAmount = 0;
if (paymentMethod && paymentMethod.paymentMethod === 'cash') {
cashAmount = data.points;
}
let cartResponse = null;
if (
data.rechargeDetails.isProcessingCart &&
Boolean(data.paidBy.userID) &&
!data.isGoldUser
) {
cartResponse = await payCart(context, data.paidBy.userID, cashAmount);
// This is the function that does all the writes and for some reason it is getting
// called twice or thrice in some rare cases, and I'm pretty much sure that
// The Angular Client is only calling this function "completeRechargedTransaction " once.
}
await createLog({
message: 'Success',
createdAt: new Date(),
type: 'activity',
collectionName: 'transactions',
callerID: context.auth.uid || null,
docID: transactionRef.id
});
return {
code: 200,
message: 'Success',
transaction: currentTransaction,
cartResponse
};
} catch (error) {
console.error(error);
await unlockCart(data.paidBy.userID);
await createLog({
message: error.message,
createdAt: new Date(),
type: 'error',
collectionName: 'transactions',
callerID: context.auth.uid || null,
docID: data.transactionID,
errorSource:
'completeRechargedTransaction'
});
throw error;
}
}
);
I'm reading a lot of firebase documentation, but I can't find a solution to implement idempotency on my callable functions, the context parameter in callable function is very different from background functions and triggers, the callable context looks like this:
https://firebase.google.com/docs/reference/functions/providers_https_.callablecontext
I did find a helpful blogpost to implement idempotency with firebase triggers:
Cloud Functions pro tips: Building idempotent functions
But I don't fully understand this approach because I think it's assuming that the document writes are made on the client aka the front end application, and I don't really think that's a good approach because is it too reliant on the client and I'm afraid of security issues as well.
So yeah, I would like to know is there's a way to implement Idempotency on Callable Functions, I need something like an EventID but for callable functions to safely implement payments on my app and third party apis, such as stripe.
I will appreciate any help or hint you can give me.

The use of idempotent functions mainly applies to the automatically triggered Cloud Functions that respond to events such as a file uploaded to Cloud Storage or document added to Firestore. In these cases, the event triggers the function to be executed, and if the function succeeds, all is well. However, if the function fails, it will get retried automatically which leads to the problems discussed in the blog post you linked.
In the case of user-triggered cloud functions (a HTTPS Event or Callable cloud function), these are not retried automatically. It is left up to the caller of these functions to choose to handle any errors and whether they are retried by the client calling the function again.
As these user-triggered functions are only executed by your client code, you should check to make sure that completeRechargedTransaction() isn't being called more than once. A method of testing this is to supply your own value for Event ID prior to calling the function like so:
// using a firebase push ID as a UUID
// could also use someFirestoreCollectionReference.doc().id or uuid()
const eventId = firebase.database.ref().push().key;
completeRechargedTransaction({
eventId,
/* ... other data ... */
})
.then(console.log.bind(null, "Successfully completed recharged transaction:"))
.catch(console.error.bind(null, "Failed to complete recharged transaction:"));
Note: One of the most common ways functions will get called twice by the client is because of rerenders where you've updated the state to show a "loading" message and then your call to the function gets made a second time. As an example for React, you would make sure your database call is wrapped in it's own useEffect() call.

Related

Issue with seting up a pub/ sub implementation with Pubnub

First off let me start by saying I am a web developer who is very new to the Blockchain space so I apologize in advance if I am missing something obvious. With that being said I am having issues implementing a broadcast transaction method into a cryptocurrency project I am working on. Every time I am able to successfully make transaction requests to my API and I am able to see the correct transaction pool on the main dev network, however my peer network does not see any new transactions until I restart it. I know this means my broadcast transaction implementation is missing something but I am not sure what I need to fix it.
please refer to the following images
Here is the code snippets for my pubsub implementation using PUBNUB
const CHANNELS = {
TEST: "TEST",
BLOCKCHAIN: "BLOCKCHAIN",
TRANSACTION: "TRANSACTION"
};
class PubSub {
constructor({blockchain, transactionPool}) {
this.blockchain = blockchain;
this.transactionPool = transactionPool;
this.pubnub = new PubNub(credentials); // defined above but omitted for obvious reasons
this.pubnub.subscribe({channels: Object.values(CHANNELS)}); // defined in channels object
this.pubnub.addListener(this.listener());
};
listener() {
return {
message: messageObject => {
const { channel, message } = messageObject;
console.log(`Message received. Channel: ${channel}. Message: ${message}`);
const parsedMessage = JSON.parse(message);
switch(channel) {
case CHANNELS.BLOCKCHAIN:
this.blockchain.replaceChain(parsedMessage);
break;
case CHANNELS.TRANSACTION:
this.transactionPool.setTransaction(parsedMessage);
break;
default:
return;
}
}
};
}
publish({channel, message}) {
this.pubnub.unsubscribe(channel, () => {
this.pubnub.publish(channel, message, () => {
this.pubnub.subscribe(channel);
});
});
}
broadcastChain() {
this.publish({
channel: CHANNELS.BLOCKCHAIN,
message: JSON.stringify(this.blockchain.chain)
})
}
broadcastTransaction(transaction) {
this.publish({
channel: CHANNELS.TRANSACTION,
message: JSON.stringify(transaction)
});
}
};
And here is the snippets for where the broadcastTransaction method is called
const pubsub = new PubSub({blockchain, transactionPool});
let transaction = transactionPool.existingTransaction({inputAddress: wallet.publicKey}); // Creates global binding
// Sends transaction to the network
app.post("/api/transact", (req, res) => {
const {amount, recipient} = req.body;
try {
if(transaction) {
transaction.update({senderWallet: wallet, recipient, amount });
} else {
transaction = wallet.createTransaction({recipient, amount});
}
} catch(error) {
return res.status(400).json({type: "error", message: error.message});
};
transactionPool.setTransaction(transaction);
pubsub.broadcastTransaction(transaction); // Calls broadcastTransaction from pubsub class (does not work for peers)
res.json({type: "success", transaction});
});
I tried to be as specific as possible but If I missed anything please let me know. Thank you in advance!
Thank you for the input. logging helped me realize that my dev and peer ports were not subscribed when starting up the nodes. I added the following method to the PubSub class and was able to sync both the dev and peer ports
subscribeToChannels() {
this.pubnub.subscribe({
channels: [Object.values(CHANNELS)]
});
}

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received an instance of Object

I am using the source code from a security rules tutorial to attempt to do integration testing with Jest for my Javascript async function async_create_post, used for my firebase HTTP function create_post The files involved has a directory structure of the following:
Testing file: root/tests/handlers/posts.test.js
File to be tested: root/functions/handlers/posts.js
Helper code from the tutorial: root/tests/rules/helpers.js
And here is the source code that is involved:
posts.test.js
const { setup, teardown} = require("../rules/helpers");
const {
async_get_all_undeleted_posts,
async_get_post,
async_delete_post,
async_create_post
} = require("../../functions/handlers/posts");
describe("Post Creation", () => {
afterEach(async () => {
await teardown();
});
test("should create a post", async () => {
const db = await setup();
const malloryUID = "non-existent uid";
const firstPost = {
body: "First post from Mallory",
author_id: malloryUID,
images: ["url1", "url2"]
}
const before_post_snapshot = await db.collection("posts").get();
expect(before_post_snapshot.docs.length).toBe(0);
await async_create_post(firstPost); //fails at this point, expected to create a new post, but instead threw an error
const after_post_snapshot = await db.collection("posts").get();
expect(after_post_snapshot.docs.length).toBe(1);
});
});
posts.js
const {admin, db } = require('../util/admin');
//admin.initializeApp(config); //my credentials
//const db = admin.firestore();
const { uuid } = require("uuidv4");
const {
success_response,
error_response
} = require("../util/validators");
exports.async_create_post = async (data, context) => {
try {
const images = [];
data.images.forEach((url) => {
images.push({
uid: uuid(),
url: url
});
})
const postRecord = {
body: data.body,
images: images,
last_updated: admin.firestore.FieldValue.serverTimestamp(),
like_count: 0,
comment_count: 0,
deleted: false,
author_id: data.author_id
};
const generatedToken = uuid();
await db
.collection("posts")
.doc(generatedToken)
.set(postRecord);
// return success_response();
return success_response(generatedToken);
} catch (error) {
console.log("Error in creation of post", error);
return error_response(error);
}
}
When I run the test in Webstorm IDE, with 1 terminal running Firebase emulators:start , I get the following error message.
console.log
Error in creation of post TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received an instance of Object
at validateString (internal/validators.js:120:11)
at Object.basename (path.js:1156:5)
at GrpcClient.loadProto (/Users/isaac/Desktop/project/functions/node_modules/google-gax/src/grpc.ts:166:23)
at new FirestoreClient (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/v1/firestore_client.js:118:38)
at ClientPool.clientFactory (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/index.js:330:26)
at ClientPool.acquire (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/pool.js:87:35)
at ClientPool.run (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/pool.js:164:29)
at Firestore.request (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/index.js:961:33)
at WriteBatch.commit_ (/Users/isaac/Desktop/project/functions/node_modules/#google-cloud/firestore/build/src/write-batch.js:485:48)
at exports.async_create_post (/Users/isaac/Desktop/project/functions/handlers/posts.js:36:5) {
code: 'ERR_INVALID_ARG_TYPE'
}
at exports.async_create_post (/Users/isaac/Desktop/project/functions/handlers/posts.js:44:13)
Error: expect(received).toBe(expected) // Object.is equality
Expected: 1
Received: 0
<Click to see difference>
at Object.<anonymous> (/Users/isaac/Desktop/project/tests/handlers/posts.test.js:59:45)
Error in creation of post comes from the console.log("Error in creation of post", error); in posts.js, so the error is shown in the title of this post.
I want to know why calling the async_create_post from posts.test.js will cause this error and does not populate my database with an additional record as expected behaviour. Do inform me if more information is required to solve the problem.
Here are some code snippets that may give more context.
helpers.js [Copied from the repository]
const firebase = require("#firebase/testing");
const fs = require("fs");
module.exports.setup = async (auth, data) => {
const projectId = `rules-spec-${Date.now()}`;
const app = firebase.initializeTestApp({
projectId,
auth
});
const db = app.firestore();
// Apply the test rules so we can write documents
await firebase.loadFirestoreRules({
projectId,
rules: fs.readFileSync("firestore-test.rules", "utf8")
});
// write mock documents if any
if (data) {
for (const key in data) {
const ref = db.doc(key); // This means the key should point directly to a document
await ref.set(data[key]);
}
}
// Apply the actual rules for the project
await firebase.loadFirestoreRules({
projectId,
rules: fs.readFileSync("firestore.rules", "utf8")
});
return db;
// return firebase;
};
module.exports.teardown = async () => {
// Delete all apps currently running in the firebase simulated environment
Promise.all(firebase.apps().map(app => app.delete()));
};
// Add extensions onto the expect method
expect.extend({
async toAllow(testPromise) {
let pass = false;
try {
await firebase.assertSucceeds(testPromise);
pass = true;
} catch (error) {
// log error to see which rules caused the test to fail
console.log(error);
}
return {
pass,
message: () =>
"Expected Firebase operation to be allowed, but it was denied"
};
}
});
expect.extend({
async toDeny(testPromise) {
let pass = false;
try {
await firebase.assertFails(testPromise);
pass = true;
} catch (error) {
// log error to see which rules caused the test to fail
console.log(error);
}
return {
pass,
message: () =>
"Expected Firebase operation to be denied, but it was allowed"
};
}
});
index.js
const functions = require('firebase-functions');
const {
async_get_all_undeleted_posts,
async_get_post,
async_delete_post,
async_create_post
} = require('./handlers/posts');
exports.create_post = functions.https.onCall(async_create_post);
The error message means that a method of the path module (like path.join) expects one of its arguments to be a string but got something else.
I found the offending line by binary search commenting the program until the error was gone.
Maybe one of your modules uses path and you supply the wrong arguments.

Cloudinary Signed Uploads with Widget

Documentation is extremely frustrating.
I'm using the upload widget to try to allow users to upload multiple pictures for their profile. I can't use unsigned uploads because of the potential for abuse.
I would much rather upload the file through the upload widget instead of through the server as it seems like it should be so simple
I've pieced together what I think should work but it is still saying: Upload preset must be whitelisted for unsigned uploads
Server:
// grab a current UNIX timestamp
const millisecondsToSeconds = 1000;
const timestamp = Math.round(Date.now() / millisecondsToSeconds);
// generate the signature using the current timestmap and any other desired Cloudinary params
const signature = cloudinaryV2.utils.api_sign_request({ timestamp }, CLOUDINARY_SECRET_KEY);
// craft a signature payload to send to the client (timestamp and signature required)
return signature;
also tried
return {
signature,
timestamp,
};
also tried
const signature = cloudinaryV2.utils.api_sign_request(
data.params_to_sign,
CLOUDINARY_SECRET_KEY,
);
Client:
const generateSignature = async (callback: Function, params_to_sign: object): Promise<void> => {
try {
const signature = await generateSignatureCF({ slug: 'xxxx' });
// also tried { slug: 'xxxx', params_to_sign }
callback(signature);
} catch (err) {
console.log(err);
}
};
cloudinary.openUploadWidget(
{
cloudName: 'xxx',
uploadPreset: 'xxxx',
sources: ['local', 'url', 'facebook', 'dropbox', 'google_photos'],
folder: 'xxxx',
apiKey: ENV.CLOUDINARY_PUBLIC_KEY,
uploadSignature: generateSignature,
},
function(error, result) {
console.log(error);
},
);
Let's all take a moment to point out how horrible Cloudinary's documentation is. It's easily the worst i've ever seen. Nightmare fuel.
Now that i've got that off my chest... I really needed to be able to do this and I spent way too long banging my head against walls for what should be extremely simple. Here it is...
Server (Node.js)
You'll need an endpoint that returns a signature-timestamp pair to the frontend:
import cloudinary from 'cloudinary'
export async function createImageUpload() {
const timestamp = new Date().getTime()
const signature = await cloudinary.utils.api_sign_request(
{
timestamp,
},
process.env.CLOUDINARY_SECRET
)
return { timestamp, signature }
}
Client (Browser)
The client makes a request to the server for a signature-timestamp pair and then uses that to upload a file. The file used in the example should come from an <input type='file'/> change event etc.
const CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME
const API_KEY = process.env.CLOUDINARY_API_KEY
async function uploadImage(file) {
const { signature, timestamp } = await api.post('/image-upload')
const form = new FormData()
form.append('file', file)
const res = await fetch(
`https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload?api_key=${API_KEY}&timestamp=${timestamp}&signature=${signature}`,
{
method: 'POST',
body: form,
}
)
const data = await res.json()
return data.secure_url
}
That's it. That's all it takes. If only Cloudinary had this in their docs.
Man. I hate my life. I finally figured it out. It literally took me beautifying the upload widget js to understand that the return of the function should be a string instead of an object even though the docs make it seem otherwise.
Here is how to implement a signed upload with a Firebase Cloud Function
import * as functions from 'firebase-functions';
import cloudinary from 'cloudinary';
const CLOUDINARY_SECRET_KEY = functions.config().cloudinary.key;
const cloudinaryV2 = cloudinary.v2;
module.exports.main = functions.https.onCall(async (data, context: CallableContext) => {
// Checking that the user is authenticated.
if (!context.auth) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError(
'failed-precondition',
'The function must be called while authenticated.',
);
}
try {
return cloudinaryV2.utils.api_sign_request(data.params_to_sign, CLOUDINARY_SECRET_KEY);
} catch (error) {
throw new functions.https.HttpsError('failed-precondition', error.message);
}
});
// CLIENT
const uploadWidget = () => {
const generateSignature = async (callback: Function, params_to_sign: object): Promise<void> => {
try {
const signature = await generateImageUploadSignatureCF({ params_to_sign });
callback(signature.data);
} catch (err) {
console.log(err);
}
};
cloudinary.openUploadWidget(
{
cloudName: 'xxxxxx',
uploadSignature: generateSignature,
apiKey: ENV.CLOUDINARY_PUBLIC_KEY,
},
function(error, result) {
console.log(error);
},
);
};

How do I call the function with a parameter I set up in firebase functions

I've deployed this code to my firebase functions project:
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
admin.initializeApp()
export const getEmail = functions.https.onRequest((request, response) => {
var from = request.body.sender;
admin.auth().getUserByEmail(from)
.then(snapshot => {
const data = snapshot.toJSON()
response.send(data)
})
.catch(error => {
//Handle error
console.log(error)
response.status(500).send(error)
})
})
Which takes in a email parameter that it gets from the user's input on my app. My app's code looks like this:
Functions.functions().httpsCallable("https://us-central1-projectname.cloudfunctions.net/getEmail").call(email) { (result, error) in
if let error = error as NSError? {
if error.domain == FunctionsErrorDomain {
//email isnt taken
let code = FunctionsErrorCode(rawValue: error.code)
let message = error.localizedDescription
let details = error.userInfo[FunctionsErrorDetailsKey]
print(code, message, details)
}
// ...
}
if let text = (result?.data as? [String: Any])?["text"] as? String {
// email taken
}
}
When I run the app and when that function is called, it seems to do nothing, no error message is shown and no data has been sent back. What am I missing?
Update: I went to the logs and nothing has happened in there as if the function was never called.
You are actually mixing up HTTP Cloud Functions and Callable Cloud Functions:
You Cloud Function code corresponds to an HTTP one but the code in your front-end seems to call a Callable one.
You should adapt one or the other, most probably adapt your Cloud Function to a Callable one, along the following lines:
exports.getEmail = functions.https.onCall((data, context) => {
const from = data.sender;
return admin.auth().getUserByEmail(from)
.then(userRecord => {
const userData = userRecord.toJSON();
return { userData: userData };
})
});
Have a look at the doc for more details, in particular how to handle errors. The doc is quite detailed and very clear.

Resolving a "TypeError: Cannot read property 'data' of undefined" in Cloud Functions

Sorry if this seems like a really basic question, the concept of cloud functions is extremely new to me and i'm still highly in the learning process.
However, whilst trying to execute this cloud function i get the following error
TypeError: Cannot read property 'data' of undefined
Full log can be seen here
For reference as well, I didnt make this function, im just trying to get it working, i used this video.
The actual cloud function:
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
const firestore = admin.firestore();
const settings = { timestampInSnapshots: true };
firestore.settings(settings);
const stripe = require('stripe')(functions.config().stripe.token);
exports.addStripeSource =
functions.firestore.document('cards/{userid}/tokens/{tokenid}')
.onCreate(async (tokenSnap, context) => {
var customer;
const data = tokenSnap.after.data();
if (data === null) {
return null
}
const token = data.tokenId;
const snapchat = await
firestore.collection('cards').doc(context.params.userId).get();
const customerId = snapshot.data().custId;
const customerEmail = snpashot.data().email;
if (customerId === 'new') {
customer = await stripe.customers.create({
email: customerEmail,
source: token
});
firestore.collection('cards').doc(context.params.userId).update({
custId: customer.id
});
}
else {
customer = await stripe.customers.retrieve(customerId)
}
const customerSource = customer.sources.data[0];
return firestore.collection('cards').doc(context.params.userId).collection('sources').doc(customerSource.card.fingerprint).set(customersource, { merge: true });})
The dart code im using for writing a payment service:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class PaymentService {
addCard(token) {
FirebaseAuth.instance.currentUser().then((user) {
print("Found User");
Firestore.instance
.collection('cards')
.document(user.uid)
.collection('tokens')
.add({'tokenId': token}).then((val) {
print('saved');
});
});
}
}
And finally, what executes when i push the button:
StripeSource.addSource().then((String token) {
print("Stripe!");
PaymentService().addCard(token);
});
As you can see the code is clearly being triggered, but i guess there is some sort of error with the data var, JavaScript is brand new to me so im sure its some sort of very dumb syntax issue.
From the log image attached the error is context is not defined
functions.firestore.document('cards/{userid}/tokens/{tokenid}')
.onCreate(async (tokenSnap, conetxt) => {
In the above function, you have passed parameter as conetxt and later in the function context is used, because of which it is giving undefined error.
Change the parameter name conetxt to context.
As your provided log output explains : you need to define a reference for your firestore.document function :
functions.firestore.document('cards/{userid}/tokens/{tokenid}')
modify it to :
functions.firestore.documentReference(){
}

Categories

Resources