From e7f2130c132bc0dea91843f46ce8d418e62ffe06 Mon Sep 17 00:00:00 2001 From: Simone infante <52280205+infantesimone@users.noreply.github.com> Date: Thu, 8 Oct 2020 10:15:39 +0200 Subject: [PATCH] [#175119553] Centralize environment variables in single config module (#110) --- CreateValidationTokenActivity/index.ts | 8 +- GetMessage/index.ts | 10 +- GetMessages/index.ts | 7 +- GetVisibleServices/index.ts | 8 +- .../__tests__/handler.test.ts | 7 - .../__tests__/handler.test.ts | 6 - SendValidationEmailActivity/index.ts | 29 ++- SendWelcomeMessagesActivity/index.ts | 14 +- StoreSpidLogs/__test__/index.test.ts | 13 -- StoreSpidLogs/index.ts | 6 +- UpdateSubscriptionsFeedActivity/index.ts | 22 ++- docker/fixtures/index.ts | 15 +- env.example | 22 +++ jest.config.js | 2 + package.json | 1 + utils/__tests__/config.test.ts | 183 ++++++++++++++++++ utils/config.ts | 161 +++++++++++++++ utils/cosmosdb.ts | 11 +- utils/email.ts | 6 +- utils/notification.ts | 9 +- yarn.lock | 5 + 21 files changed, 456 insertions(+), 89 deletions(-) create mode 100644 env.example create mode 100644 utils/__tests__/config.test.ts create mode 100644 utils/config.ts diff --git a/CreateValidationTokenActivity/index.ts b/CreateValidationTokenActivity/index.ts index 146814db..09e5c63c 100644 --- a/CreateValidationTokenActivity/index.ts +++ b/CreateValidationTokenActivity/index.ts @@ -5,18 +5,20 @@ import { createTableService } from "azure-storage"; import { Millisecond } from "italia-ts-commons/lib/units"; import { VALIDATION_TOKEN_TABLE_NAME } from "io-functions-commons/dist/src/entities/validation_token"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { ulidGenerator } from "io-functions-commons/dist/src/utils/strings"; import { getCreateValidationTokenActivityHandler } from "./handler"; +import { getConfigOrThrow } from "../utils/config"; + +const config = getConfigOrThrow(); + const TOKEN_INVALID_AFTER_MS = (1000 * 60 * 60 * 24 * 30) as Millisecond; // 30 days // TODO: Rename this env to `StorageConnection` // https://www.pivotaltracker.com/story/show/169591817 -const storageConnectionString = getRequiredStringEnv("QueueStorageConnection"); -const tableService = createTableService(storageConnectionString); +const tableService = createTableService(config.QueueStorageConnection); const randomBytesGenerator = (size: number) => crypto.randomBytes(size).toString("hex"); diff --git a/GetMessage/index.ts b/GetMessage/index.ts index d41cf407..54b71a53 100644 --- a/GetMessage/index.ts +++ b/GetMessage/index.ts @@ -2,7 +2,6 @@ import { Context } from "@azure/functions"; import * as express from "express"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; @@ -18,19 +17,20 @@ import createAzureFunctionHandler from "io-functions-express/dist/src/createAzur import { cosmosdbInstance } from "../utils/cosmosdb"; import { GetMessage } from "./handler"; +import { getConfigOrThrow } from "../utils/config"; + // Setup Express const app = express(); secureExpressApp(app); -const messageContainerName = getRequiredStringEnv("MESSAGE_CONTAINER_NAME"); +const config = getConfigOrThrow(); const messageModel = new MessageModel( cosmosdbInstance.container(MESSAGE_COLLECTION_NAME), - messageContainerName + config.MESSAGE_CONTAINER_NAME ); -const storageConnectionString = getRequiredStringEnv("QueueStorageConnection"); -const blobService = createBlobService(storageConnectionString); +const blobService = createBlobService(config.QueueStorageConnection); app.get( "/api/v1/messages/:fiscalcode/:id", diff --git a/GetMessages/index.ts b/GetMessages/index.ts index f9e77678..fbbf2734 100644 --- a/GetMessages/index.ts +++ b/GetMessages/index.ts @@ -2,7 +2,6 @@ import { Context } from "@azure/functions"; import * as express from "express"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; @@ -16,15 +15,17 @@ import createAzureFunctionHandler from "io-functions-express/dist/src/createAzur import { cosmosdbInstance } from "../utils/cosmosdb"; import { GetMessages } from "./handler"; +import { getConfigOrThrow } from "../utils/config"; + // Setup Express const app = express(); secureExpressApp(app); -const messageContainerName = getRequiredStringEnv("MESSAGE_CONTAINER_NAME"); +const config = getConfigOrThrow(); const messageModel = new MessageModel( cosmosdbInstance.container(MESSAGE_COLLECTION_NAME), - messageContainerName + config.MESSAGE_CONTAINER_NAME ); app.get("/api/v1/messages/:fiscalcode", GetMessages(messageModel)); diff --git a/GetVisibleServices/index.ts b/GetVisibleServices/index.ts index 5f6850e2..8ffc90ec 100644 --- a/GetVisibleServices/index.ts +++ b/GetVisibleServices/index.ts @@ -3,7 +3,6 @@ import { createBlobService } from "azure-storage"; import * as express from "express"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; @@ -11,12 +10,15 @@ import createAzureFunctionHandler from "io-functions-express/dist/src/createAzur import { GetVisibleServices } from "./handler"; +import { getConfigOrThrow } from "../utils/config"; + // Setup Express const app = express(); secureExpressApp(app); -const storageConnectionString = getRequiredStringEnv("QueueStorageConnection"); -const blobService = createBlobService(storageConnectionString); +const config = getConfigOrThrow(); + +const blobService = createBlobService(config.QueueStorageConnection); app.get("/api/v1/services", GetVisibleServices(blobService)); diff --git a/HandleNHNotificationCallActivity/__tests__/handler.test.ts b/HandleNHNotificationCallActivity/__tests__/handler.test.ts index ba34024e..ade5acef 100644 --- a/HandleNHNotificationCallActivity/__tests__/handler.test.ts +++ b/HandleNHNotificationCallActivity/__tests__/handler.test.ts @@ -1,12 +1,5 @@ /* tslint:disable: no-any */ // tslint:disable-next-line: no-object-mutation -process.env = { - ...process.env, - AZURE_NH_ENDPOINT: - "Endpoint=sb://anendpoint.servicebus.windows.net/;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=XXX", - AZURE_NH_HUB_NAME: "AZURE_NH_HUB_NAME" -}; - import { NonEmptyString } from "italia-ts-commons/lib/strings"; import { context as contextMock } from "../../__mocks__/durable-functions"; import { PlatformEnum } from "../../generated/backend/Platform"; diff --git a/HandleNHNotificationCallOrchestrator/__tests__/handler.test.ts b/HandleNHNotificationCallOrchestrator/__tests__/handler.test.ts index fc456178..66b5a4ad 100644 --- a/HandleNHNotificationCallOrchestrator/__tests__/handler.test.ts +++ b/HandleNHNotificationCallOrchestrator/__tests__/handler.test.ts @@ -1,11 +1,5 @@ /* tslint:disable:no-any */ // tslint:disable-next-line: no-object-mutation -process.env = { - ...process.env, - AZURE_NH_ENDPOINT: - "Endpoint=sb://anendpoint.servicebus.windows.net/;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=C4xIzNZv4VrUnu5jkmPH635MApRUj8wABky8VfduYqg=", - AZURE_NH_HUB_NAME: "AZURE_NH_HUB_NAME" -}; import { NonEmptyString } from "italia-ts-commons/lib/strings"; import { context as contextMock } from "../../__mocks__/durable-functions"; import { PlatformEnum } from "../../generated/backend/Platform"; diff --git a/SendValidationEmailActivity/index.ts b/SendValidationEmailActivity/index.ts index cbe9ba2d..4f905b3c 100644 --- a/SendValidationEmailActivity/index.ts +++ b/SendValidationEmailActivity/index.ts @@ -1,5 +1,4 @@ -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; -import { MailMultiTransportConnectionsFromString } from "io-functions-commons/dist/src/utils/multi_transport_connection"; +import { MailMultiTransportConnectionsFromString } from "io-functions-commons/dist/src/utils/multi_transport_connection"; import { MultiTransport } from "io-functions-commons/dist/src/utils/nodemailer"; import { NonEmptyString } from "italia-ts-commons/lib/strings"; @@ -14,24 +13,24 @@ import { import { initTelemetryClient } from "../utils/appinsights"; +import { getConfigOrThrow } from "../utils/config"; + +const config = getConfigOrThrow(); + // Whether we're in a production environment -const isProduction = process.env.NODE_ENV === "production"; +const isProduction = config.NODE_ENV === "production"; // Optional SendGrid key -const sendgridApiKey = NonEmptyString.decode( - process.env.SENDGRID_API_KEY -).getOrElse(undefined); +const sendgridApiKey = NonEmptyString.decode(config.SENDGRID_API_KEY).getOrElse( + undefined +); // Mailup -const mailupUsername = getRequiredStringEnv("MAILUP_USERNAME"); -const mailupSecret = getRequiredStringEnv("MAILUP_SECRET"); +const mailupUsername = config.MAILUP_USERNAME; +const mailupSecret = config.MAILUP_SECRET; // Email data const EMAIL_TITLE = "Valida l’indirizzo email che usi su IO"; -const mailFrom = getRequiredStringEnv("MAIL_FROM"); - -// Needed to construct the email validation url -const functionsPublicUrl = getRequiredStringEnv("FUNCTIONS_PUBLIC_URL"); const HTML_TO_TEXT_OPTIONS: HtmlToTextOptions = { ignoreImage: true, // Ignore all document images @@ -39,7 +38,7 @@ const HTML_TO_TEXT_OPTIONS: HtmlToTextOptions = { }; const emailDefaults = { - from: mailFrom, + from: config.MAIL_FROM, htmlToTextOptions: HTML_TO_TEXT_OPTIONS, title: EMAIL_TITLE }; @@ -51,7 +50,7 @@ export type EmailDefaults = typeof emailDefaults; // [mailup:username:password;][sendgrid:apikey:;] // Note that multiple instances of the same provider can be provided. const transports = MailMultiTransportConnectionsFromString.decode( - process.env.MAIL_TRANSPORTS + config.MAIL_TRANSPORTS ) .map(getTransportsForConnections) .getOrElse([]); @@ -78,7 +77,7 @@ initTelemetryClient(); const activityFunctionHandler = getSendValidationEmailActivityHandler( mailerTransporter, emailDefaults, - functionsPublicUrl + config.FUNCTIONS_PUBLIC_URL ); export default activityFunctionHandler; diff --git a/SendWelcomeMessagesActivity/index.ts b/SendWelcomeMessagesActivity/index.ts index 5a520fd9..271c8b69 100644 --- a/SendWelcomeMessagesActivity/index.ts +++ b/SendWelcomeMessagesActivity/index.ts @@ -1,4 +1,3 @@ -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { agent } from "italia-ts-commons"; import { AbortableFetch, @@ -8,12 +7,12 @@ import { import { Millisecond } from "italia-ts-commons/lib/units"; import { getActivityFunction } from "./handler"; +import { getConfigOrThrow } from "../utils/config"; + // HTTP external requests timeout in milliseconds const DEFAULT_REQUEST_TIMEOUT_MS = 10000; -// Needed to call notifications API -const publicApiUrl = getRequiredStringEnv("PUBLIC_API_URL"); -const publicApiKey = getRequiredStringEnv("PUBLIC_API_KEY"); +const config = getConfigOrThrow(); // HTTP-only fetch with optional keepalive agent // @see https://github.com/pagopa/io-ts-commons/blob/master/src/agent.ts#L10 @@ -25,6 +24,11 @@ const timeoutFetch = toFetch( setFetchTimeout(DEFAULT_REQUEST_TIMEOUT_MS as Millisecond, abortableFetch) ); -const index = getActivityFunction(publicApiUrl, publicApiKey, timeoutFetch); +// Needed to call notifications API +const index = getActivityFunction( + config.PUBLIC_API_URL, + config.PUBLIC_API_KEY, + timeoutFetch +); export default index; diff --git a/StoreSpidLogs/__test__/index.test.ts b/StoreSpidLogs/__test__/index.test.ts index 86e10073..601af49c 100644 --- a/StoreSpidLogs/__test__/index.test.ts +++ b/StoreSpidLogs/__test__/index.test.ts @@ -1,19 +1,6 @@ /* tslint:disable:no-any */ /* tslint:disable:no-object-mutation */ -process.env = { - APPINSIGHTS_INSTRUMENTATIONKEY: "foo", - QueueStorageConnection: "foobar", - SPID_BLOB_CONTAINER_NAME: "spidblob", - SPID_BLOB_STORAGE_CONNECTION_STRING: "foobar", - SPID_LOGS_PUBLIC_KEY: `-----BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDhiXpvLD8UMMUy1T2JCzo/Sj5E -l09Fs0z2U4aA37BrXlSo1DwQ2O9i2XFxXGJmE83siSWEfRlMWlabMu7Yj6dkZvmj -dGIO4gotO33TgiAQcwRo+4pwjoCN7Td47yssCcj9C727zBt+Br+XK7B1bRcqjc0J -YdF4yiVtD7G4RDXmRQIDAQAB ------END PUBLIC KEY-----` -}; - import { format } from "date-fns"; import { toPlainText } from "italia-ts-commons/lib/encrypt"; import { IPString } from "italia-ts-commons/lib/strings"; diff --git a/StoreSpidLogs/index.ts b/StoreSpidLogs/index.ts index bee3fe59..d392f329 100644 --- a/StoreSpidLogs/index.ts +++ b/StoreSpidLogs/index.ts @@ -2,7 +2,6 @@ import { Context } from "@azure/functions"; import { sequenceS } from "fp-ts/lib/Apply"; import { either } from "fp-ts/lib/Either"; import { curry } from "fp-ts/lib/function"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import * as t from "io-ts"; import { UTCISODateFromString } from "italia-ts-commons/lib/dates"; import { @@ -12,9 +11,10 @@ import { import { readableReport } from "italia-ts-commons/lib/reporters"; import { IPString, PatternString } from "italia-ts-commons/lib/strings"; import { initTelemetryClient } from "../utils/appinsights"; +import { getConfigOrThrow } from "../utils/config"; -const rsaPublicKey = getRequiredStringEnv("SPID_LOGS_PUBLIC_KEY"); -const encrypt = curry(toEncryptedPayload)(rsaPublicKey); +const config = getConfigOrThrow(); +const encrypt = curry(toEncryptedPayload)(config.SPID_LOGS_PUBLIC_KEY); /** * Payload of the stored blob item diff --git a/UpdateSubscriptionsFeedActivity/index.ts b/UpdateSubscriptionsFeedActivity/index.ts index 0aa27b70..661bca86 100644 --- a/UpdateSubscriptionsFeedActivity/index.ts +++ b/UpdateSubscriptionsFeedActivity/index.ts @@ -8,20 +8,26 @@ import { createTableService, TableUtilities } from "azure-storage"; import { readableReport } from "italia-ts-commons/lib/reporters"; import { FiscalCode } from "italia-ts-commons/lib/strings"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; - import { ServiceId } from "io-functions-commons/dist/generated/definitions/ServiceId"; import { isNone } from "fp-ts/lib/Option"; import { deleteTableEntity, insertTableEntity } from "../utils/storage"; -const storageConnectionString = getRequiredStringEnv("QueueStorageConnection"); -const tableService = createTableService(storageConnectionString); +import { getConfigOrThrow } from "../utils/config"; + +const config = getConfigOrThrow(); + +const tableService = createTableService(config.QueueStorageConnection); -const subscriptionsFeedTable = getRequiredStringEnv("SUBSCRIPTIONS_FEED_TABLE"); +const insertEntity = insertTableEntity( + tableService, + config.SUBSCRIPTIONS_FEED_TABLE +); -const insertEntity = insertTableEntity(tableService, subscriptionsFeedTable); -const deleteEntity = deleteTableEntity(tableService, subscriptionsFeedTable); +const deleteEntity = deleteTableEntity( + tableService, + config.SUBSCRIPTIONS_FEED_TABLE +); const eg = TableUtilities.entityGenerator; @@ -58,7 +64,7 @@ export type Input = t.TypeOf; // When the function starts, attempt to create the table if it does not exist // Note that we cannot log anything just yet since we don't have a Context -tableService.createTableIfNotExists(subscriptionsFeedTable, () => 0); +tableService.createTableIfNotExists(config.SUBSCRIPTIONS_FEED_TABLE, () => 0); /** * Updates the subscrption status of a user. diff --git a/docker/fixtures/index.ts b/docker/fixtures/index.ts index c4a904e5..ec03e874 100644 --- a/docker/fixtures/index.ts +++ b/docker/fixtures/index.ts @@ -18,15 +18,14 @@ import { import { sequenceT } from "fp-ts/lib/Apply"; import { TaskEither, taskEitherSeq, tryCatch } from "fp-ts/lib/TaskEither"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; -const cosmosDbKey = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_KEY"); -const cosmosDbUri = getRequiredStringEnv("CUSTOMCONNSTR_COSMOSDB_URI"); -const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME"); +import { getConfigOrThrow } from "../../utils/config"; + +const config = getConfigOrThrow(); export const cosmosdbClient = new CosmosClient({ - endpoint: cosmosDbUri, - key: cosmosDbKey + endpoint: config.CUSTOMCONNSTR_COSMOSDB_URI, + key: config.CUSTOMCONNSTR_COSMOSDB_KEY }); function createDatabase(databaseName: string): TaskEither { @@ -56,7 +55,7 @@ const aService: Service = Service.decode({ organizationFiscalCode: "01234567890", organizationName: "Organization name", requireSecureChannels: false, - serviceId: process.env.REQ_SERVICE_ID, + serviceId: config.REQ_SERVICE_ID, serviceName: "MyServiceName" }).getOrElseL(() => { throw new Error("Cannot decode service payload."); @@ -88,7 +87,7 @@ const aNewProfile = NewProfile.decode({ throw new Error("Cannot decode new profile."); }); -createDatabase(cosmosDbName) +createDatabase(config.COSMOSDB_NAME) .chain(db => sequenceT(taskEitherSeq)( createCollection(db, "message-status", "messageId"), diff --git a/env.example b/env.example new file mode 100644 index 00000000..c28c30b1 --- /dev/null +++ b/env.example @@ -0,0 +1,22 @@ +APPINSIGHTS_INSTRUMENTATIONKEY=foo +AZURE_NH_HUB_NAME=azhub +AZURE_NH_ENDPOINT=Endpoint=sb://host.docker.internal:30000;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=foobar +COSMOSDB_KEY=key +COSMOSDB_NAME=cosmoname +COSMOSDB_URI=uri +CUSTOMCONNSTR_COSMOSDB_KEY=key +CUSTOMCONNSTR_COSMOSDB_URI=uri +FUNCTIONS_PUBLIC_URL=url +MAILHOG_HOSTNAME=mailhog +MAIL_FROM=mail@example.it +MESSAGE_CONTAINER_NAME=msg +NODE_ENV=dev +PUBLIC_API_KEY=key +PUBLIC_API_URL=url +QueueStorageConnection=foobar +REQ_SERVICE_ID=req_id_dev +SPID_BLOB_CONTAINER_NAME=spidblob +SPID_BLOB_STORAGE_CONNECTION_STRING=foobar +SPID_LOGS_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDhiXpvLD8UMMUy1T2JCzo/Sj5E\nl09Fs0z2U4aA37BrXlSo1DwQ2O9i2XFxXGJmE83siSWEfRlMWlabMu7Yj6dkZvmj\ndGIO4gotO33TgiAQcwRo+4pwjoCN7Td47yssCcj9C727zBt+Br+XK7B1bRcqjc0J\nYdF4yiVtD7G4RDXmRQIDAQAB\n-----END PUBLIC KEY-----" +SUBSCRIPTIONS_FEED_TABLE=feed +UBSCRIPTIONS_FEED_TABLE=feed \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 8866385e..d2eef61f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,5 @@ +require("dotenv").config({ path: "env.example" }); + module.exports = { preset: "ts-jest", testEnvironment: "node", diff --git a/package.json b/package.json index 309bcc87..4d70650b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/nodemailer": "^4.6.8", "danger": "^4.0.2", "danger-plugin-digitalcitizenship": "^0.3.1", + "dotenv": "^8.2.0", "fast-check": "^1.16.0", "italia-tslint-rules": "^1.1.3", "italia-utils": "^4.1.0", diff --git a/utils/__tests__/config.test.ts b/utils/__tests__/config.test.ts new file mode 100644 index 00000000..a11c9bba --- /dev/null +++ b/utils/__tests__/config.test.ts @@ -0,0 +1,183 @@ +import { Either } from "fp-ts/lib/Either"; +import { MailerConfig } from "../config"; + +const aMailFrom = "example@test.com"; +const mailSecret = "a-mu-secret"; +const mailUsername = "a-mu-username"; +const devEnv = "dev"; +const prodEnv = "production"; + +const noop = () => null; +const expectRight = (e: Either, t: (r: R) => void = noop) => + e.fold( + _ => + fail(`Expecting right, received left. Value: ${JSON.stringify(e.value)}`), + _ => t(_) + ); + +const expectLeft = (e: Either, t: (l: L) => void = noop) => + e.fold( + _ => t(_), + _ => + fail(`Expecting left, received right. Value: ${JSON.stringify(e.value)}`) + ); + +describe("MailerConfig", () => { + it("should decode configuration for sendgrid", () => { + const rawConf = { + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv, + SENDGRID_API_KEY: "a-sg-key" + }; + const result = MailerConfig.decode(rawConf); + + expectRight(result, value => { + expect(value.SENDGRID_API_KEY).toBe("a-sg-key"); + expect(typeof value.MAILUP_USERNAME).toBe("undefined"); + }); + }); + + it("should decode configuration for sendgrid even if mailup conf is passed", () => { + const rawConf = { + MAILUP_SECRET: mailSecret, + MAILUP_USERNAME: mailUsername, + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv, + SENDGRID_API_KEY: "a-sg-key" + }; + const result = MailerConfig.decode(rawConf); + + expectRight(result, value => { + expect(value.SENDGRID_API_KEY).toBe("a-sg-key"); + }); + }); + + it("should decode configuration for mailup", () => { + const rawConf = { + MAILUP_SECRET: mailSecret, + MAILUP_USERNAME: mailUsername, + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv + }; + const result = MailerConfig.decode(rawConf); + + expectRight(result, value => { + expect(value.MAILUP_USERNAME).toBe("a-mu-username"); + expect(value.MAILUP_SECRET).toBe("a-mu-secret"); + }); + }); + + it("should decode configuration with multi transport", () => { + const aTransport = { + password: "abc".repeat(5), + transport: "transport-name", + username: "t-username" + }; + const aRawTrasport = [ + aTransport.transport, + aTransport.username, + aTransport.password + ].join(":"); + + const rawConf = { + MAIL_FROM: aMailFrom, + MAIL_TRANSPORTS: [aRawTrasport, aRawTrasport].join(";"), + NODE_ENV: prodEnv + }; + const result = MailerConfig.decode(rawConf); + + expectRight(result, value => { + expect(value.MAIL_TRANSPORTS).toEqual([aTransport, aTransport]); + }); + }); + + it("should decode configuration for mailhog", () => { + const rawConf = { + MAILHOG_HOSTNAME: "a-mh-host", + MAIL_FROM: aMailFrom, + NODE_ENV: devEnv + }; + const result = MailerConfig.decode(rawConf); + + expectRight(result, value => { + expect(value.MAILHOG_HOSTNAME).toBe("a-mh-host"); + }); + }); + + it("should require mailhog if not in prod", () => { + const rawConf = { + MAIL_FROM: aMailFrom, + NODE_ENV: devEnv + }; + const result = MailerConfig.decode(rawConf); + + expectLeft(result); + }); + + it("should require at least on transporter if in prod", () => { + const rawConf = { + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv + }; + const result = MailerConfig.decode(rawConf); + + expectLeft(result); + }); + + it("should not allow mailhog if in prod", () => { + const rawConf = { + MAILHOG_HOSTNAME: "a-mh-host", + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv + }; + const result = MailerConfig.decode(rawConf); + + expectLeft(result); + }); + + it("should not decode configuration with empty transport", () => { + const rawConf = { + MAIL_FROM: aMailFrom, + MAIL_TRANSPORTS: "", + NODE_ENV: prodEnv + }; + const result = MailerConfig.decode(rawConf); + + expectLeft(result); + }); + + it("should not decode configuration when no transporter is specified", () => { + const rawConf = { + MAIL_FROM: aMailFrom + }; + const result = MailerConfig.decode(rawConf); + + expectLeft(result); + }); + + it("should not decode ambiguos configuration", () => { + const withMailUp = { + MAILUP_SECRET: mailSecret, + MAILUP_USERNAME: mailUsername + }; + const withSendGrid = { + SENDGRID_API_KEY: "a-sg-key" + }; + const withMultiTransport = { + MAIL_TRANSPORTS: "a-trasnport-name" + }; + const base = { + MAIL_FROM: aMailFrom, + NODE_ENV: prodEnv + }; + + // tslint:disable-next-line readonly-array + const examples = [ + { ...base, ...withMultiTransport, ...withSendGrid }, + { ...base, ...withMailUp, ...withMultiTransport }, + { ...base, ...withMailUp, ...withSendGrid, ...withMultiTransport } + ]; + + examples.map(MailerConfig.decode).forEach(_ => expectLeft(_)); + }); +}); diff --git a/utils/config.ts b/utils/config.ts new file mode 100644 index 00000000..83111b7e --- /dev/null +++ b/utils/config.ts @@ -0,0 +1,161 @@ +/** + * Config module + * + * Single point of access for the application confguration. Handles validation on required environment variables. + * The configuration is evaluate eagerly at the first access to the module. The module exposes convenient methods to access such value. + */ + +import { MailMultiTransportConnectionsFromString } from "io-functions-commons/dist/src/utils/multi_transport_connection"; +import * as t from "io-ts"; +import { readableReport } from "italia-ts-commons/lib/reporters"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; + +// exclude a specific value from a type +// as strict equality is performed, allowed input types are constrained to be values not references (object, arrays, etc) +// tslint:disable-next-line max-union-size +const AnyBut = ( + but: A, + base: t.Type = t.any +) => + t.brand( + base, + ( + s + ): s is t.Branded< + t.TypeOf, + { readonly AnyBut: unique symbol } + > => s !== but, + "AnyBut" + ); + +// configuration to send email +export type MailerConfig = t.TypeOf; +export const MailerConfig = t.intersection([ + // common required fields + t.interface({ + MAIL_FROM: NonEmptyString + }), + // the following union includes the possible configuration variants for different mail transports we use in prod + // undefined values are kept for easy usage + t.union([ + // Using sendgrid + // we allow mailup values as well, as sendgrid would be selected first if present + t.intersection([ + t.interface({ + MAILHOG_HOSTNAME: t.undefined, + MAIL_TRANSPORTS: t.undefined, + NODE_ENV: t.literal("production"), + SENDGRID_API_KEY: NonEmptyString + }), + t.partial({ + MAILUP_SECRET: NonEmptyString, + MAILUP_USERNAME: NonEmptyString + }) + ]), + // using mailup + t.interface({ + MAILHOG_HOSTNAME: t.undefined, + MAILUP_SECRET: NonEmptyString, + MAILUP_USERNAME: NonEmptyString, + MAIL_TRANSPORTS: t.undefined, + NODE_ENV: t.literal("production"), + SENDGRID_API_KEY: t.undefined + }), + // Using multi-transport definition + // Optional multi provider connection string + // The connection string must be in the format: + // [mailup:username:password;][sendgrid:apikey:;] + // Note that multiple instances of the same provider can be provided. + t.interface({ + MAILHOG_HOSTNAME: t.undefined, + MAILUP_SECRET: t.undefined, + MAILUP_USERNAME: t.undefined, + MAIL_TRANSPORTS: MailMultiTransportConnectionsFromString, + NODE_ENV: t.literal("production"), + SENDGRID_API_KEY: t.undefined + }), + // the following states that a mailhog configuration is optional and can be provided only if not in prod + t.interface({ + MAILHOG_HOSTNAME: NonEmptyString, + MAILUP_SECRET: t.undefined, + MAILUP_USERNAME: t.undefined, + MAIL_TRANSPORTS: t.undefined, + NODE_ENV: AnyBut("production", t.string), + SENDGRID_API_KEY: t.undefined + }) + ]) +]); + +// configuration for REQ_SERVICE_ID in dev +export type ReqServiceIdConfig = t.TypeOf; +export const ReqServiceIdConfig = t.union([ + t.interface({ + NODE_ENV: t.literal("production"), + REQ_SERVICE_ID: t.undefined + }), + t.interface({ + NODE_ENV: AnyBut("production", t.string), + REQ_SERVICE_ID: NonEmptyString + }) +]); + +// global app configuration +export type IConfig = t.TypeOf; +export const IConfig = t.intersection([ + t.interface({ + AZURE_NH_ENDPOINT: NonEmptyString, + AZURE_NH_HUB_NAME: NonEmptyString, + + COSMOSDB_KEY: NonEmptyString, + COSMOSDB_NAME: NonEmptyString, + COSMOSDB_URI: NonEmptyString, + + CUSTOMCONNSTR_COSMOSDB_KEY: NonEmptyString, + CUSTOMCONNSTR_COSMOSDB_URI: NonEmptyString, + + FUNCTIONS_PUBLIC_URL: NonEmptyString, + + MESSAGE_CONTAINER_NAME: NonEmptyString, + + PUBLIC_API_KEY: NonEmptyString, + PUBLIC_API_URL: NonEmptyString, + + QueueStorageConnection: NonEmptyString, + + SPID_LOGS_PUBLIC_KEY: NonEmptyString, + SUBSCRIPTIONS_FEED_TABLE: NonEmptyString, + + isProduction: t.boolean + }), + MailerConfig, + ReqServiceIdConfig +]); + +// No need to re-evaluate this object for each call +const errorOrConfig: t.Validation = IConfig.decode({ + ...process.env, + isProduction: process.env.NODE_ENV === "production" +}); + +/** + * Read the application configuration and check for invalid values. + * Configuration is eagerly evalued when the application starts. + * + * @returns either the configuration values or a list of validation errors + */ +export function getConfig(): t.Validation { + return errorOrConfig; +} + +/** + * Read the application configuration and check for invalid values. + * If the application is not valid, raises an exception. + * + * @returns the configuration values + * @throws validation errors found while parsing the application configuration + */ +export function getConfigOrThrow(): IConfig { + return errorOrConfig.getOrElseL(errors => { + throw new Error(`Invalid configuration: ${readableReport(errors)}`); + }); +} diff --git a/utils/cosmosdb.ts b/utils/cosmosdb.ts index 62b373c7..f561d178 100644 --- a/utils/cosmosdb.ts +++ b/utils/cosmosdb.ts @@ -2,12 +2,15 @@ * Use a singleton CosmosDB client across functions. */ import { CosmosClient } from "@azure/cosmos"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; + +import { getConfigOrThrow } from "../utils/config"; + +const config = getConfigOrThrow(); // Setup DocumentDB -export const cosmosDbUri = getRequiredStringEnv("COSMOSDB_URI"); -export const cosmosDbName = getRequiredStringEnv("COSMOSDB_NAME"); -export const cosmosDbKey = getRequiredStringEnv("COSMOSDB_KEY"); +export const cosmosDbUri = config.COSMOSDB_URI; +export const cosmosDbName = config.COSMOSDB_NAME; +export const cosmosDbKey = config.COSMOSDB_KEY; export const cosmosdbClient = new CosmosClient({ endpoint: cosmosDbUri, diff --git a/utils/email.ts b/utils/email.ts index ad5bb609..803b9285 100644 --- a/utils/email.ts +++ b/utils/email.ts @@ -18,6 +18,8 @@ import * as NodeMailer from "nodemailer"; import nodemailerSendgrid = require("nodemailer-sendgrid"); import Mail = require("nodemailer/lib/mailer"); +import { getConfigOrThrow } from "../utils/config"; + // 5 seconds timeout by default const DEFAULT_EMAIL_REQUEST_TIMEOUT_MS = 5000; @@ -28,6 +30,8 @@ const fetchWithTimeout = setFetchTimeout( abortableFetch ); +const conf = getConfigOrThrow(); + interface IMailUpOptions { mailupSecret: NonEmptyString; mailupUsername: NonEmptyString; @@ -60,7 +64,7 @@ export function getMailerTransporter(opts: MailTransportOptions): Mail { : // For development we use mailhog to intercept emails // Use the `docker-compose.yml` file to run the mailhog server NodeMailer.createTransport({ - host: process.env.MAILHOG_HOSTNAME || "localhost", + host: conf.MAILHOG_HOSTNAME || "localhost", port: 1025, secure: false }); diff --git a/utils/notification.ts b/utils/notification.ts index 9b31fb1c..dd586e0a 100644 --- a/utils/notification.ts +++ b/utils/notification.ts @@ -7,8 +7,8 @@ import { NonEmptyString } from "italia-ts-commons/lib/strings"; import * as azure from "azure-sb"; import { tryCatch } from "fp-ts/lib/TaskEither"; -import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; import { Platform, PlatformEnum } from "../generated/backend/Platform"; +import { getConfigOrThrow } from "../utils/config"; /** * Notification template. @@ -35,12 +35,11 @@ export enum APNSPushType { MDM = "mdm" } -const hubName = getRequiredStringEnv("AZURE_NH_HUB_NAME"); -const endpointOrConnectionString = getRequiredStringEnv("AZURE_NH_ENDPOINT"); +const config = getConfigOrThrow(); const notificationHubService = azure.createNotificationHubService( - hubName, - endpointOrConnectionString + config.AZURE_NH_HUB_NAME, + config.AZURE_NH_ENDPOINT ); /** diff --git a/yarn.lock b/yarn.lock index 3852a653..6ed42afc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2508,6 +2508,11 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"