Skip to content

Commit

Permalink
Merge pull request #20 from Yvand/releases/1.0.0.rc-7
Browse files Browse the repository at this point in the history
Publish 1.0.0.rc 7
  • Loading branch information
Yvand authored Dec 2, 2024
2 parents 3339efc + 956201b commit 0c80954
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 80 deletions.
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The resources deployed in Azure are configured with a high level of security:
+ [Node.js 20](https://www.nodejs.org/)
+ [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?pivots=programming-language-typescript#install-the-azure-functions-core-tools)
+ [Azure Developer CLI (AZD)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd)
+ Be `Owner` of the subscription (or have [`Role Based Access Control Administrator`](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator)), to successfully assign Azure RBAC roles to the managed identity, as part of the provisioning process
+ To use Visual Studio Code to run and debug locally:
+ [Visual Studio Code](https://code.visualstudio.com/)
+ [Azure Functions extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions)
Expand Down Expand Up @@ -77,7 +78,7 @@ You can initialize a project from this `azd` template in one of these ways:
}
```

1. Review the file `infra\main.parameters.json` to customize the parameters used for provisioning the resources in Azure. Review [this article](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/manage-environment-variables) to manage the azd's environment variables.
1. Review the file `infra/main.parameters.json` to customize the parameters used for provisioning the resources in Azure. Review [this article](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/manage-environment-variables) to manage the azd's environment variables.
Important: Ensure the values for `TenantPrefix` and `SiteRelativePath` are identical between the files `local.settings.json` (used when running the functions locally) and `infra\main.parameters.json` (used to set the environment variables in Azure).
Expand Down Expand Up @@ -246,7 +247,7 @@ curl "https://${funchost}.azurewebsites.net/api/webhooks/show?code=${code}&listT
# Remove the webhook from the list
# Step 1: Get the webhook id in the output of the function /webhooks/show
webhookId=$(curl -s "https://${funchost}.azurewebsites.net/api/webhooks/show?code=${code}&listTitle=${listTitle}&notificationUrl=${notificationUrl}" | \
python3 -c "import sys, json; document = json.load(sys.stdin); document and print(document['id'])"
python3 -c "import sys, json; document = json.load(sys.stdin); document and print(document['id'])")
# Step 2: Call function /webhooks/remove and pass the webhookId
curl -X POST "https://${funchost}.azurewebsites.net/api/webhooks/remove?code=${code}&listTitle=${listTitle}&webhookId=${webhookId}"
```
Expand All @@ -260,7 +261,7 @@ code="YOUR_HOST_KEY"
notificationUrl="https://${funchost}.azurewebsites.net/api/webhooks/service?code=${code}"
listTitle="YOUR_SHAREPOINT_LIST"
# List all webhooks on a list
# List all the webhooks registered on a list
curl "http://localhost:7071/api/webhooks/list?listTitle=${listTitle}"
# Register a webhook
Expand All @@ -272,7 +273,7 @@ curl "http://localhost:7071/api/webhooks/show?listTitle=${listTitle}&notificatio
# Remove the webhook from the list
# Step 1: Get the webhook id in the output of the function /webhooks/show
webhookId=$(curl -s "http://localhost:7071/api/webhooks/show?listTitle=${listTitle}&notificationUrl=${notificationUrl}" | \
python3 -c "import sys, json; document = json.load(sys.stdin); document and print(document['id'])"
python3 -c "import sys, json; document = json.load(sys.stdin); document and print(document['id'])")
# Step 2: Call function /webhooks/remove and pass the webhookId
curl -X POST "http://localhost:7071/api/webhooks/remove?listTitle=${listTitle}&webhookId=${webhookId}"
```
Expand All @@ -284,20 +285,28 @@ When the functions run in Azure, the logging goes to the Application Insights re
### KQL queries for Application Insights
The KQL query below shows the messages from all the functions, and filters out the logging from the infrastructure:
The KQL query below shows the entries from all the functions, and filters out the logging from the infrastructure:
```kql
traces
| where isnotempty(operation_Name)
| project timestamp, operation_Name, severityLevel, message
| order by timestamp desc
```
The KQL query below shows the messages only from the function `webhooks/service` (which receives the notifications from SharePoint):
The KQL query below does the following:
- Includes only the entries from the function `webhooks/service` (which receives the notifications from SharePoint)
- Parses the `message` as a json document (which is how this project writes the messages)
- Includes only the entries that were successfully parsed (excludes those from the infrastructure)
```kql
traces
| where operation_Name contains "webhooks-service"
| project timestamp, operation_Name, severityLevel, message
| extend jsonMessage = parse_json(message)
| where isnotempty(jsonMessage.['message'])
| project timestamp, operation_Name, severityLevel, jsonMessage.['message'], jsonMessage.['error']
| order by timestamp desc
```
## Known issues
Expand Down
2 changes: 1 addition & 1 deletion azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

name: functions-quickstart-spo-azd
metadata:
template: Yvand/[email protected]6
template: Yvand/[email protected]7
services:
api:
project: .
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "functions-quickstart-spo-azd",
"version": "1.0.0.rc-6",
"version": "1.0.0.rc-7",
"author": {
"name": "Yvan Duhamel"
},
Expand Down
46 changes: 46 additions & 0 deletions src/debug/funcs-debug-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import "@pnp/sp/items/index.js";
import "@pnp/sp/lists/index.js";
import "@pnp/sp/subscriptions/index.js";
import "@pnp/sp/webs/index.js";
import { CommonConfig, safeWait } from "../utils/common.js";
import { logError } from "../utils/loggingHandler.js";
import { getSharePointSiteInfo, getSpAccessToken, getSPFI } from "../utils/spAuthentication.js";

export async function getAccessToken(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const tenantPrefix = request.query.get('tenantPrefix') || CommonConfig.TenantPrefix;
try {
const token = await getSpAccessToken(tenantPrefix);
let result: any = {
userAssignedManagedIdentityClientId: CommonConfig.UserAssignedManagedIdentityClientId,
tenantPrefix: tenantPrefix,
sharePointDomain: CommonConfig.SharePointDomain,
token: token,
};
return { status: 200, jsonBody: result };
}
catch (error: unknown) {
const errorDetails = await logError(context, error, context.functionName);
return { status: errorDetails.httpStatus, jsonBody: errorDetails };
}
};

export async function getWeb(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
try {
const tenantPrefix = request.query.get('tenantPrefix') || undefined;
const siteRelativePath = request.query.get('siteRelativePath') || undefined;
const sharePointSite = getSharePointSiteInfo(tenantPrefix, siteRelativePath);
const sp = getSPFI(sharePointSite);
let result: any, error: any;
[result, error] = await safeWait(sp.web());
if (error) {
const errorDetails = await logError(context, error, `Could not get web for tenantPrefix '${sharePointSite.tenantPrefix}' and site '${sharePointSite.siteRelativePath}'`);
return { status: errorDetails.httpStatus, jsonBody: errorDetails };
}
return { status: 200, jsonBody: result };
}
catch (error: unknown) {
const errorDetails = await logError(context, error, context.functionName);
return { status: errorDetails.httpStatus, jsonBody: errorDetails };
}
};
5 changes: 5 additions & 0 deletions src/functions-definition/funcs-debug-def.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { app } from "@azure/functions";
import { getAccessToken, getWeb } from "../debug/funcs-debug-impl.js";

app.http('debug-getAccessToken', { methods: ['GET'], authLevel: 'admin', handler: getAccessToken, route: 'debug/getAccessToken' });
app.http('debug-getWeb', { methods: ['GET'], authLevel: 'function', handler: getWeb, route: 'debug/getWeb' });
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app } from "@azure/functions";
import { registerWebhook, listWehhooks, wehhookService, removeWehhook, showWehhook } from "../webhooks/webhooks-app.js"
import { registerWebhook, listWehhooks, wehhookService, removeWehhook, showWehhook } from "../webhooks/funcs-webhooks-impl.js"

app.http('webhooks-register', { methods: ['POST'], authLevel: 'function', handler: registerWebhook, route: 'webhooks/register' });
app.http('webhooks-service', { methods: ['POST'], authLevel: 'function', handler: wehhookService, route: 'webhooks/service' });
Expand Down
6 changes: 2 additions & 4 deletions src/utils/common.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { SharePointSiteInfo } from "./spAuthentication";

export const CommonConfig = {
UserAgent: process.env.UserAgent || "functions-quickstart-spo",
TenantPrefix: process.env.TenantPrefix || "",
TenantBaseUrl: `https://${process.env.TenantPrefix}.sharepoint.com` || "",
SharePointDomain: process.env.SharePointDomain || "sharepoint.com",
SiteRelativePath: process.env.SiteRelativePath || "",
IsLocalEnvironment: process.env.AZURE_FUNCTIONS_ENVIRONMENT === "Development" ? true : false,
UserAssignedManagedIdentityClientId: process.env.UserAssignedManagedIdentityClientId || undefined,
WebhookHistoryListTitle: process.env.WebhookHistoryListTitle || "webhookHistory",
UserAgent: process.env.UserAgent || "Yvand/functions-quickstart-spo-azd",
}

// This method awaits on async calls and catches the exception if there is any - https://dev.to/sobiodarlington/better-error-handling-with-async-await-2e5m
Expand Down
95 changes: 69 additions & 26 deletions src/utils/loggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import { hOP } from "@pnp/core";

// Create a listener to write messages to the logging system
const listener: ILogListener = FunctionListener((entry: ILogEntry): void => {
logMessage(entry);
writeEntryToLog(entry);
});
Logger.subscribe(listener);
Logger.activeLogLevel = LogLevel.Verbose;

// Internal function which logs all the messages including the formatted errors, to app insights if possible, and to the console if in local environment
function logMessage(entry: ILogEntry): void {
/**
* Internal function which writes the entry to the log: application insights if possible, or the console if in local environment
* @param entry
*/
function writeEntryToLog(entry: ILogEntry): void {
let logcontext: InvocationContext = entry.data;
if (logcontext) {
switch (entry.level) {
Expand All @@ -43,41 +46,81 @@ function logMessage(entry: ILogEntry): void {
}
}

export interface IMessageDocument {
timestamp: string;
level: LogLevel;
message: string;
}

export interface IErrorMessageDocument extends IMessageDocument {
error: string;
type: string;
sprequestguid?: string;
httpStatus?: number;
}

/**
* Handles the error and logs it
* @param e
* @param currentOperationDetails
* @returns formatted error message
* Process the error, record an error message and return a document with details about the error
* @param error
* @param logcontext
* @param message
* @returns document with details about the error
*/
export async function handleError(e: Error | HttpRequestError | unknown, logcontext: InvocationContext, currentOperationDetails?: string): Promise<string> {
let message = currentOperationDetails ? `${currentOperationDetails}: ` : "";
let level: LogLevel = LogLevel.Error;

if (e instanceof Error) {
if (hOP(e, "isHttpRequestError")) {
let [jsonResponse, awaiterror] = await safeWait((<HttpRequestError>e).response.json());
export async function logError(logcontext: InvocationContext, error: Error | HttpRequestError | unknown, message: string): Promise<IErrorMessageDocument> {
let errorDocument: IErrorMessageDocument = { timestamp: new Date().toISOString(), level: LogLevel.Error, message: message, error: "", type: "", httpStatus: 500 };
let errorDetails = "";
if (error instanceof Error) {
if (hOP(error, "isHttpRequestError")) {
errorDocument.type = "HttpRequestError";
let [jsonResponse, awaiterror] = await safeWait((<HttpRequestError>error).response.json());
if (jsonResponse) {
message += typeof jsonResponse["odata.error"] === "object" ? jsonResponse["odata.error"].message.value : e.message;
errorDetails += typeof jsonResponse["odata.error"] === "object" ? jsonResponse["odata.error"].message.value : error.message;
} else {
message += e.message;
errorDetails += error.message;
}
if ((<HttpRequestError>e).status === 404) {
level = LogLevel.Warning;

errorDocument.httpStatus = (<HttpRequestError>error).status;
if (errorDocument.httpStatus === 404) {
errorDocument.level = LogLevel.Warning;
}

const spCorrelationId = (error as HttpRequestError).response.headers.get("sprequestguid");
errorDocument.sprequestguid = spCorrelationId || "";
} else {
message += e.message;
errorDocument.type = error.name;
errorDetails += error.message;
}
} else if (typeof e === "string") {
message += e;
} else if (typeof error === "string") {
errorDocument.type = "string";
errorDetails = error;
}
else {
message += JSON.stringify(e);
errorDocument.type = "unknown";
errorDetails = JSON.stringify(error);
}

errorDocument.error = errorDetails;
Logger.log({
data: logcontext,
level: errorDocument.level,
message: JSON.stringify(errorDocument),
});
return errorDocument;
}

/**
* record the message and return a document with additionnal details
* @param logcontext
* @param message
* @param level
* @returns
*/
export function logInfo(logcontext: InvocationContext, message: string, level: LogLevel = LogLevel.Info): IMessageDocument {
const messageResponse: IMessageDocument = { timestamp: new Date().toISOString(), level: level, message: message };
Logger.log({
data: logcontext,
level: level,
message: message,
level: messageResponse.level,
message: JSON.stringify(messageResponse),
});
return message;
}
return messageResponse;
}
17 changes: 15 additions & 2 deletions src/utils/spAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NodeFetchWithRetry } from "@pnp/nodejs";
import { SPDefault } from "@pnp/nodejs/index.js";
import { DefaultParse, InjectHeaders, Queryable } from "@pnp/queryable";
import { SPFI, spfi } from "@pnp/sp";
import { AccessToken, AzureCliCredential, AzureDeveloperCliCredential, DefaultAzureCredential } from "@azure/identity";
import { AccessToken, AzureCliCredential, AzureDeveloperCliCredential, DefaultAzureCredential, ManagedIdentityCredential, ManagedIdentityCredentialClientIdOptions } from "@azure/identity";
import { AzureIdentity, ValidCredential } from "@pnp/azidjsclient";
import { DefaultHeaders } from "@pnp/sp";
import { CommonConfig } from "./common.js";
Expand Down Expand Up @@ -64,7 +64,7 @@ export function getSPFI(spSite: SharePointSiteInfo): SPFI {
*/
function initSPFI(spSite: SharePointSiteInfo): SharePointSiteConnection {
const credential = getAzureCredential();
const baseUrl: string = `https://${spSite.tenantPrefix}.sharepoint.com${spSite.siteRelativePath}`;
const baseUrl: string = `https://${spSite.tenantPrefix}.${CommonConfig.SharePointDomain}${spSite.siteRelativePath}`;
const scopes: string[] = getScopes(spSite.tenantPrefix);
const spConnection: SPFI = spfi(baseUrl).using(
CustomConnection(),
Expand Down Expand Up @@ -123,6 +123,19 @@ function withDefaultAzureCredential(): ValidCredential {
return credential;
}

function withManagedIdentityCredential(): ValidCredential {
const options: ManagedIdentityCredentialClientIdOptions = {
// if the identity is a system-assigned identity, clientId is not needed
clientId: CommonConfig.UserAssignedManagedIdentityClientId,
// loggingOptions: {
// allowLoggingAccountIdentifiers: true,
// enableUnsafeSupportLogging: true,
// },
}
const credential = new ManagedIdentityCredential(options);
return credential;
}

function withAzureCliCredential(): ValidCredential {
// As you can see in this example, the AzureCliCredential does not take any parameters,
// instead relying on the Azure CLI authenticated user to authenticate.
Expand Down
Loading

0 comments on commit 0c80954

Please sign in to comment.