Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamically load api config and refactor the logger #51

Merged
merged 12 commits into from
Oct 1, 2023
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Lint Typescript
run: pnpm run tsc
- name: Lint
run: pnpm run prettier:check && pnpm run eslint:check
- name: Test
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"eslint:fix": "pnpm recursive run eslint:fix",
"prettier:check": "pnpm recursive run prettier:check",
"prettier:fix": "pnpm recursive run prettier:fix",
"test": "pnpm recursive run test"
"test": "pnpm recursive run test",
"tsc": "pnpm recursive run tsc"
},
"keywords": [],
"license": "MIT",
Expand Down
16 changes: 16 additions & 0 deletions packages/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
LOGGER_ENABLED=true
LOG_COLORIZE=true
LOG_FORMAT=pretty
LOG_LEVEL=info

# Available options
# local (default) - loads config/signed-api.json from the filesystem
# aws-s3 - loads the config file from AWS S3
CONFIG_SOURCE=local

# Set these variables if you would like to source your config from AWS S3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_S3_BUCKET_NAME=
AWS_S3_BUCKET_PATH=
3 changes: 2 additions & 1 deletion packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ A service for storing and accessing signed data. It provides endpoints to handle

1. `cp config/signed-api.example.json config/signed-api.json` - To create a config file from the example one. Optionally
change the defaults.
2. `pnpm run dev` - To start the API server. The port number can be configured in the configuration file.
2. `cp .env.example .env` - To copy the example environment variables. Optionally change the defaults.
3. `pnpm run dev` - To start the API server. The port number can be configured in the configuration file.

## Deployment

Expand Down
5 changes: 0 additions & 5 deletions packages/api/config/signed-api.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,5 @@
"port": 8090,
"cache": {
"maxAgeSeconds": 300
},
"logger": {
"type": "pretty",
"styling": "on",
"minLevel": "debug"
}
}
7 changes: 5 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"main": "index.js",
"scripts": {
"build": "tsc --project .",
"build": "tsc --project tsconfig.build.json",
"clean": "rm -rf coverage dist",
"dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/index.ts\"",
"docker:build": "docker compose --file docker/docker-compose.yml build",
Expand All @@ -19,7 +19,8 @@
"prettier:check": "prettier --check \"./**/*.{js,ts,md,json}\"",
"prettier:fix": "prettier --write \"./**/*.{js,ts,md,json}\"",
"start-prod": "node dist/src/index.js",
"test": "jest"
"test": "jest",
"tsc": "tsc --project ."
},
"license": "MIT",
"devDependencies": {
Expand All @@ -30,6 +31,8 @@
},
"dependencies": {
"@api3/promise-utils": "0.4.0",
"@aws-sdk/client-s3": "^3.418.0",
"dotenv": "^16.3.1",
"ethers": "^5.7.2",
"express": "^4.18.2",
"lodash": "^4.17.21",
Expand Down
54 changes: 54 additions & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { go } from '@api3/promise-utils';
import { S3 } from '@aws-sdk/client-s3';
import { logger } from './logger';
import { Config, configSchema } from './schema';
import { loadEnv } from './env';

let config: Config | undefined;

export const getConfig = (): Config => {
if (!config) throw new Error(`Config has not been set yet`);

return config;
};

export const fetchAndCacheConfig = async (): Promise<Config> => {
const jsonConfig = await fetchConfig();
config = configSchema.parse(jsonConfig);
return config;
};

const fetchConfig = async (): Promise<any> => {
const env = loadEnv();
const source = env.CONFIG_SOURCE;
if (!source || source === 'local') {
return JSON.parse(readFileSync(join(__dirname, '../config/signed-api.json'), 'utf8'));
}
if (source === 'aws-s3') {
return await fetchConfigFromS3();
}
throw new Error(`Unable to load config CONFIG_SOURCE:${source}`);
};

const fetchConfigFromS3 = async (): Promise<any> => {
const env = loadEnv();
const region = env.AWS_REGION!; // Validated by environment variables schema.
const s3 = new S3({ region });

const params = {
Bucket: env.AWS_S3_BUCKET_NAME,
Key: env.AWS_S3_BUCKET_PATH,
};

logger.info(`Fetching config from AWS S3 region:${region}...`);
const res = await go(() => s3.getObject(params), { retries: 1 });
if (!res.success) {
logger.error('Error fetching config from AWS S3:', res.error);
throw res.error;
}
logger.info('Config fetched successfully from AWS S3');
const stringifiedConfig = await res.data.Body!.transformToString();
return JSON.parse(stringifiedConfig);
};
19 changes: 19 additions & 0 deletions packages/api/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { join } from 'path';
import dotenv from 'dotenv';
import { EnvConfig, envConfigSchema } from './schema';

let env: EnvConfig | undefined;

export const loadEnv = () => {
if (env) return env;

dotenv.config({ path: join(__dirname, '../.env') });

const parseResult = envConfigSchema.safeParse(process.env);
if (!parseResult.success) {
throw new Error(`Invalid environment variables:\n, ${JSON.stringify(parseResult.error.format())}`);
}

env = parseResult.data;
return env;
};
4 changes: 2 additions & 2 deletions packages/api/src/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
import { omit } from 'lodash';
import * as cacheModule from './cache';
import * as utilsModule from './utils';
import * as configModule from './config';
import { batchInsertData, getData, listAirnodeAddresses } from './handlers';
import { createSignedData, generateRandomWallet } from '../test/utils';

Expand All @@ -12,7 +12,7 @@ afterEach(() => {

beforeEach(() => {
jest
.spyOn(utilsModule, 'getConfig')
.spyOn(configModule, 'getConfig')
.mockImplementation(() => JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8')));
});

Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { CACHE_HEADERS, COMMON_HEADERS } from './constants';
import { deriveBeaconId, recoverSignerAddress } from './evm';
import { getAll, getAllAirnodeAddresses, prune, putAll } from './in-memory-cache';
import { ApiResponse } from './types';
import { generateErrorResponse, getConfig, isBatchUnique } from './utils';
import { generateErrorResponse, isBatchUnique } from './utils';
import { batchSignedDataSchema, evmAddressSchema } from './schema';
import { getConfig } from './config';

// Accepts a batch of signed data that is first validated for consistency and data integrity errors. If there is any
// issue during this step, the whole batch is rejected.
Expand Down
12 changes: 9 additions & 3 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
// create a test script with/without the source map support, build the project and run the built script using node.
import 'source-map-support/register';
import { startServer } from './server';
import { getConfig } from './utils';
import { logger } from './logger';
import { fetchAndCacheConfig } from './config';

logger.info('Using configuration', getConfig());
startServer();
async function main() {
const config = await fetchAndCacheConfig();
logger.info('Using configuration', config);

startServer(config);
}

main();
18 changes: 14 additions & 4 deletions packages/api/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { createLogger } from 'signed-api/common';
import { getConfig } from './utils';
import { createLogger, logConfigSchema } from 'signed-api/common';
import { loadEnv } from './env';

const config = getConfig();
export const logger = createLogger(config.logger);
// We need to load the environment variables before we can use the logger. Because we want the logger to always be
// available, we load the environment variables as a side effect during the module import.
const env = loadEnv();

const options = logConfigSchema.parse({
colorize: env.LOG_COLORIZE,
enabled: env.LOGGER_ENABLED,
minLevel: env.LOG_LEVEL,
format: env.LOG_FORMAT,
});

export const logger = createLogger(options);
66 changes: 65 additions & 1 deletion packages/api/src/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { ZodError } from 'zod';
import { configSchema, endpointSchema, endpointsSchema } from './schema';
import dotenv from 'dotenv';
import { configSchema, endpointSchema, endpointsSchema, envBooleanSchema, envConfigSchema } from './schema';

describe('endpointSchema', () => {
it('validates urlPath', () => {
Expand Down Expand Up @@ -48,3 +49,66 @@ describe('configSchema', () => {
expect(() => configSchema.parse(config)).not.toThrow();
});
});

describe('env config schema', () => {
it('parses boolean env variable correctly', () => {
expect(envBooleanSchema.parse('true')).toBe(true);
expect(envBooleanSchema.parse('false')).toBe(false);

// Using a function to create the expected error because the error message length is too long to be inlined. The
// error messages is trivially stringified if propagated to the user.
const createExpectedError = (received: string) =>
new ZodError([
{
code: 'invalid_union',
unionErrors: [
new ZodError([
{
received,
code: 'invalid_literal',
expected: 'true',
path: [],
message: 'Invalid literal value, expected "true"',
},
]),
new ZodError([
{
received,
code: 'invalid_literal',
expected: 'false',
path: [],
message: 'Invalid literal value, expected "false"',
},
]),
],
path: [],
message: 'Invalid input',
},
]);
expect(() => envBooleanSchema.parse('')).toThrow(createExpectedError(''));
expect(() => envBooleanSchema.parse('off')).toThrow(createExpectedError('off'));
});

it('parses example env correctly', () => {
// Load the example configuration from the ".env.example" file
const env = dotenv.parse(readFileSync(join(__dirname, '../.env.example'), 'utf8'));

expect(() => envConfigSchema.parse(env)).not.toThrow();
});

it('AWS_REGION is set when CONFIG_SOURCE is aws-s3', () => {
const env = {
CONFIG_SOURCE: 'aws-s3',
};

expect(() => envConfigSchema.parse(env)).toThrow(
new ZodError([
{
code: 'custom',
message: 'The AWS_REGION must be set when CONFIG_SOURCE is "aws-s3"',
path: ['AWS_REGION'],
},
])
);
});
});
35 changes: 33 additions & 2 deletions packages/api/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { uniqBy } from 'lodash';
import { logConfigSchema } from 'signed-api/common';
import { logFormatSchema, logLevelSchema } from 'signed-api/common';
import { z } from 'zod';

export const endpointSchema = z
Expand Down Expand Up @@ -28,7 +28,6 @@ export const configSchema = z
cache: z.object({
maxAgeSeconds: z.number().nonnegative().int(),
}),
logger: logConfigSchema,
})
.strict();

Expand All @@ -52,3 +51,35 @@ export type SignedData = z.infer<typeof signedDataSchema>;
export const batchSignedDataSchema = z.array(signedDataSchema);

export type BatchSignedData = z.infer<typeof batchSignedDataSchema>;

export const envBooleanSchema = z.union([z.literal('true'), z.literal('false')]).transform((val) => val === 'true');

// We apply default values to make it convenient to omit certain environment variables. The default values should be
// primarily focused on users and production usage.
export const envConfigSchema = z
.object({
LOGGER_ENABLED: envBooleanSchema.default('true'),
LOG_COLORIZE: envBooleanSchema.default('false'),
LOG_FORMAT: logFormatSchema.default('json'),
LOG_LEVEL: logLevelSchema.default('info'),

CONFIG_SOURCE: z.union([z.literal('local'), z.literal('aws-s3')]).default('local'),

AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_REGION: z.string().optional(),
AWS_S3_BUCKET_NAME: z.string().optional(),
AWS_S3_BUCKET_PATH: z.string().optional(),
})
.strip() // We parse from ENV variables of the process which has many variables that we don't care about.
.superRefine((val, ctx) => {
if (val.CONFIG_SOURCE === 'aws-s3' && !val.AWS_REGION) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'The AWS_REGION must be set when CONFIG_SOURCE is "aws-s3"',
path: ['AWS_REGION'],
});
}
});

export type EnvConfig = z.infer<typeof envConfigSchema>;
5 changes: 2 additions & 3 deletions packages/api/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import express from 'express';
import { getData, listAirnodeAddresses, batchInsertData } from './handlers';
import { getConfig } from './utils';
import { logger } from './logger';
import { Config } from './schema';

export const startServer = () => {
const config = getConfig();
export const startServer = (config: Config) => {
const app = express();

app.use(express.json());
Expand Down
17 changes: 8 additions & 9 deletions packages/api/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { ApiResponse } from './types';
import { COMMON_HEADERS } from './constants';
import { BatchSignedData, SignedData, configSchema } from './schema';
import { BatchSignedData, SignedData } from './schema';
import { ApiResponse } from './types';

export const isBatchUnique = (batchSignedData: BatchSignedData) =>
batchSignedData.length === new Set(batchSignedData.map(({ airnode, templateId }) => airnode.concat(templateId))).size;
export const isBatchUnique = (batchSignedData: BatchSignedData) => {
return (
batchSignedData.length ===
new Set(batchSignedData.map(({ airnode, templateId }) => airnode.concat(templateId))).size
);
};

export const isIgnored = (signedData: SignedData, ignoreAfterTimestamp: number) => {
return parseInt(signedData.timestamp) > ignoreAfterTimestamp;
Expand All @@ -19,6 +21,3 @@ export const generateErrorResponse = (
): ApiResponse => {
return { statusCode, headers: COMMON_HEADERS, body: JSON.stringify({ message, detail, extra }) };
};

export const getConfig = () =>
configSchema.parse(JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8')));
Loading