Azure / azure-functions-nodejs-library

The Node.js framework for Azure Functions
https://www.npmjs.com/package/@azure/functions
MIT License
58 stars 14 forks source link

How to access request.body in an Azure Function using Node.js (programming model v4) ? #172

Closed oshihirii closed 9 months ago

oshihirii commented 1 year ago

I've looked at the official docs:

Azure Functions Node.js developer guide https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=javascript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4

Azure Functions developer guide
https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference?tabs=blob&pivots=programming-language-javascript

Azure Functions overview https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?pivots=programming-language-javascript

Blog Post - Azure Functions: Version 4 of the Node.js programming model is in preview
https://techcommunity.microsoft.com/t5/apps-on-azure-blog/azure-functions-version-4-of-the-node-js-programming-model-is-in/ba-p/3773541

The are no code samples that demonstrate how to access request.body.

The docs state:

In order to access a request or response's body, the following methods can be used:

arrayBuffer() returns Promise<ArrayBuffer>
blob() returns Promise<Blob>
formData() returns Promise<FormData>
json() returns Promise<unknown>
text() returns Promise<string>

I am sending a POST request to the Azure Function endpoint in the local environment.

I have tried in both Postman and using curl in Windows 11 terminal.

The headers defined in both cases are:

Content-Type: application/json  
x-functions-key: ******  

The body defined in both cases is:

{
    "target_site_id": "******",
    "target_library_id": "******",
    "choice_col_01_value": "Choice 02",
    "choice_col_02_value": "Choice 02"
}

I have tried numerous approaches to access request.body.

Below are details of one of the approaches that has not worked, using the json() method on request.

I am choosing to ask about this approach because it seems like it should be the most straightforward.

Environment

package.json

{
  "name": "",
  "version": "1.0.0",
  "description": "",
  "main": "src/functions/*.js",
  "scripts": {
    "start": "func start",
    "test": "echo \"No tests yet...\""
  },
  "dependencies": {
    "@azure/functions": "^4.0.0-alpha.1",
    "@azure/msal-node": "^2.2.0",
    "axios": "^1.5.1"
  },
  "devDependencies": {
    "azure-functions-core-tools": "^4.x"
  }
}

Azure Function Code

// required libraries
const { app } = require('@azure/functions');
// to handle token management
const msal = require('@azure/msal-node');
// to make requests  
const axios = require('axios');

app.http('MyHttpTriggerFunction', {
    methods: ['GET', 'POST'],
    authLevel: 'function',
    handler: async (request, context) => {
        try {

            // sanity check - this is logged    
            context.log(`The http function processed a request for the req.url "${request.url}"`);

            // using 'await' here doesn't seem to halt execution  
            const requestData = await request.json();

            // these don't work:   
            // const requestData = await request.json(); 
            // const requestData = await request.body.json();
            // const requestData = request.body.json();
            // const requestData = request.json();
            // request.body <--- returns ReadableStream { locked: false, state: 'readable', supportsBYOB: false }

            // all of the logs from this point onwards are displayed in a seemingly random order, i.e not synchronously  

            context.log("requestData:");  
            context.log(requestData);

            // get target_site_id from the request body
            const target_site_id = requestData.target_site_id;

            // get target_library_id from the request body
            const target_library_id = requestData.target_library_id;  

            // get choice_col_01_value from the request body
            const choice_col_01_value = requestData.choice_col_01_value;

            // get choice_col_02_value from the request body
            const choice_col_02_value = requestData.choice_col_02_value;

            context.log("target_site_id:");  
            context.log(target_site_id);

            context.log("target_library_id:");
            context.log(target_library_id);

            context.log("choice_col_01_value:");
            context.log(choice_col_01_value);

            context.log("choice_col_02_value:");
            context.log(choice_col_02_value);

            const msal_config = {
                auth: {
                    clientId: process.env["azure_ad_app_registration_client_id"],
                    authority: `https://login.microsoftonline.com/${process.env["azure_ad_app_registration_tenant_id"]}`,
                    clientSecret: process.env["azure_ad_app_registration_client_secret"],
                }
            };

            // create MSAL client instance
            const cca = new msal.ConfidentialClientApplication(msal_config);

            // acquire token
            const clientCredentialRequest = {
                scopes: ["https://graph.microsoft.com/.default"],
            };
            const response = await cca.acquireTokenByClientCredential(clientCredentialRequest);
            const token = response.accessToken;

            context.log("token:");
            context.log(token);

            const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$expand=fields&$filter=ChoiceCol01/Value eq '${choice_col_01_value}' and ChoiceCol02/Value eq '${choice_col_02_value}'`;

            context.log("graphRequestURL");
            context.log(graphRequestURL);

            const graphResponse = await axios.get(graphRequestURL, {

                headers: {
                    Authorization: `Bearer ${token}`
                }
            });       

            // this isn't logged  
            context.log("This is JSON.stringify(graphResponse.data):");
            context.log(JSON.stringify(graphResponse.data));

            // nothing is returned  
            // Postman returns 204 No Content.
            // curl just says Executed 'Functions.MyHttpTriggerFunction' (Succeeded, Id=******, Duration=1368ms)

            return { body: JSON.stringify(graphResponse.data) }

        } catch (error) {
            context.res = {
                status: 500,
                body: `Error: ${error.message || error}`
            };
        }

    }
});
oshihirii commented 1 year ago

Update:

I changed two main things in the Azure Function code above and am now getting a successful response.

The modified code is at the end of this message.

It includes examples of passing data through to the endpoint via either query parameters or the request body.

Change 01) I changed the syntax of the Graph API endpoint from this:

const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$expand=fields&$filter=ChoiceCol01/Value eq '${choice_col_01_value}' and ChoiceCol02/Value eq '${choice_col_02_value}'`;

to this (just added fields/ before the column name):

const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$expand=fields&$filter=fields/ChoiceCol01/Value eq '${choice_col_01_value}' and fields/ChoiceCol02/Value eq '${choice_col_02_value}'`;

Change 02) I indexed the SharePoint library columns that I wanted to filter on.

However I am still uncomfortable about the following behavior in Azure Functions:

I would love it if anyone could alleviate, explain or resolve any of the above concerns so that I could confidently use Azure Functions.

Otherwise, it seems it would be a lot less stress to set up a conventional web app with backend Node.js/Express server etc so that I could confidently use my normal approach, conventions and folder structure when developing apps in Node.js and not be 'caught out' by any unexpected behavior that may exist in Azure Functions.

Modified Azure Function Code

// required libraries
const { app } = require('@azure/functions');
// to handle token management
const msal = require('@azure/msal-node');
// to make requests  
const axios = require('axios');

app.http('MyHttpTriggerFunction', {
    methods: ['GET', 'POST'],
    authLevel: 'function',
    handler: async (request, context) => {
        try {

            // sanity check to see if this function is being called when requested  
            context.log(`The http function processed a request for the req.url "${request.url}"`);

            /*

            below are two approaches to sending data to the endpoint using a POST request

            01.  via query parameters (values are displayed in the request URL)  

            02.  in the request body (values are not displayed in the request URL)  

            */

            // // BEGIN approach 01:  using query parameters - this works if values are passed through in query parameters

            // const requestQueryParams = request.query; 
            // context.log(requestQueryParams);

            // // get target_site_id from the request query parameters
            // const target_site_id = requestQueryParams.get('target_site_id');

            // // get target_library_id from the request query parameters
            // const target_library_id = requestQueryParams.get('target_library_id');  

            // // get choice_col_01_value from the request query parameters
            // const choice_col_01_value = requestQueryParams.get('choice_col_01_value');

            // // get choice_col_02_value from the request query parameters
            // const choice_col_02_value = requestQueryParams.get('choice_col_02_value');

            // // END approach 01:  using query parameters

            // BEGIN approach 02:  using request body - this works if values are passed through in request body  

            const requestData = await request.json(); 

            // get target_site_id from the request body
            const target_site_id = requestData.target_site_id;

            // get target_library_id from the request body
            const target_library_id = requestData.target_library_id;  

            // get choice_col_01_value from the request body
            const choice_col_01_value = requestData.choice_col_01_value;

            // get choice_col_02_value from the request body
            const choice_col_02_value = requestData.choice_col_02_value;

            // END approach 02:  using request body

            // all of the context.log() statements from this point onwards are displayed in a seemingly random order, i.e not synchronously  
            // this makes it very confusing to ascertain how the function is progressing   

            context.log("target_site_id:");  
            context.log(target_site_id);

            context.log("target_library_id:");
            context.log(target_library_id);

            context.log("choice_col_01_value:");
            context.log(choice_col_01_value);

            context.log("choice_col_02_value:");
            context.log(choice_col_02_value);

            const msal_config = {
                auth: {
                    clientId: process.env["azure_ad_app_registration_client_id"],
                    authority: `https://login.microsoftonline.com/${process.env["azure_ad_app_registration_tenant_id"]}`,
                    clientSecret: process.env["azure_ad_app_registration_client_secret"],
                }
            };

            // create MSAL client instance
            const cca = new msal.ConfidentialClientApplication(msal_config);

            // acquire token
            const clientCredentialRequest = {
                scopes: ["https://graph.microsoft.com/.default"],
            };
            const response = await cca.acquireTokenByClientCredential(clientCredentialRequest);
            const token = response.accessToken;

            context.log("token:");
            context.log(token);

            // get all library items - this works  
            // const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items`;

            // get matching library items - this works ONLY if the SharePoint library columns you are filtering on have been indexed!   
            // const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$expand=fields&$filter=fields/ChoiceCol01/Value eq '${choice_col_01_value}' and fields/ChoiceCol02/Value eq '${choice_col_02_value}'`;

            // get matching library items and only return selected fields as well as the default fields returned, which it does not seem possible to suppress
            // as above - this works ONLY if the SharePoint library columns you are filtering on have been indexed! 
            const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$expand=fields($select=ChoiceCol01,ChoiceCol02,Slot01,Slot02,Slot03)&$filter=fields/ChoiceCol01/Value eq '${choice_col_01_value}' and fields/ChoiceCol02/Value eq '${choice_col_02_value}'`;

            // this works when the columns are indexed, but it only returns basic details about the file:
            // const graphRequestURL = `https://graph.microsoft.com/v1.0/sites/${target_site_id}/lists/${target_library_id}/items?$filter=fields/ChoiceCol01/Value eq '${choice_col_01_value}' and fields/ChoiceCol02/Value eq '${choice_col_02_value}'`;

            context.log("graphRequestURL");
            context.log(graphRequestURL);

            const graphResponse = await axios.get(graphRequestURL, {
                headers: {
                    Authorization: `Bearer ${token}`
                }
            });

            context.log("This is JSON.stringify(graphResponse.data):");
            context.log(JSON.stringify(graphResponse.data));

            //  return { body: graphResponse.data }  <--- this returns [object Object] 
            return { body: JSON.stringify(graphResponse.data) }

        } catch (error) {
            context.res = {
                status: 500,
                body: `Error: ${error.message || error}`
            };
        }

    }
});
ejizba commented 1 year ago

I do not trust that the await keyword is halting executing like I believe it should

I'm not sure what you mean by "halting execution". Can you expand on your desired behavior? There's nothing unique to Azure Functions in terms of how the await works - that's just pure Node.js.

the context.log() statements are logged in a seemingly random order which makes it very difficult to 'see' how the function is progressing

This is unfortunately a known issue. @brettsam is actively working on this, related to https://github.com/Azure/azure-functions-host/issues/9238. Apparently some improvements to performance made a while ago caused this behavior, and they want to be careful when fixing it to make sure they don't lose the performance gains.

I expected that Azure Functions would work 'just like Node.js', but am concerned that standard features may be missing and/or that there are layers of abstraction code that need to be implemented to get things working

There will be some abstractions necessary, but we are getting closer and closer to "just like Node.js" every day. If you notice any standard features missing, please file a new issue for each feature you would like to use.

I am also concerned that if the programming model changes (as it did from v3 to v4) then I will be forced to majorly refactor code in the future, which I will not have the time to do

v3 to v4 was the biggest change we've done for Node.js in many years. It was inspired by one of the most-upvoted issues for Azure Functions and took at least 2+ years from the first design to the GA release. We have a lot of exciting new features in the works (top of mind is stream support), but nothing nearly as disruptive. We also have no plans to deprecate v3 for a while, so even in this case no one will be "forced" to refactor their code.

I would like to know how to return a JSON object as the response, rather than having to use return { body: JSON.stringify(graphResponse.data) }, Postman shows that the response Content-Type is text/plain;charset=UTF-8, even though in the request headers, Content-Type is specified as application/json.

You could try return { jsonBody: graphResponse.data }. Regardless, I'm not entirely sure why the content-type is text/plain, though. If you still have issues with this please file a separate issue for us to investigate.

I am not sure if I can/should be using Express.js in the v4 model, as demonstrated in this video , and then I could use body parser

We don't have any direct integrations with Express.js. We have an issue to track something like that, but have no real plans to work on it soon unless we get more feedback asking for it: https://github.com/Azure/azure-functions-nodejs-library/issues/16

oshihirii commented 1 year ago

I very much appreciate your thoughtful and detailed response.

For reference, I recognize my message was a little 'all over the place', but I was just trying to 'cram understand' Azure Functions over the space of a few days and thought there might be some value in documenting all of the newbie experiences I was having.

Thank you again.

Also, I can confirm that using return { jsonBody: graphResponse.data } returns a JSON object in Postman, with the Content-Type in the headers of the response displayed as application/json as opposed to text/plain;charset=UTF-8.

oshihirii commented 1 year ago

Can I please ask how I should reference query parameters in a GET request.

This doesn't seem to work:

const requestQueryParams = request.query; 

// get target_site_id from the request query parameters
const target_site_id = requestQueryParams.target_site_id;

// get target_library_id from the request query parameters
const target_library_id = requestQueryParams.target_library_id;  

// get choice_col_01_value from the request query parameters
const choice_col_01_value = requestQueryParams.choice_col_01_value;

// get choice_col_02_value from the request query parameters
const choice_col_02_value = requestQueryParams.choice_col_02_value;

Whilst this does work:

const requestQueryParams = request.query; 

// get target_site_id from the request query parameters
const target_site_id = requestQueryParams.get('target_site_id');

// get target_library_id from the request query parameters
const target_library_id = requestQueryParams.get('target_library_id');  

// get choice_col_01_value from the request query parameters
const choice_col_01_value = requestQueryParams.get('choice_col_01_value');

// get choice_col_02_value from the request query parameters
const choice_col_02_value = requestQueryParams.get('choice_col_02_value');

Edit:

If I am reading it right, it seems that query parameters are in a URLSearchParams object:

https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?pivots=nodejs-model-v4&tabs=javascript%2Cwindows%2Cazure-cli#http-request

And the docs for that object are here:

https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams

And therefore I do need to use request.query.get('some_key_here')

ejizba commented 1 year ago

For reference, I recognize my message was a little 'all over the place', but I was just trying to 'cram understand' Azure Functions over the space of a few days and thought there might be some value in documenting all of the newbie experiences I was having.

No problem! We'll have to split any action items into separate issues moving forward, but this type of feedback is always helpful and informative!

And yes your "Edit" about the query parameters is correct

ihnaqi commented 9 months ago

I don't know whether my response is going to help someone or not. Unlike NodeJs's traditional request.body() the one in Azure comes with a request.text() method and returns Promise<String>, now we can use JSON.parse() to convert the returned string into a JSON and access our intended values. Here is a simple code snippet

const { app } = require('@azure/functions');

app.http('CreatePost', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        try {
            context.log(`Http function processed request for url "${request.url}"`)

            const { name } = JSON.parse(await request.text())

            if(!name) {
                return {
                    status: 411,
                    body: "Bad Request"
                }
            }

            return { 
                status: 201,
                body: `Hello, ${name}!`
            };
        }
        catch(error) {
            return {
                status: 501,
                body: error.message
            }
        }
    }
})
ejizba commented 9 months ago

Hi @ihnaqi thanks for the sample. You can also use await request.json() directly as shorthand for JSON.parse(await request.text())

Fyi, the HTTP request docs specific to Azure Functions are here: https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=javascript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-request

And here are some docs for the fetch standard, which we are based off of: https://developer.mozilla.org/en-US/docs/Web/API/Request#instance_methods

ejizba commented 9 months ago

Closing this issue - it covered several areas, but I think the most impactful ones already have separate issues for tracking. If not, please create a new issue for each one. Thanks again!