diff --git a/dictation_function/package-lock.json b/dictation_function/package-lock.json index a51bd0f..4da5447 100644 --- a/dictation_function/package-lock.json +++ b/dictation_function/package-lock.json @@ -13,11 +13,13 @@ "@sendgrid/mail": "^7.7.0", "dotenv": "^16.0.3", "mysql2": "^2.3.3", + "redis": "^3.1.2", "typeorm": "^0.3.10" }, "devDependencies": { "@types/jest": "^27.5.0", "@types/node": "18.x", + "@types/redis": "^2.8.13", "azure-functions-core-tools": "^4.x", "jest": "^28.0.3", "rimraf": "^5.0.0", @@ -1989,6 +1991,15 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", @@ -5252,9 +5263,9 @@ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, "node_modules/lru-cache": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", - "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", + "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -6371,8 +6382,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", - "optional": true, - "peer": true, "dependencies": { "denque": "^1.5.0", "redis-commands": "^1.7.0", @@ -6390,16 +6399,12 @@ "node_modules/redis-commands": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", - "optional": true, - "peer": true + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -6408,8 +6413,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "optional": true, - "peer": true, "dependencies": { "redis-errors": "^1.0.0" }, @@ -6421,8 +6424,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", - "optional": true, - "peer": true, "engines": { "node": ">=0.10" } diff --git a/dictation_function/package.json b/dictation_function/package.json index 02589f6..8a6260d 100644 --- a/dictation_function/package.json +++ b/dictation_function/package.json @@ -18,11 +18,13 @@ "@sendgrid/mail": "^7.7.0", "dotenv": "^16.0.3", "mysql2": "^2.3.3", + "redis": "^3.1.2", "typeorm": "^0.3.10" }, "devDependencies": { "@types/jest": "^27.5.0", "@types/node": "18.x", + "@types/redis": "^2.8.13", "azure-functions-core-tools": "^4.x", "jest": "^28.0.3", "rimraf": "^5.0.0", diff --git a/dictation_function/src/adb2c/adb2c.service.ts b/dictation_function/src/adb2c/adb2c.service.ts deleted file mode 100644 index 8caa96c..0000000 --- a/dictation_function/src/adb2c/adb2c.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ClientSecretCredential } from "@azure/identity"; -import { Client } from "@microsoft/microsoft-graph-client"; -import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials"; -import { AdB2cResponse, AdB2cUser } from "./types/types"; -import { error } from "console"; - -export class Adb2cTooManyRequestsError extends Error {} - -export class AdB2cService { - private graphClient: Client; - - constructor() { - // ADB2Cへの認証情報 - if ( - !process.env.ADB2C_TENANT_ID || - !process.env.ADB2C_CLIENT_ID || - !process.env.ADB2C_CLIENT_SECRET - ) { - throw error; - } - const credential = new ClientSecretCredential( - process.env.ADB2C_TENANT_ID, - process.env.ADB2C_CLIENT_ID, - process.env.ADB2C_CLIENT_SECRET - ); - const authProvider = new TokenCredentialAuthenticationProvider(credential, { - scopes: ["https://graph.microsoft.com/.default"], - }); - - this.graphClient = Client.initWithMiddleware({ authProvider }); - } - - /** - * Azure AD B2Cからユーザ情報を取得する - * @param externalIds 外部ユーザーID - * @returns ユーザ情報 - */ - async getUsers(externalIds: string[]): Promise { - const chunkExternalIds = splitArrayInChunksOfFifteen(externalIds); - - try { - const b2cUsers: AdB2cUser[] = []; - for (let index = 0; index < chunkExternalIds.length; index++) { - const element = chunkExternalIds[index]; - const res: AdB2cResponse = await this.graphClient - .api(`users/`) - .select(["id", "displayName", "identities"]) - .filter(`id in (${element.map((y) => `'${y}'`).join(",")})`) - .get(); - - b2cUsers.push(...res.value); - } - - return b2cUsers; - } catch (e) { - const { statusCode } = e; - if (statusCode === 429) { - throw new Adb2cTooManyRequestsError(); - } - - throw e; - } finally { - } - } -} - -const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => { - const result: string[][] = []; - const chunkSize = 15; // SDKの制限数 - for (let i = 0; i < arr.length; i += chunkSize) { - result.push(arr.slice(i, i + chunkSize)); - } - return result; -}; diff --git a/dictation_function/src/adb2c/adb2c.ts b/dictation_function/src/adb2c/adb2c.ts new file mode 100644 index 0000000..f58f9b3 --- /dev/null +++ b/dictation_function/src/adb2c/adb2c.ts @@ -0,0 +1,147 @@ +import { ClientSecretCredential } from "@azure/identity"; +import { Client } from "@microsoft/microsoft-graph-client"; +import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials"; +import { AdB2cResponse, AdB2cUser } from "./types/types"; +import { error } from "console"; +import { makeADB2CKey, restoreAdB2cID } from "../common/cache"; +import { promisify } from "util"; +import { createRedisClient } from "../redis/redis"; +import { InvocationContext } from "@azure/functions"; + +export class Adb2cTooManyRequestsError extends Error {} + +export class AdB2cService { + private graphClient: Client; + + constructor() { + // ADB2Cへの認証情報 + if ( + !process.env.ADB2C_TENANT_ID || + !process.env.ADB2C_CLIENT_ID || + !process.env.ADB2C_CLIENT_SECRET || + !process.env.ADB2C_CACHE_TTL + ) { + throw error; + } + const credential = new ClientSecretCredential( + process.env.ADB2C_TENANT_ID, + process.env.ADB2C_CLIENT_ID, + process.env.ADB2C_CLIENT_SECRET + ); + const authProvider = new TokenCredentialAuthenticationProvider(credential, { + scopes: ["https://graph.microsoft.com/.default"], + }); + + this.graphClient = Client.initWithMiddleware({ authProvider }); + } + + /** + * Azure AD B2Cからユーザ情報を取得する + * @param externalIds 外部ユーザーID + * @returns ユーザ情報 + */ + async getUsers( + context: InvocationContext, + externalIds: string[] + ): Promise { + const redisClient = createRedisClient(); + try { + const b2cUsers: AdB2cUser[] = []; + const keys = externalIds.map((externalId) => makeADB2CKey(externalId)); + + // 取得対象が0件だとmgetがエラーとなるので、1件以上のときに処理 + if (keys.length !== 0) { + // redisからキャッシュされているユーザーを取得 + let cacheUserObjects: { + key: string; + value: AdB2cUser; + }[] = []; + const mgetAsync = promisify(redisClient.mget).bind(redisClient); + try { + const values = await mgetAsync(...keys); + cacheUserObjects = values.map((value, index) => ({ + key: keys[index], + value: value ? JSON.parse(value) : null, + })); + context.log("mget Result:", cacheUserObjects); + } catch (error) { + context.error("mget Error:", error); + } + + // キャッシュ上に存在していれば、キャッシュから取得する + const cachedUsers = cacheUserObjects.flatMap((x) => + x.value ? [x.value] : [] + ); + if (cachedUsers.length > 0) { + context.log( + `[CACHE HIT] ids: ${cachedUsers.map((x) => x.id).join(",")}` + ); + } + + // キャッシュ上に存在していなければ、ADB2Cから取得する + const queryExternalIds = cacheUserObjects + .filter((x) => x.value === null) + .map((x) => restoreAdB2cID(x.key)); + const chunkExternalIds = splitArrayInChunksOfFifteen(queryExternalIds); + + for (let index = 0; index < chunkExternalIds.length; index++) { + const element = chunkExternalIds[index]; + const res: AdB2cResponse = await this.graphClient + .api(`users/`) + .select(["id", "displayName", "identities"]) + .filter(`id in (${element.map((y) => `'${y}'`).join(",")})`) + .get(); + + b2cUsers.push(...res.value); + + // 取得したユーザーをキャッシュに保存する + const users = res.value.map((user) => { + const key = makeADB2CKey(user.id); + return { + key: key, + value: user, + }; + }); + try { + const setexAsync = promisify(redisClient.setex).bind(redisClient); + const ttl = process.env.ADB2C_CACHE_TTL; + users.map(async (x) => { + await setexAsync(x.key, ttl, JSON.stringify(x.value)); + context.log( + "setex Result:", + `key:${x.key},ttl:${ttl},value:${JSON.stringify(x.value)}` + ); + }); + } catch (error) { + context.error("setex Error:", error); + } + + context.log( + `[ADB2C GET] externalIds: ${res.value?.map((x) => x.id).join(",")}` + ); + } + + return [...cachedUsers, ...b2cUsers]; + } else { + return undefined; + } + } catch (e) { + const { statusCode } = e; + if (statusCode === 429) { + throw new Adb2cTooManyRequestsError(); + } + throw e; + } finally { + redisClient.quit; + } + } +} + +const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => { + const result: string[][] = []; + const chunkSize = 15; // SDKの制限数 + for (let i = 0; i < arr.length; i += chunkSize) { + result.push(arr.slice(i, i + chunkSize)); + } + return result; +}; diff --git a/dictation_function/src/functions/licenseAlert.ts b/dictation_function/src/functions/licenseAlert.ts index 1225883..f8f2a71 100644 --- a/dictation_function/src/functions/licenseAlert.ts +++ b/dictation_function/src/functions/licenseAlert.ts @@ -16,8 +16,8 @@ import { } from "../common/types/types"; import { createMailContentOfLicenseShortage } from "../sendgrid/mailContents/U103ShortageAlert"; import { createMailContentOfLicenseExpiringSoon } from "../sendgrid/mailContents/U104ExpiringSoonAlert"; -import { AdB2cService } from "../adb2c/adb2c.service"; -import { SendGridService } from "../sendgrid/sendgrid.service"; +import { AdB2cService } from "../adb2c/adb2c"; +import { SendGridService } from "../sendgrid/sendgrid"; import { getMailFrom } from "../common/getEnv/getEnv"; export async function licenseAlertProcessing( @@ -156,8 +156,12 @@ export async function licenseAlertProcessing( externalIds.push(x.secondaryAdminExternalId); } }); - const adb2cUsers = await adb2c.getUsers(externalIds); - + const adb2cUsers = await adb2c.getUsers(context, externalIds); + if (!adb2cUsers) { + context.log("Target user not found"); + context.log("[OUT]licenseAlertProcessing"); + return; + } // ADB2Cから取得したメールアドレスをRDBから取得した情報にマージ sendTargetAccounts.map((info) => { const primaryAdminUser = adb2cUsers.find( diff --git a/dictation_function/src/functions/redisTimerTest.ts b/dictation_function/src/functions/redisTimerTest.ts new file mode 100644 index 0000000..419079d --- /dev/null +++ b/dictation_function/src/functions/redisTimerTest.ts @@ -0,0 +1,29 @@ +import { app, InvocationContext, Timer } from "@azure/functions"; +import * as dotenv from "dotenv"; +import { promisify } from "util"; +import { createRedisClient } from "../redis/redis"; + +export async function redisTimerTest( + myTimer: Timer, + context: InvocationContext +): Promise { + context.log("---Timer function processed request."); + + dotenv.config({ path: ".env" }); + dotenv.config({ path: ".env.local", override: true }); + + const redisClient = createRedisClient(); + const setAsync = promisify(redisClient.set).bind(redisClient); + const getAsync = promisify(redisClient.get).bind(redisClient); + + await setAsync("foo", "bar"); + const value = await getAsync("foo"); + context.log(`value=${value}`); // returns 'bar' + + await redisClient.quit; +} + +app.timer("redisTimerTest", { + schedule: "*/30 * * * * *", + handler: redisTimerTest, +}); diff --git a/dictation_function/src/redis/redis.ts b/dictation_function/src/redis/redis.ts new file mode 100644 index 0000000..aedb573 --- /dev/null +++ b/dictation_function/src/redis/redis.ts @@ -0,0 +1,35 @@ +import { createClient, RedisClient } from "redis"; +import { error } from "console"; + +export const createRedisClient = (): RedisClient => { + if ( + !process.env.REDIS_HOST || + !process.env.REDIS_PORT || + !process.env.REDIS_PASSWORD + ) { + throw error("Required environment variables are not set."); + } + + const host = process.env.REDIS_HOST; + const port = parseInt(process.env.REDIS_PORT, 10); + const password = process.env.REDIS_PASSWORD; + + let client: RedisClient; + if (process.env.STAGE === "local") { + client = createClient({ + host: host, + port: port, + password: password, + }); + } else { + client = createClient({ + url: `rediss://${host}:${port}`, + password: password, + tls: {}, + }); + } + + client.on("error", (err) => console.log("Redis Client Error", err)); + + return client; +}; diff --git a/dictation_function/src/sendgrid/sendgrid.service.ts b/dictation_function/src/sendgrid/sendgrid.ts similarity index 100% rename from dictation_function/src/sendgrid/sendgrid.service.ts rename to dictation_function/src/sendgrid/sendgrid.ts diff --git a/dictation_function/src/test/licenseAlert.spec.ts b/dictation_function/src/test/licenseAlert.spec.ts index 12ecbf6..a37e9d4 100644 --- a/dictation_function/src/test/licenseAlert.spec.ts +++ b/dictation_function/src/test/licenseAlert.spec.ts @@ -10,8 +10,8 @@ import { } from "../common/types/types"; import { AdB2cUser } from "../adb2c/types/types"; import { ADB2C_SIGN_IN_TYPE } from "../constants"; -import { SendGridService } from "../sendgrid/sendgrid.service"; -import { AdB2cService } from "../adb2c/adb2c.service"; +import { SendGridService } from "../sendgrid/sendgrid"; +import { AdB2cService } from "../adb2c/adb2c"; import { InvocationContext } from "@azure/functions"; describe("licenseAlert", () => { @@ -209,7 +209,10 @@ export class AdB2cServiceMock { * @param externalIds 外部ユーザーID * @returns ユーザ情報 */ - async getUsers(externalIds: string[]): Promise { + async getUsers( + context: InvocationContext, + externalIds: string[] + ): Promise { const AdB2cMockUsers: AdB2cUser[] = [ { id: "external_id1",