Skip to content

What happens when I return a promise in Node.js functions? #431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
christopheranderson opened this issue Aug 8, 2017 · 19 comments
Closed
Assignees

Comments

@christopheranderson
Copy link
Contributor

christopheranderson commented Aug 8, 2017

Needs more docs

@ggailey777
Copy link
Contributor

ggailey777 commented Aug 8, 2017

Check with Matt Mason. - /cc @mamaso

@jsturtevant
Copy link

Is there a difference between calling context.done() and returning a promise? Why would someone choose one over the other?

Examples of each approach:

@christopheranderson
Copy link
Contributor Author

I think these are the equivalences - it's all sugar:

context.done() -> promise that resolves without a value
context.done(err) -> promise that rejects with err
context.done(null, {}) -> promise that resolves with {}
context.done(err, {}) -> promise that rejects with err

@mamaso
Copy link

mamaso commented Aug 8, 2017

@christopheranderson yep, those are the semantics.

Code which applies the then and catch handlers here: https://github.com/Azure/azure-webjobs-sdk-script/blob/dev/src/WebJobs.Script/azurefunctions/functions.js#L96

I can see some potential confusion around $return

// $return: the resolved value is bound to $return binding
return Promise.resolve("bindingvalue");

// not $return: the resolved value is added to context.bindings, i.e. this will set context.binding.res & context.bindings.output
return Promise.resolve({ res: ..., output: ...})

@christopheranderson
Copy link
Contributor Author

That's a good catch in the $return semantics.

@mamaso mamaso closed this as completed Aug 8, 2017
@mamaso mamaso reopened this Aug 8, 2017
@mamaso
Copy link

mamaso commented Aug 8, 2017

To be fair $return is misleading in node - it never is what is returned from the function, only what is passed as the result to context.done

@jsturtevant
Copy link

Agreed on the $return value. It is bit confusing in the docs: https://docs.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings#using-the-function-return-type-to-return-a-single-output

It talks about the value but then there is no return called.

@christopheranderson
Copy link
Contributor Author

Should have made it like $output or something. Just shows that we lean a bit too much towards building for C# developers sometimes.

@iyerusad
Copy link

iyerusad commented Oct 8, 2018

I am really having a heck of time trying to wrap my head around return within context of Azure Functions and node. Admittedly, completely fresh to javascript, but after a weekend of banging away at basic examples, I am really struggling to get basic async working within Azure Functions.

I've tried the examples outlined using promises syntax or context syntax but neither actually worked for me.

After much too long of trial and error, I've put together a very rudimentary example of taking a query string from request (e.g. a URL) and pass it along to a function to fetch the website data and return the content:

const axios = require('axios');

module.exports = function(context, req) {
  async function GetURLdata (URL) {
    try {return await axios.get(URL)} 
    catch (error) {context.log('Error getting content' + error)}
  }

  GetURLdata(req.query.url)
  .then(function (response) {
    context.res = { body: response.data }
    context.done() 
  })
  .catch(function (error) {
    console.log(error)
  })

}

I'm actually surprised the above even works - Just about every attempt prior to this has resulted in the line for return await axios.get(URL) sending a context.done() (presumably since a promise was done).

I don't have much of a point here other than to +1 for some additional async javascript documentation within Azure functions. Personally, I'm finding stumbling blocks particularly around the return not behaving as one would expect.

@mhoeger
Copy link

mhoeger commented Oct 8, 2018

@iyerusad Yeah, we definitely need documentation on how you can assign an output binding to take the value of return in an exported async function. Will do :) In the meantime, I think your main issue that is your function.json is not configured correctly.

This should be an easier way to do what you were trying to do:
function.json:

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "$return"
    }
  ]
}

Note that the named value "$return" makes it so that the returned value in your function is assigned to that output binding (since you can have multiple output bindings per trigger).

index.js

const axios = require('axios');

module.exports = async function (context, req) {
    let url = req.query && req.query.url;
    // Recommended to check if url is null (return 400 if so)
    let response = await axios.get(url);
    return {
        body: response.data
    };
}

Note that I did not do a "try/catch", as you did, since unhandled errors are caught by the runtime and logged and will return a 500. If you did want to do custom error handling and logic around it, I would recommend something like below. Note that (mainly for clarity) I did not define a function within the exported function.

module.exports = async function (context, req) {
    let url = req.query && req.query.url;
    // Recommended to check if url is null (return 400 if so)
    let response = await GetURLdata(url, context.log); // This is where we await the axios Promise
    return {
        body: response.data
    };
}

// GetURLdata returns a Promise, since it is returning the output of axios.get
function GetURLdata(url, log) {
    try {
        // No need to await, since you are returning a Promise which is awaited later
        return axios.get(url);
    } catch (error) {
        // If you want to do any custom logic or error handling
        log('Error getting content ' + error);
        // It is still recommended you throw the error to keep it propagating
        throw error;
    }
}

@iyerusad
Copy link

iyerusad commented Oct 8, 2018

My function.json had contained res (which I believe came from when did func init). When I changed it to $return, the examples shown worked!

Thinking aloud: Changing the http->out name binding to use $return bound whatever the exported function returns (presumably because $return is a magic variable that is defined (or stays undefined) when a function completes). This makes sense. The try/catch error handling is noted and also understood.

Can you further expound on:

  1. Why did res as the name of the output binding seemingly screw up "normal" behavior expected of helper functions (e.g. GetURLdata)? Returns in helper functions now work as expected where as before they seemed to go function myfun () {return blah; //context.done() immediately runs). Really would like to understand how I seemingly shot myself so hard in foot here with such a small thing.

  2. Multiple output bindings per trigger - Can you provide a link or an example of a use case of how that would look? This seems like it would be helpful in better understanding the previous question.

Much appreciate the insight @mhoeger - I must of read several hundred posts on async and promises in the last few days and this actually is the first this started falling into place and functioning as expected.

@mhoeger
Copy link

mhoeger commented Oct 9, 2018

@iyerusad - You got it! $return is the "magic" variable name that corresponds to the result of the invoked function.

A few notes on the two points you mentioned:

  1. It's not so much the $return keyword that fixed things, but that I think it's easier to reason about Promises and what's being assigned when the output is assigned from your function's return.

This is also valid:
function.json

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ]
}

index.js

const axios = require('axios');

module.exports = async function (context, req) {
    let url = req.query && req.query.url;
    // Recommended to check if url is null (return 400 if so)
    let response = await axios.get(url);
    context.bindings.res = {
        body: response.data
    };
    return; // optional, as the return is implicit
}

This snippet of code from the azure functions internals that run your code may be helpful in understanding what our expectations around promises are. If a Promise is returned (an Object with a .then method... note that whatever is returned from async is implicitly a Promise), then we await that Promise's result and assign it to $return. If there's no return value, that's also totally fine! But your function will complete as soon as that Promise resolves (note that when your returned Promise resolves may not be when you expect it to resolve... This would lead to a case where the Promise your module.exports'ed function returned would resolve, but your context.bindings.whateverProperty has not been set yet. You may have already found this, but this is by far my favorite blogpost on Promises - hopefully you'll find it helpful too!

  1. Here's some text on multiple output bindings from here:

Input and output bindings provide a declarative way to connect to data from within your code. Bindings are optional and a function can have multiple input and output bindings.

A quick example:
function.json

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "name": "req",
      "type": "httpTrigger",
      "direction": "in"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    },
    {
      "type": "queue",
      "name": "outputQueueItem",
      "queueName": "outqueue",
      "connection": "CONNECTION_STRING_NAME",
      "direction": "out"
    }    
  ]
}

index.js:

module.exports = async function (context,req) {
    context.log('JavaScript HTTP trigger function processed a request.');
    var retMsg = 'Hello, world!';
    context.bindings.outputQueueItem =  retMsg;
    context.bindings.res = {
        body: retMsg
    };    
};

@iyerusad
Copy link

iyerusad commented Oct 10, 2018

I think it's easier to reason about Promises and what's being assigned when the output is assigned from your function's return

Agreed, so much so, this probably should make its way into default func init template(s) (particularly http one).

Thank you very much for the link from pouchDB developer around Promises - Some of the advanced mistakes outlined are still fuzzy, but it helps wrapping my head around what is apparently a better but still tricky approach for async code.

The multiple output bindings example is also very helpful: It probably would be very common to have an azure function return a 200 over http (aka "received your input!"), as well as toss something into a message bus for further processing.

Coming from a systems automation background, this quote (from the mentioned article) really resonates with me:

you shouldn't have to learn a bunch of arcane rules and new APIs to do things that, in the synchronous world, you can do perfectly well with familiar patterns like return, catch, throw, and for-loops. There shouldn't be two parallel systems that you have to keep straight in your head at all times.

Suggested take-away (for when writing docs): As a newbie, knowing to change host.json to use $return instead of res falls into category of somewhat arcane rule (albeit easy enough to understand once pointed to that direction) - prior to change, seemingly basic stuff wouldn't function as expected (promises or otherwise), even after consulting copious amount of literature on js/async/promises/etc. My opinion may change as my understanding advances (perhaps explicitly using $return in this scenario is an undesirable anti-pattern), but today I'd throw a +1 vote for having the defaults sticking to what I perceive to be the relatively better known concept of $return.

Much appreciate the guidance @mhoeger

@mhoeger
Copy link

mhoeger commented Oct 12, 2018

Ahh - yeah that's a very good point. Thanks for the feedback @iyerusad!! I'm torn between changing the template to $return, or returning a full object like return { res: { body: "Hello, world!" } }; in function code, which makes multiple output bindings more discoverable. In any case, agreed that we should change the template default + add docs on a comprehensive list of ways to do input/output bindings in node. Thanks again!

@mhoeger
Copy link

mhoeger commented Nov 14, 2018

@mhoeger mhoeger closed this as completed Nov 14, 2018
@iyerusad
Copy link

Leaving note for anyone else/future me (nothing actionable, just speaking aloud):

A dozen or so azure functions later, I see one pitfall of using $return:

await FunctionThatReturnsSomething(); // return is implied here

return "end of sequence"

Last return is never hit because await ... line has an implied return. Using res instead of $return would of behaved way I subconsciously expected (but poorly implemented):

await FunctionThatReturnsSomething(); // return is implied here

context.res = "end of sequence"

The above helped me better visualize why I would want to use res over $return as res would have to be explicitly returned, thus avoiding accidentally returning an earlier command response. Apologies if this was already stated and flew well over my head.

Also, random unexpected/but understandable thing:

obj = {
  prop1: "abc"
  body: "blah"
  prop457823: "999"
}

return obj

returns "blah". To return actual obj (with all its content), had to do this

obj = {
  prop1: "abc"
  body: "blah"
  prop457823: "999"
}

return {
  body: obj
}

@iyerusad
Copy link

iyerusad commented Feb 13, 2020

If anyone has the time and the patience, would you look through the below under a lens of a code/peer review? If not, no worries - at a basic level it works.

--
I'm roughly 2-3 dozen Azure Functions (JS/node) in, and wondering if could get a sanity check.

Below I drafted a basic wrapper to upload a blob to Azure Blob Storage (with the assumption such a basic function would be one off-ed like this).

From the standpoint of context.res usage:

  1. I use multiple if conditions to validate request parameters; It is effective in returning the exact failure conditions. I could use ternary operator here, but unless I'm mistaken its same result.
  • Q: Is this ideal or would you crack this nut in a different way? I could see context.res.body being concatenated (+=) as each error is encountered.
  1. I leave building/defining context.res until the "bottom" of the function, based on flipped "switches" of whether a file was stored or if any errors were encountered.
  • Q: In this kind of scenario, would you go into then/catch methods of the promise to define context.res rather then flipping a variable to true and defining context.res later?
    • If so, would you move the if (errors.length > 0) condition within the then/catch methods?
  • Q: Assume you have multiple promises (lets say; Check for existing blob, if doesn't exist -> Upload new blob), would you still recommend then()/catch() for setting context.res?
    • This would result in nestled Promises, correct? If so, prefer nestled promises over say if conditions and variable test conditions?
  1. [Paradigm Question] When/Where do you build your context.res in your production code? Beginning? Throughout (as executing checks and promises)? At the End (through the use of flipped switches?)?
const appInsights = require("applicationinsights");
if (process.env.APPINSIGHTS_INSTRUMENTATIONKEY) {appInsights.setup(process.env.APPINSIGHTS_INSTRUMENTATIONKEY).start()}

const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob');

// Spec: Store blobs in Azure Blob storage

// Inputs - POST
//// - [REQUIRED] req.body - JSON containing meta and file
////     {
////       account: "",
////       accountKey: "",
////       container: "",
////       file: {
////         type: "", //optional
////         name: "",
////         b64: ""
////       }
////     }

//// 1. Check for approriate fields supplied
//// 2. Attempt uploading blob

// Outputs
//// Success: 200/201 - String - `${blobAccountURL}/${container}/${blobName}`
//// Failure: 422 - JSON - { message: "Failed to upload blob: errors returned", errors: `${Comma deliminted string of errors}` }


module.exports = async function (context, req) {
  context.log.info('Started Function: azure-blob-store');

  const errors = []; //Casting as Array
  let account;
  let accountkey;
  let blobAccountURL;
  let container;
  let blobName;
  let blobContent;
  let storedFile;

  if (req.body && req.body.account) {
    account = req.body.account;
    blobAccountURL = `https://${account}.blob.core.windows.net`
  } else {let e = "Missing account name"; context.log.error(e); errors.push(e)}

  if (req.body && req.body.accountkey) {
    accountkey = req.body.accountkey;
  } else {let e = "Missing account key"; context.log.error(e); errors.push(e)}

  if (req.body && req.body.container) {
    container = req.body.container;
  } else {let e = "Missing container name"; context.log.error(e); errors.push(e)}

  if (req.body && req.body.file && req.body.file.name) {
    blobName = req.body.file.name;
  } else {let e = "Missing file name"; context.log.error(e); errors.push(e)}

  if (req.body && req.body.file && req.body.file.b64) {
    blobContent = Buffer.from(req.body.file.b64, 'base64');
  } else {let e = "Missing file content (in b64)"; context.log.error(e); errors.push(e)}

  if (account && container && accountkey && blobName && blobContent) {
    const sharedKeyCredential = new StorageSharedKeyCredential(account, accountkey); //Craft sharedKeyCredential
    const blobServiceClient = new BlobServiceClient(blobAccountURL, sharedKeyCredential); //Craft blobServiceClient
    const containerClient = blobServiceClient.getContainerClient(container); //Craft containerClient
    const blockBlobClient = containerClient.getBlockBlobClient(blobName); //Craft blockBlobClient

    //Upload blob content
    context.log.info("Attempting to upload blob")
    await blockBlobClient.upload(blobContent, Buffer.byteLength(blobContent))
    .then(r => {
      context.log.info("Success in uploading blob")
      storedFile = r;
    })
    .catch(err => {
      let e = `Failed attempting to upload blob: ${blobAccountURL}/${container}/${blobName}`;
      errors.push(e)
      errors.push(`${err}`)
      context.log.error(e)
      context.log.error(`${err}`)
    })
  }

  if (storedFile && errors.length == 0) {
    context.log.info(`Exit: 200; Successfully uploaded blob: ${blobAccountURL}/${container}/${blobName}`)
    context.res = {
      status: 200,
      body: `${blobAccountURL}/${container}/${blobName}`
    }
  }

  if (errors.length > 0) {
    context.log.warn(`Exit: 422; Failed to upload blob due to errors`)
    context.res = {
      status: 422,
      body: {
        message: "Failed to upload blob: errors returned",
        errors: `${errors.join(", ")}`
      }
    }
  }

  //return context.res //<-- is implied; Whatever is set in context.res returns
};

@mhoeger
Copy link

mhoeger commented Feb 14, 2020

@iyerusad - tbh I love these types of questions :) As a disclaimer, I would take everything I say with a grain of salt and that they're my opinions on what makes sense. I've heard (and agree) that code clarity is what's most important (over fancy things), so please take things that add clarity and leave things that don't!

One approach to the "redundant" error handling is to make a helper method like this, where you pass in the errors array and modify that as a side-effect of your code.

// In same file, just below module.exports or somewhere else - just a helper function
function handleError(log, errorMessage, exception) {
    log.error(errorMessage);
    errors.push(errorMessage);
    if (exception) {
         log.error(JSON.stringify(exception));
         errors.push(exception);
    }
    return errors;
}
// called like
// else {
//     handleError(errors, context.log, "Missing this thing");
// }
// or 
// catch (exception) {
//     handleError(errors, context.log, "Failed to do this", exception);
// }

I'm a little torn on the above because I don't like having to pass in "errors" and "log". Especially weird is that we're modifying "errors" in the method and then expecting that consequence to happen to the original input too (whereas if our handleError did errors = [], the input wouldn't be changed). We could also explicitly return errors, but that adds redundant code too.

One kind-of out there approach is to give each function invocation an "environment" by making it into an object. Take it or leave it, I just wanted to mention it! Sorry about the bad class names, still iterating on this idea :)

module.exports = async (context, req) => {
    let blobMaker = new BlobMaker(context, req);
    return blobMaker.run();
}

class BlobMaker {
    context;
    log;
    errors = [];
    account;

    constructor(context, req) {
        this.log = context.log;
        this.account = this.validateInput("account", req.body && req.body.account);
        this.context = context;
        // warning: don't do something like this.res = context.res, because assigning to 
        // res will not work as expected! Log is ok because we're calling a method on it,
        // not assigning to it.
    }
    
    async run() {
        this.log.info('Started Function: azure-blob-store');
        if (this.account) {
            this.log("OMG!!");
            return {
                status: 200,
                body: "ok!!"
            }
        } else {
            return {
                status: 500,
                body: "womp womp :( " + JSON.stringify(this.errors)
            }
        }
    }

    validateInput(parameterName, parameter) {
        if (parameter) {
            return parameter
        } else {
            let e = `Missing ${parameterName}`;
            handleError(e);
        }
    }

    handleError(errorMessage) {
        this.log.error(e);
        this.errors.push(e);
    }

    // whatever other methods that help break this up! If you use TypeScript, you could  
    // also mark these as "private"
}

The advantage here is that you can more easily break apart code into manageable bits while accessing commonly needed things like "log", "context", and "req".

So here, we set it up so that each invocation creates a new instance of an object and runs the needed method on it.

A suggestion for your "//Upload blob content" code:

    // Instead of a .then and .catch, use try/catch with await! i think this more clearly represents
    // what you're trying to do
    try {
        storedFile  = await blockBlobClient.upload(blobContent, Buffer.byteLength(blobContent));
    } catch (err) {
        let e = `Failed attempting to upload blob: ${blobAccountURL}/${container}/${blobName}`;
        errors.push(e)
        errors.push(`${err}`)
        context.log.error(e)
        context.log.error(`${err}`)
    }

For http outputs, my personal preference is to use the "$return" binding and "return" the http response to make sure that code afterwards doesn't change it. Although I would only recommend this with V3, where this bug is fixed: Azure/azure-functions-nodejs-worker#228

Another note, you might want to consider caching the client's you're creating? Here's some guidelines: https://docs.microsoft.com/en-us/azure/azure-functions/manage-connections

I know your case is a bit trickier because the blob account changes dynamically - so this is only a suggestion if your inputs tend to be a subset of a few known entities and if you expect this function to receive a lot of concurrent traffic.

I might have missed some of your questions, please let me know!

@iyerusad
Copy link

@mhoeger

Got a chance to dig into this today. Very much appreciate the pointers and insight. No obligation to reply, below is my findings/musings. Thank you.

  1. Error handling as a function: Implemented! Something that used before but I had initially skipped over for AZfunction ("You ain't gonna need it" mindset) but using it again reminded of its nice quality of life enhancement:
function errorHandler(error, prefix, exception) {
  if (error && error.response && error.response.data) {
    if (error.response.data.error) {error = error.response.data.error}
    else if (error.response.data.errors) {error = error.response.data.errors}
    else {error = JSON.stringify(error.response.data)}
  }

  if (prefix) {error = prefix + " " + error}

  if (exception) {exceptions.push(`${error}`)}
  else {errors.push(`${error}`)}

  context.log.error(`${error}`)
}

I added a prefix component (errorHandler(err, "helper-getcontact -> ")) that has been excellent for providing a deep contextual error/call stack (as I often have functions that call subfunctions that call their own subfunctions)

Here is real world example how that looks from a failure within a Function -> SubFunction (workflow-processSubmission) -> SubFunction (Document-store) -> SubFunction (helper-getcontact)

"errors": "workflow-processSubmission -> Document-store -> helper-getcontact -> Missing req.body.email"

or another (where blob accountkey is incorrect):

{
"message": "Exit: 422; Encountered errors during execution",
"errors": "blockBlobClient.upload() -> Error: AuthenticationFailedServer failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.\nRequestId:a8bc2 > - snipped- "
}

  1. Converting to object/class: Hmmm... Undecided. Back when I used to write more PowerShell, I would write functions and then at the very bottom of the script I would call the function(s) - while it condensed the "what was happening" in the script, it seemed like wrapping for the sake of wrapping. I've seen this approach alot more in the C# repos, but in those typically there would be alot more interconnected pieces. It would be interesting to see Azure Function examples where this kind of abstraction would be more than just wrapping for sake of wrapping - I could see it be very valuable if this azure-blob function expanded to more request methods (PUT/PATCH/DELETE/GET)... Might be a decent canidate for advanced fully featured API example of azure blob storage. tldr: New concept to me, lil bit learning curve on my end as try to wrap head around it.

  2. Promise -> Try/Catch: Implemented! Initially this one perplexed me, especially since blockBlobClient.upload() returns a promise. So I did a little reading about when NOT to use promises and similar. While you can run synchronous in promise flow, a try/catch improves the readibility (does my code represent what its trying to do?) and amount of explicit error handling (promises tend to want explicit catch handling, whereas a Try block can include multiple commands that could potentially fail and get caught by a single catch block - NICE!).

//Upload blob content
try {
  context.log.info("Attempting to upload blob")
  let options = { blobHTTPHeaders: { blobContentType: mimeType } };
  await blockBlobClient.upload(blobContent, Buffer.byteLength(blobContent), options)
  context.log.info("Success in uploading blob")
  storedFile = blobURL;
} catch(err) {
  errorHandler(err, "blockBlobClient.upload() ->")
}

My C# class professor did tell us to use try/catch sparingly since it was an expensive operation, latest reading seems to indicate its only expensive if actual exception is thrown (which I understand as: flag all expected issues before try/catch, leaving the catching of exceptional for something truly exceptional).

  1. Binding $return vs context.res: So I originally started off in Azure Functions land binding $return. What ended up pushing me to defaulting to binding to context.res ended up being: unexpected completion of function. I would have some command that would run and spit out its output to $return, triggering the end of the function (unless I properly handled the command output). Perhaps I was doing it wrong, but it seemed like stdout could/would end my function prematurely. As a beginner, $return binding made sense. As became more intermediate, context.res binding became my preferred go to, given I had to explicitly define it.

  2. Caching the client: Considered it, ended up dumping it in favor of Bezos's API Mandate concept of "no back channel access" - Currently I am writing my functions with the mindset the rest of my company/team can utilize them (eat our own dogfood). In the case of this function, azure-store-blob, my peers could utilize the function to store a blob. I will cache/reuse client if function will handle an array of blobs down the line. Side Note: Biggest pet peeve I've had with a bunch of vendors who I use Azure Functions to glue together their services: They clearly don't use their own APIs and have back channel access to the database(s) - as a customer, my stuff is broken/missing features but their backend systems work just fine.

Tweaked iteration:

const appInsights = require("applicationinsights");
if (process.env.APPINSIGHTS_INSTRUMENTATIONKEY) {appInsights.setup(process.env.APPINSIGHTS_INSTRUMENTATIONKEY).start()}

const {
  BlobServiceClient,
  StorageSharedKeyCredential,
  generateBlobSASQueryParameters,
  BlobSASPermissions
} = require('@azure/storage-blob');

// Spec: Store blobs in Azure Blob storage
//// Security: Anonymous - Wrapper around Azure Storage Library; auth is from request. No auth is provided by wrapper.

// Inputs - POST
//// - [REQUIRED] req.body - JSON containing meta and file
////     {
////       account: "",
////       accountKey: "",
////       container: "",
////       file: {
////         name: "",
////         b64: "",
////         mime: "",
////         SASify: [datetime] //optional, can be "true" or a custom expiration date
////       }
////     }

//// 1. Check for appropriate fields supplied
//// 2. Attempt uploading blob

// Outputs
//// Success: 200 - String - `${blobAccountURL}/${container}/${blobName}`
//// Failure: 422 - JSON - Errors encountered
//// Failure: 500 - JSON - Fatal exceptions encountered


module.exports = async function (context, req) {
  context.log.info('Started Function: azure-blob-store');

  function isDate(string) {
    if (typeof string === "boolean") {string = "invalid"}
    return (new Date(string) !== "Invalid Date") && !isNaN(new Date(string));
  }

  function errorHandler(error, prefix, exception) {
    if (error && error.response && error.response.data) {
      if (error.response.data.error) {error = error.response.data.error}
      else if (error.response.data.errors) {error = error.response.data.errors}
      else {error = JSON.stringify(error.response.data)}
    }

    if (prefix) {error = prefix + " " + error}

    if (exception) {exceptions.push(`${error}`)}
    else {errors.push(`${error}`)}

    context.log.error(`${error}`)
  }

  const errors = []; //Casting as Array
  const exceptions = []; //Casting as Array
  let account;
  let accountkey;
  let blobAccountURL;
  let container;
  let blobName;
  let blobContent;
  let mimeType;
  let SASstartTime;
  let SASexpireTime;
  let SAStoken;
  let blobURL;
  let storedFile;

  if (req.body && req.body.account) {
    account = req.body.account;
    blobAccountURL = `https://${account}.blob.core.windows.net`
  } else {errorHandler("Missing account")}

  if (req.body && req.body.accountkey) {
    accountkey = req.body.accountkey;
  } else {errorHandler("Missing accountKey")}

  if (req.body && req.body.container) {
    container = req.body.container;
  } else {errorHandler("Missing container")}

  if (req.body && req.body.file && req.body.file.name) {
    blobName = req.body.file.name;
  } else {errorHandler("Missing file.name")}

  if (req.body && req.body.file && req.body.file.b64) {
    blobContent = Buffer.from(req.body.file.b64, 'base64');
  } else {errorHandler("Missing file content in file.b64 (as base64)")}

  if (req.body && req.body.file && req.body.file.mime) {
    mimeType = req.body.file.mime;
  } else {errorHandler("Missing file.mime")}

  if (req.body && req.body.file && req.body.file.SASify) {
    //Start time
    SASstartTime = new Date(new Date().setMinutes(new Date().getMinutes() - 5)); // Set start time to 5 minutes ago to avoid clock skew.

    //Expire Time
    if (isDate(req.body.file.SASify)) {
      SASexpireTime = new Date(req.body.file.SASify) //Use datetime from request for expiration time
    } else {
      SASexpireTime = new Date(new Date().setMinutes((new Date().getMinutes() + 30))); //30 minute default or if invald date provided
    }
  } //optional parameter

  if (blobAccountURL && container && blobName) {
    blobURL = encodeURI(`${blobAccountURL}/${container}/${blobName}`)
  }

  if (account && container && accountkey && blobName && blobContent && mimeType) {
    const sharedKeyCredential = new StorageSharedKeyCredential(account, accountkey); //Craft sharedKeyCredential
    const blobServiceClient = new BlobServiceClient(blobAccountURL, sharedKeyCredential); //Craft blobServiceClient
    const containerClient = blobServiceClient.getContainerClient(container); //Craft containerClient
    const blockBlobClient = containerClient.getBlockBlobClient(blobName); //Craft blockBlobClient

    //TODO: Should check if blob exists before overwriting. Maybe there is overload of blockBlobClient for this?

    //Upload blob content
    try {
      context.log.info("Attempting to upload blob")
      let options = { blobHTTPHeaders: { blobContentType: mimeType } };
      await blockBlobClient.upload(blobContent, Buffer.byteLength(blobContent), options)
      context.log.info("Success in uploading blob")
      storedFile = blobURL;
    } catch(err) {errorHandler(err, "blockBlobClient.upload() ->")}

    if (storedFile && SASstartTime && SASexpireTime) {
      try {
        SAStoken = generateBlobSASQueryParameters({
          containerName: container,
          permissions: BlobSASPermissions.parse("r"),
          blobName: blobName,
          expiresOn: SASexpireTime,
          startsOn: SASstartTime
          }, sharedKeyCredential
        ).toString();
      } catch(err) {errorHandler(err, "generateBlobSASQueryParameters ->")}

      if (SAStoken) {
        storedFile += `?${SAStoken}`
      }
    }
  }

  if (storedFile && errors.length == 0) {
    context.log.info(`Exit: 200; Successfully uploaded blob: ${blobURL}`)
    context.res = {
      status: 200,
      body: storedFile
    }
  }

  if (exceptions.length > 0) {
    context.log.warn(`Exit: 500; Encountered fatal error`)
    context.res = {
      status: 500,
      headers: {"Content-Type": "application/json"},
      body: {
        message: "FATAL failure: server side failure",
        errors: `${exceptions.join(", ")}`
      }
    }
  } else if (errors.length > 0) {
    context.log.warn(`Exit: 422; Encountered errors during execution`)
    context.res = {
      status: 422,
      headers: {"Content-Type": "application/json"},
      body: {
        message: "Exit: 422; Encountered errors during execution",
        errors: `${errors.join(", ")}`
      }
    }
  }

  //return context.res //<-- is implied; Whatever is set in context.res returns
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants