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:
commit
981a25fb1c
14 changed files with 219 additions and 181 deletions
|
@ -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()));
|
||||
|
|
|
@ -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;
|
18
packages/core/src/env-set/create-pool.ts
Normal file
18
packages/core/src/env-set/create-pool.ts
Normal 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;
|
|
@ -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;
|
18
packages/core/src/env-set/create-query-client.ts
Normal file
18
packages/core/src/env-set/create-query-client.ts
Normal 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;
|
|
@ -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', {}) });
|
||||
};
|
|
@ -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;
|
||||
|
|
28
packages/core/src/env-set/throw-errors.ts
Normal file
28
packages/core/src/env-set/throw-errors.ts
Normal 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;
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
})
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Reference in a new issue