Skip to content

Instantly share code, notes, and snippets.

@renoirb
Created January 27, 2021 21:24
Show Gist options
  • Save renoirb/049a9bc69a4c19e06f18e20c974cb278 to your computer and use it in GitHub Desktop.
Save renoirb/049a9bc69a4c19e06f18e20c974cb278 to your computer and use it in GitHub Desktop.
Convict example setup
// 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
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'
}
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>
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)
})
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