0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #2850 from logto-io/gao-log-5092-class-envset

refactor(core,shared): `EnvSet` class
This commit is contained in:
Gao Sun 2023-01-09 14:50:51 +08:00 committed by GitHub
commit 981a25fb1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 219 additions and 181 deletions

View file

@ -38,7 +38,7 @@ export default async function initApp(app: Koa): Promise<void> {
app.use(koaConnectorErrorHandler());
app.use(koaI18next());
const provider = await initOidc(app);
const provider = initOidc(app);
initRouter(app, provider);
app.use(mount('/', koaRootProxy()));

View file

@ -1,42 +0,0 @@
import { assert, assertEnv } from '@silverhand/essentials';
import chalk from 'chalk';
import { createMockPool, createMockQueryResult, createPool, parseDsn } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
const createPoolByEnv = async (isTest: boolean) => {
// Database connection is disabled in unit test environment
if (isTest) {
return createMockPool({ query: async () => createMockQueryResult([]) });
}
const key = 'DB_URL';
const interceptors = [...createInterceptors()];
try {
const databaseDsn = assertEnv(key);
assert(parseDsn(databaseDsn).databaseName, new Error('Database name is required in `DB_URL`'));
return await createPool(databaseDsn, { interceptors });
} catch (error: unknown) {
if (error instanceof Error && error.message === `env variable ${key} not found`) {
console.error(
`${chalk.red('[error]')} No Postgres DSN (${chalk.green(
key
)}) found in env variables.\n\n` +
` Either provide it in your env, or add it to the ${chalk.blue(
'.env'
)} file in the Logto project root.\n\n` +
` If you want to set up a new Logto database, run ${chalk.green(
'npm run cli db seed'
)} before setting env ${chalk.green(key)}.\n\n` +
` Visit ${chalk.blue(
'https://docs.logto.io/docs/references/core/configuration'
)} for more info about setting up env.\n`
);
}
throw error;
}
};
export default createPoolByEnv;

View file

@ -0,0 +1,18 @@
import { assert } from '@silverhand/essentials';
import { createMockPool, createMockQueryResult, createPool, parseDsn } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
const createPoolByEnv = async (databaseDsn: string, isTest: boolean) => {
// Database connection is disabled in unit test environment
if (isTest) {
return createMockPool({ query: async () => createMockQueryResult([]) });
}
const interceptors = [...createInterceptors()];
assert(parseDsn(databaseDsn).databaseName, new Error('Database name is required'));
return createPool(databaseDsn, { interceptors });
};
export default createPoolByEnv;

View file

@ -1,43 +0,0 @@
import { assert, assertEnv } from '@silverhand/essentials';
import { PostgresQueryClient } from '@withtyped/postgres';
import chalk from 'chalk';
import { parseDsn } from 'slonik';
import { MockQueryClient } from '#src/test-utils/query-client.js';
const createQueryClientByEnv = (isTest: boolean) => {
// Database connection is disabled in unit test environment
if (isTest) {
return new MockQueryClient();
}
const key = 'DB_URL';
try {
const databaseDsn = assertEnv(key);
assert(parseDsn(databaseDsn), new Error('Database name is required in `DB_URL`'));
return new PostgresQueryClient({ connectionString: databaseDsn });
} catch (error: unknown) {
if (error instanceof Error && error.message === `env variable ${key} not found`) {
console.error(
`${chalk.red('[error]')} No Postgres DSN (${chalk.green(
key
)}) found in env variables.\n\n` +
` Either provide it in your env, or add it to the ${chalk.blue(
'.env'
)} file in the Logto project root.\n\n` +
` If you want to set up a new Logto database, run ${chalk.green(
'npm run cli db seed'
)} before setting env ${chalk.green(key)}.\n\n` +
` Visit ${chalk.blue(
'https://docs.logto.io/docs/references/core/configuration'
)} for more info about setting up env.\n`
);
}
throw error;
}
};
export default createQueryClientByEnv;

View file

@ -0,0 +1,18 @@
import { assert } from '@silverhand/essentials';
import { PostgresQueryClient } from '@withtyped/postgres';
import { parseDsn } from 'slonik';
import { MockQueryClient } from '#src/test-utils/query-client.js';
const createQueryClient = (databaseDsn: string, isTest: boolean) => {
// Database connection is disabled in unit test environment
if (isTest) {
return new MockQueryClient();
}
assert(parseDsn(databaseDsn), new Error('Database name is required'));
return new PostgresQueryClient({ connectionString: databaseDsn });
};
export default createQueryClient;

View file

@ -1,12 +0,0 @@
import { appendFileSync } from 'fs';
import dotenv from 'dotenv';
import { findUp } from 'find-up';
export const appendDotEnv = (key: string, value: string) => {
appendFileSync('.env', `${key}=${value}\n`);
};
export const configDotEnv = async () => {
dotenv.config({ path: await findUp('.env', {}) });
};

View file

@ -1,5 +1,6 @@
import { tryThat } from '@logto/shared';
import type { Optional } from '@silverhand/essentials';
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import type { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik';
@ -8,10 +9,11 @@ import { getOidcConfigs } from '#src/libraries/logto-config.js';
import { appendPath } from '#src/utils/url.js';
import { checkAlterationState } from './check-alteration-state.js';
import createPoolByEnv from './create-pool-by-env.js';
import createQueryClientByEnv from './create-query-client-by-env.js';
import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage, throwNotLoadedError } from './throw-errors.js';
export enum MountedApps {
Api = 'api',
@ -21,7 +23,7 @@ export enum MountedApps {
Welcome = 'welcome',
}
const loadEnvValues = async () => {
const loadEnvValues = () => {
const isProduction = getEnv('NODE_ENV') === 'production';
const isTest = getEnv('NODE_ENV') === 'test';
const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
@ -29,6 +31,7 @@ const loadEnvValues = async () => {
const port = Number(getEnv('PORT', '3001'));
const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`;
const endpoint = getEnv('ENDPOINT', localhostUrl);
const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
return Object.freeze({
isTest,
@ -40,6 +43,7 @@ const loadEnvValues = async () => {
port,
localhostUrl,
endpoint,
dbUrl: databaseUrl,
userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'),
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')),
@ -47,68 +51,71 @@ const loadEnvValues = async () => {
});
};
const throwNotLoadedError = () => {
throw new Error(
'The env set is not loaded. Make sure to call `await envSet.load()` before using it.'
);
};
class EnvSet {
static envValues: ReturnType<typeof loadEnvValues> = loadEnvValues();
/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
function createEnvSet() {
let values: Optional<Awaited<ReturnType<typeof loadEnvValues>>>;
let pool: Optional<DatabasePool>;
#pool: Optional<DatabasePool>;
// Use another pool for `withtyped` while adopting the new model,
// as we cannot extract the original PgPool from slonik
let queryClient: Optional<QueryClient<PostgreSql>>;
let oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
#queryClient: Optional<QueryClient<PostgreSql>>;
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
return {
get values() {
if (!values) {
return throwNotLoadedError();
}
constructor(public readonly databaseUrl = EnvSet.envValues.dbUrl) {}
return values;
},
get pool() {
if (!pool) {
return throwNotLoadedError();
}
get values() {
return EnvSet.envValues;
}
return pool;
},
get poolSafe() {
return pool;
},
get queryClient() {
if (!queryClient) {
return throwNotLoadedError();
}
get isTest() {
return EnvSet.envValues.isTest;
}
return queryClient;
},
get queryClientSafe() {
return queryClient;
},
get oidc() {
if (!oidc) {
return throwNotLoadedError();
}
get pool() {
if (!this.#pool) {
return throwNotLoadedError();
}
return oidc;
},
load: async () => {
values = await loadEnvValues();
pool = await createPoolByEnv(values.isTest);
queryClient = createQueryClientByEnv(values.isTest);
return this.#pool;
}
const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs(pool)]);
oidc = await loadOidcValues(appendPath(values.endpoint, '/oidc').toString(), oidcConfigs);
},
};
get poolSafe() {
return this.#pool;
}
get queryClient() {
if (!this.#queryClient) {
return throwNotLoadedError();
}
return this.#queryClient;
}
get queryClientSafe() {
return this.#queryClient;
}
get oidc() {
if (!this.#oidc) {
return throwNotLoadedError();
}
return this.#oidc;
}
async load() {
const pool = await createPool(this.databaseUrl, this.isTest);
this.#pool = pool;
this.#queryClient = createQueryClient(this.databaseUrl, this.isTest);
const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs(pool)]);
this.#oidc = await loadOidcValues(
appendPath(this.values.endpoint, '/oidc').toString(),
oidcConfigs
);
}
}
/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation */
const envSet = createEnvSet();
const envSet = new EnvSet();
export default envSet;

View file

@ -0,0 +1,28 @@
import chalk from 'chalk';
export const throwNotLoadedError = () => {
throw new Error(
'The env set is not loaded. Make sure to call `await envSet.load()` before using it.'
);
};
export const throwErrorWithDsnMessage = (error: unknown) => {
const key = 'DB_URL';
if (error instanceof Error && error.message === `env variable ${key} not found`) {
console.error(
`${chalk.red('[error]')} No Postgres DSN (${chalk.green(key)}) found in env variables.\n\n` +
` Either provide it in your env, or add it to the ${chalk.blue(
'.env'
)} file in the Logto project root.\n\n` +
` If you want to set up a new Logto database, run ${chalk.green(
'npm run cli db seed'
)} before setting env ${chalk.green(key)}.\n\n` +
` Visit ${chalk.blue(
'https://docs.logto.io/docs/references/core/configuration'
)} for more info about setting up env.\n`
);
}
throw error;
};

View file

@ -1,14 +1,19 @@
import { noop } from '@silverhand/essentials';
import dotenv from 'dotenv';
import { findUp } from 'find-up';
import Koa from 'koa';
import { loadConnectorFactories } from './connectors/index.js';
import { configDotEnv } from './env-set/dot-env.js';
import envSet from './env-set/index.js';
import initI18n from './i18n/init.js';
dotenv.config({ path: await findUp('.env', {}) });
// Import after env has configured
const { default: envSet } = await import('./env-set/index.js');
await envSet.load();
const { loadConnectorFactories } = await import('./connectors/index.js');
try {
await configDotEnv();
await envSet.load();
const app = new Koa({
proxy: envSet.values.trustProxyHeader,
});

View file

@ -48,7 +48,7 @@ mockEsm('#src/queries/application.js', () => ({
}));
// eslint-disable-next-line unicorn/consistent-function-scoping
mockEsmDefault('#src/env-set/create-query-client-by-env.js', () => () => queryClient);
mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient);
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
const { triggerInteractionHooksIfNeeded } = await import('./hook.js');

View file

@ -5,6 +5,6 @@ import initOidc from './init.js';
describe('oidc provider init', () => {
it('init should not throw', async () => {
const app = new Koa();
await expect(initOidc(app)).resolves.not.toThrow();
expect(() => initOidc(app)).not.toThrow();
});
});

View file

@ -23,7 +23,7 @@ import assertThat from '#src/utils/assert-that.js';
import { claimToUserKey, getUserClaims } from './scope.js';
export default async function initOidc(app: Koa): Promise<Provider> {
export default function initOidc(app: Koa): Provider {
const { issuer, cookieKeys, privateJwks, defaultIdTokenTtl, defaultRefreshTokenTtl } =
envSet.oidc;
const logoutSource = readFileSync('static/html/logout.html', 'utf8');

View file

@ -1,9 +1,56 @@
import { trySafe } from './function.js';
import { trySafe, tryThat } from './function.js';
describe('tryThat()', () => {
it('should directly execute and return or throw if the function is not a Promise', () => {
expect(tryThat(() => 'foo', new Error('try'))).toStrictEqual('foo');
expect(() =>
tryThat(() => {
throw new Error('Test');
}, new Error('try'))
).toThrowError(new Error('try'));
expect(() =>
tryThat(
() => {
throw new Error('Test');
},
(error) => {
throw new Error(String(error instanceof Error && error.message) + ' try');
}
)
).toThrowError(new Error('Test try'));
});
it('should execute or unwrap a Promise and throw the error', async () => {
expect(
await tryThat(
new Promise((resolve) => {
setTimeout(() => {
resolve('bar');
}, 0);
}),
new Error('try')
)
).toStrictEqual('bar');
await expect(
tryThat(
async () =>
new Promise((resolve, reject) => {
reject();
}),
() => {
throw new Error('try');
}
)
).rejects.toStrictEqual(new Error('try'));
});
});
describe('trySafe()', () => {
it('should directly execute and return if the function is not a Promise', () => {
expect(trySafe(() => 'foo')).toStrictEqual('foo');
expect(
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
trySafe(() => {
throw new Error('Test');
})

View file

@ -1,29 +1,41 @@
export const tryThat = async <T, E extends Error>(
exec: Promise<T> | (() => Promise<T>),
onError: E | ((error: unknown) => never)
): Promise<T> => {
try {
return await (typeof exec === 'function' ? exec() : exec);
} catch (error: unknown) {
if (onError instanceof Error) {
// https://github.com/typescript-eslint/typescript-eslint/issues/3814
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw onError;
}
return onError(error);
}
};
export const isPromise = (value: unknown): value is Promise<unknown> =>
value !== null &&
(typeof value === 'object' || typeof value === 'function') &&
'then' in value &&
typeof value.then === 'function';
export type TryThat = {
<T>(exec: () => T, onError: Error | ((error: unknown) => never)): T;
<T>(
exec: Promise<T> | (() => Promise<T>),
onError: Error | ((error: unknown) => never)
): Promise<T>;
};
export const tryThat: TryThat = (exec, onError) => {
const handleError = (error: unknown) => {
if (onError instanceof Error) {
throw onError;
}
return onError(error);
};
try {
const unwrapped = typeof exec === 'function' ? exec() : exec;
return isPromise(unwrapped)
? // eslint-disable-next-line promise/prefer-await-to-then
unwrapped.catch(handleError)
: unwrapped;
} catch (error: unknown) {
return handleError(error);
}
};
export type TrySafe = {
<T>(exec: Promise<T> | (() => Promise<T>)): Promise<T | undefined>;
<T>(exec: () => T): T | undefined;
<T>(exec: Promise<T> | (() => Promise<T>)): Promise<T | undefined>;
};
export const trySafe: TrySafe = (exec) => {