Created
January 27, 2021 21:24
-
-
Save renoirb/049a9bc69a4c19e06f18e20c974cb278 to your computer and use it in GitHub Desktop.
Convict example setup
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// config.ts | |
import { join, resolve } from 'path' | |
import { default as convictFactory, Schema } from 'convict' | |
import dotenv from 'dotenv' | |
import { AppConfigSchema } from './types' | |
import { convict, maybeFilePath } from './utils' | |
const loadEnv = dotenv.config() | |
if (loadEnv.error) { | |
// tslint:disable-next-line | |
console.warn( | |
`No environment file .env found, ignoring.`, | |
loadEnv.error.message, | |
) | |
} | |
const { cwd } = process | |
const schema: Schema<AppConfigSchema> = { | |
enforceCsrf: { | |
default: false, | |
doc: 'Whether or not to enforce koa-csrf in form headers', | |
format: 'Boolean', | |
}, | |
appContextRoot: { | |
default: '/portal/bff', | |
doc: | |
'From where the API will answer from. Formerly refered to as BASE_NUXT + API.', | |
env: 'APP_CONTEXT_ROOT', | |
format: 'context-root', | |
}, | |
appFilesystemRoot: { | |
default: __dirname, | |
doc: 'Path on the filesystem where the app runtime is stored', | |
format: 'directory', | |
}, | |
appId: { | |
default: 'bff-data-layer', | |
doc: 'Application name identifier', | |
env: 'BFF_APP_ID', | |
format: String, | |
}, | |
version: { | |
arg: 'version', | |
default: 'dev', | |
doc: 'Application version identifier', | |
env: 'BFF_VERSION', | |
format: String, | |
}, | |
baseNuxt: { | |
default: '/portal', | |
doc: 'From which URL Path "context root" the portal will be calling from.', | |
env: 'BFF_BASE_NUXT', | |
format: 'context-root', | |
}, | |
baseURL: { | |
default: 'https://dev.app.example.org', | |
env: 'BFF_BASE_URL', | |
format: String, | |
}, | |
env: { | |
default: 'production', | |
doc: 'The process runtime deployment level.', | |
env: 'NODE_ENV', | |
format: ['production', 'development', 'test'], | |
}, | |
fallbackLocale: { | |
arg: 'fallback-locale', | |
default: 'en-CA', | |
doc: | |
'Language Tag for l1on an i18n, default "en-CA", MUST BE string separated by dash. First slot is language, Second is country code.', | |
env: 'BFF_FALLBACK_LOCALE', | |
format: 'locale', | |
}, | |
fallbackTimeZone: { | |
default: 'America/Montreal', | |
doc: 'TimeZone (or zoneinfo) in which we should adjust time and dates.', | |
env: 'BFF_FALLBACK_TIME_ZONE', | |
format: 'time-zone', | |
}, | |
enableFeatureFlags: { | |
default: '', | |
doc: 'Coma separated list of feature flags to enable', | |
env: 'BFF_ENABLE_FEATURE_FLAGS', | |
format: Array, | |
}, | |
host: { | |
arg: 'host', | |
default: '0.0.0.0', | |
doc: 'Service IPv4 address to bind service to.', | |
env: 'HOST', | |
format: 'ipaddress', | |
}, | |
log: { | |
write: { | |
default: false, | |
arg: 'log-write', | |
doc: 'Whether or not to write to ' + join(__dirname, '..', 'debug.json'), | |
format: 'Boolean', | |
}, | |
dir: { | |
default: resolve(cwd(), 'logs'), | |
doc: 'Which directory to write logs to. Directory MUST exist.', | |
env: 'BFF_LOG_DIR', | |
format: 'optional-directory', | |
}, | |
level: { | |
arg: 'log-level', | |
default: 'warn', | |
doc: 'Service log output level, one of: trace,debug,info,warn,error', | |
env: 'BFF_LOG_LEVEL', | |
format: ['trace', 'debug', 'info', 'warn', 'error'], | |
}, | |
}, | |
origin: { | |
hostname: { | |
default: 'private.dev.app.example.org', | |
doc: | |
'Internal/Private DNS Hostname of a data-source proxy', | |
env: 'BFF_ORIGIN_HOSTNAME', | |
format: String, | |
}, | |
port: { | |
default: 8080, | |
doc: 'Internal/Private TCP Port number of a data-source proxy.', | |
env: 'BFF_ORIGIN_PORT', // Possibly inexistent | |
format: 'port', | |
}, | |
}, | |
port: { | |
arg: 'port', | |
default: 2021, | |
doc: 'Service TCP Port number to expose.', | |
env: 'PORT', | |
format: 'port', | |
}, | |
} | |
const config = convict(convictFactory)(schema) | |
const env = config.get('env') | |
try { | |
const configs = [join(__dirname, '..', 'app.config.json')] | |
const envFile = maybeFilePath(join(__dirname, '..', `app.${env}.json`)) | |
if (typeof envFile === 'string') { | |
configs.push(envFile) | |
} | |
config.loadFile(configs) | |
const versionFile = maybeFilePath(join(__dirname, '..', `version.json`)) | |
if (typeof versionFile === 'string') { | |
config.loadFile(versionFile) | |
} | |
} catch (_) { | |
const message = `Could not find "app.${env}.json", continuing without it.` | |
// tslint:disable-next-line no-console | |
console.log(message) | |
} | |
config.validate({ allowed: 'strict' }) | |
export const getConfig = (): AppConfigSchema => config.getProperties() | |
export default config |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { FileSystem } from '@rushstack/node-core-library' | |
export const archiveIndexLoader = (dir: string): ReadonlyArray<string> => { | |
const parentPath = resolve(__dirname, dir) | |
const normalizedFilePath = resolve(parentPath, 'archivator.csv') | |
if (!FileSystem.exists(normalizedFilePath)) { | |
throw new Error( | |
`Cannot find archivator.csv archive file at ${normalizedFilePath}`, | |
) | |
} | |
// List all lines that aren’t empty | |
const loaded = FileSystem.readFile(normalizedFilePath) | |
.split('\n') | |
.filter(Boolean) | |
// Make them all as TemplateStrings, because jest.each likes them | |
const out = Object.freeze(loaded.map((i) => `${i}`)) as TemplateStringsArray | |
return out | |
} | |
import * as tzdb from 'timezones.json' | |
export type TimeZone = tzdb.Timezone | |
export const isTimeZone = (timeZone = 'America/Montreal'): boolean => { | |
let outcome = false | |
try { | |
const resolved = Intl.DateTimeFormat(undefined, { | |
timeZone, | |
}).resolvedOptions() | |
outcome = !!resolved.timeZone | |
} catch (e) { | |
outcome = false | |
} | |
return outcome | |
} | |
export const localeLooksLegitimate = (locale: string): boolean => { | |
const isAtLeastFiveChars = locale.length > 4 | |
const hasDash = locale[2] === '-' | |
const langCode = locale.split('-')[0].toLowerCase() | |
const langCodeIsOnlyTwo = langCode.length === 2 | |
return isAtLeastFiveChars && hasDash && langCodeIsOnlyTwo | |
} | |
/** | |
* Locale | |
* | |
* Is normally at least two two letter codes separated by a dash following IETF's [language tag][language-tag]: | |
* - First slot is language (e.g. French, "fr") | |
* - Second is country code (e.g. Canada, "CA") | |
* | |
* Other formats might be supported, ideally according to [IETF specification][language-tag] and [BCP47][ietf-bcp47] | |
* | |
* [ietf-bcp47]: https://tools.ietf.org/html/bcp47 | |
* [language-tag]: https://tools.ietf.org/html/rfc1766#section-2 | |
*/ | |
export class Locale { | |
/** | |
* The Language tag string | |
*/ | |
readonly code: string = 'en-CA' | |
readonly name: string = 'English (Canada)' | |
/** | |
* What LanPack file to use. | |
* | |
* This is because we do not need a list of translations for all possibilities. | |
* So, to avoid collisions, we name files with a locale name, and other locales | |
* will use from the the same one. | |
* | |
* If some day, we really want to make distinction in translations for | |
* a country and another, we will only need to change which name to use. | |
* | |
* Also, we might want to support showing data for a geographic region, but we don't have | |
* translations just yet. | |
* We can then tell which LanPack to use in the meantime. | |
* | |
* Valid locales could be: | |
* - de-DE | |
* - en-CA | |
* - fr-CA | |
* - pt-PT | |
* - sv-SE | |
*/ | |
readonly LanPackName: string = 'en-CA' | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Config } from 'convict' | |
import { LogLevel } from 'bunyan' | |
export interface AppConfigChildLog { | |
dir: string | |
level: LogLevel | |
write: boolean | |
} | |
export interface AppConfigChildOrigin { | |
hostname: string | |
port: number | |
} | |
export interface AppConfigSchema { | |
enforceCsrf: boolean | |
appContextRoot: string | |
appFilesystemRoot: string | |
appId: string | |
version: string | |
baseNuxt: string | |
baseURL: string | |
env: string | |
fallbackLocale: string | |
fallbackTimeZone: string | |
enableFeatureFlags: string | |
host: string | |
log: AppConfigChildLog | |
origin: AppConfigChildOrigin | |
port: number | |
} | |
export type ConvictAppConfig = Config<AppConfigSchema> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import test from 'ava' | |
import { | |
maybeExistingFileInAppFilesystemRoot, | |
maybeFilePath, | |
isHostname, | |
isHttp, | |
} from './utils' | |
test('isHttp', t => { | |
t.throws( | |
() => isHttp('ftp'), | |
Error, | |
'Invalid protocol "ftp", only "http" or "https" are acceptable.', | |
) | |
}) | |
test('isHostname', t => { | |
t.throws(() => isHostname('invalid_hostname.org'), Error) | |
t.true(isHostname('id.example.org')) | |
}) | |
test('maybeFilePath', async t => { | |
t.deepEqual(maybeFilePath(__dirname), __dirname) | |
t.deepEqual(maybeFilePath('bogus'), false) | |
}) | |
test('maybeExistingFileInAppFilesystemRoot', async t => { | |
// NOTE: If we were to want to check if a .ts file exists, it would not work | |
// because Ava will run test in transpiled code. | |
// But that test is good enough if we check for this current directory | |
// That's why we've picked "utils" below. | |
t.deepEqual(maybeExistingFileInAppFilesystemRoot('/utils'), __dirname) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { promisify } from 'util' | |
import { existsSync, readFile, readdir } from 'fs' | |
import { dirname, resolve } from 'path' | |
import { default as convictFactory } from 'convict' | |
import { FileSystem } from '@rushstack/node-core-library' | |
import { isHostname, isHttp, isTimeZone, localeLooksLegitimate } from './localization' | |
const promisifiedReadFile = promisify(readFile) | |
const promisifiedReadDir = promisify(readdir) | |
export const archiveIndexLoader = (relativeFilePath: string): ReadonlyArray<string> => { | |
const normalizedFilePath = resolve(__dirname, relativeFilePath) | |
const parentPath = dirname(normalizedFilePath) | |
if (!FileSystem.exists(normalizedFilePath)) { | |
throw new Error( | |
`Cannot find file ${normalizedFilePath}`, | |
) | |
} | |
// List all lines that aren’t empty | |
const loaded = FileSystem.readFile(normalizedFilePath) | |
.split('\n') | |
.filter(Boolean) | |
const out = loaded.join('\n') | |
return out | |
} | |
export const isHttp = (proto: string): boolean => { | |
const test = typeof proto === 'string' ? /^https?$/i.test(proto) : false | |
if (test === false) { | |
const inputValue = String(proto) | |
throw new Error( | |
`Invalid protocol "${inputValue}", only "http" or "https" are acceptable.`, | |
) | |
} | |
return test | |
} | |
export const isHostname = (hostname: string): boolean => { | |
const assertions = [] | |
assertions.push(typeof hostname === 'string') | |
// RFC1123 https://tools.ietf.org/html/rfc1123 | |
assertions.push( | |
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/i.test( | |
hostname, | |
), | |
) | |
// i.e. includes check whether there is a falsey test | |
// if it returns false, it means all tests passed | |
const test = assertions.includes(false) === false | |
if (test === false) { | |
const v = String(hostname) | |
throw new Error( | |
`Invalid hostname "${v}", only strings matching RFC1123 are valid.`, | |
) | |
} | |
return test | |
} | |
export const isDirectoryExists = (directoryPath: string): boolean => { | |
const exists = existsSync(directoryPath) | |
if (exists !== true) { | |
throw new Error(`Directory ${directoryPath} does not exists`) | |
} | |
return exists | |
} | |
export const maybeFilePath = (relativeFileName: string): string | boolean => { | |
const resolved = resolve(process.cwd(), relativeFileName) | |
const exists = existsSync(resolved) | |
if (exists !== true) { | |
return false | |
} | |
return resolved | |
} | |
export const maybeExistingFileInAppFilesystemRoot = ( | |
relativePath: string, | |
): string | boolean => { | |
const resolved = resolve(dirname(__dirname) + relativePath) | |
const exists = existsSync(resolved) | |
if (exists !== true) { | |
return false | |
} | |
return resolved | |
} | |
export const mustHaveFileContents = async (relativePath: string) => { | |
const resolved = resolve(dirname(__dirname) + relativePath) | |
const exists = existsSync(resolved) | |
if (exists !== true) { | |
throw new Error(`Runtime could not find files in "${resolved}"`) | |
} | |
const fileContents = await promisifiedReadFile(resolved, 'utf8') | |
return fileContents | |
} | |
export const readDir = async (relativePath: string): Promise<string[]> => { | |
const fullPath = dirname(__dirname) + relativePath | |
const resolved = resolve(fullPath) | |
const exists = existsSync(resolved) | |
if (exists !== true) { | |
throw new Error(`Runtime could not find files in "${resolved}"`) | |
} | |
const files: string[] = [] | |
const items = await promisifiedReadDir(resolved, { withFileTypes: true }) | |
for (const f of items) { | |
if ('name' in f) { | |
files.push(f.name) | |
} | |
} | |
return files | |
} | |
/** | |
* We should not mutate Convict. | |
* Ideally we should have a way to tell the schema and the factory. | |
* That's the closest to that I could make on a Thursday night at 19:00 | |
* When everything should work as before migration. | |
*/ | |
export const convict = (Convict: convictFactory): convictFactory => { | |
Convict.addFormat({ | |
coerce: (val: string): string | null => { | |
let out: string | null = `${val}` | |
try { | |
// isDirectoryExists(val) // @FIXME | |
} catch (e) { | |
out = null | |
} | |
return out | |
}, | |
name: 'optional-directory', | |
validate: (val: string): boolean => { | |
let out: boolean = false | |
try { | |
// isDirectoryExists(val) // @FIXME | |
out = typeof val === 'string' | |
out = true | |
} catch (e) { | |
// Nothing to do | |
} | |
return out | |
}, | |
}) | |
Convict.addFormat({ | |
coerce: (val: string): string => `${val}`, | |
name: 'time-zone', | |
validate: (val: string): boolean => isTimeZone(val), | |
}) | |
Convict.addFormat({ | |
coerce: (val: string): string => { | |
// isDirectoryExists(val) // @FIXME | |
return `${val}` | |
}, | |
name: 'directory', | |
// validate: (val: string): boolean => isDirectoryExists(val), // @FIXME | |
validate: (val: string): boolean => typeof val === 'string', | |
}) | |
Convict.addFormat({ | |
coerce: (val: string): string => { | |
isHttp(val) | |
return `${val}`.toLowerCase() | |
}, | |
name: 'http', | |
validate: (val: string): boolean => isHttp(val), | |
}) | |
Convict.addFormat({ | |
coerce: (val: string): string => { | |
isHostname(val) | |
return `${val}`.toLowerCase() | |
}, | |
name: 'hostname', | |
validate: (val: string): boolean => isHostname(val), | |
}) | |
Convict.addFormat({ | |
name: 'locale', | |
validate: (val: string): boolean => localeLooksLegitimate(val), | |
}) | |
Convict.addFormat({ | |
name: 'context-root', | |
validate: (val: string): boolean => { | |
const isOnlySlash = /^\/$/.test(val) | |
if (isOnlySlash) { | |
const message = `"${val}" cannot be used for context-root. Just use empty string.` | |
throw new Error(message) | |
} | |
const test = /^\/[a-z\/]+[^\/]$/i.test(val) | |
if (!test) { | |
const message = `"${val}" cannot be a valid Context-Root. It MUST be only alpha-numeric, start by /, but WITHOUT a trailing slash.` | |
throw new Error(message) | |
} | |
return true | |
}, | |
}) | |
return Convict | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment