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

Migrate to common ESlint configuration #68

Merged
merged 9 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 2 additions & 68 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,72 +1,6 @@
module.exports = {
root: true, // https://github.com/eslint/eslint/issues/13385#issuecomment-641252879
env: {
es6: true,
jest: true,
node: true,
},
parser: '@typescript-eslint/parser',
extends: ['./node_modules/@api3/commons/dist/eslint/universal', './node_modules/@api3/commons/dist/eslint/jest'],
parserOptions: {
ecmaVersion: 11,
sourceType: 'module',
},
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:jest/recommended',
],
plugins: ['@typescript-eslint', 'import', 'jest'],
rules: {
// TypeScript
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/camelcase': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// Turning off, because it conflicts with prettier
'@typescript-eslint/indent': ['off'],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
// Leave vars as 'all' to force everything to be handled when pattern matching
// Variables can be ignored by prefixing with an '_'
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', vars: 'all' }],
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-var-requires': 'off',

// eslint-plugin-import
'import/namespace': ['error', { allowComputed: true }],
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'sibling', 'parent', 'index', 'object', 'type'],
pathGroups: [
{
pattern: 'mock-utils',
group: 'builtin',
patternOptions: { matchBase: true, nocomment: true },
},
],
},
],

// ESLint
'comma-dangle': ['error', 'only-multiline'],
indent: 'off',
'no-console': 'error',
'no-useless-escape': 'off',
semi: 'error',
eqeqeq: ['error', 'smart'],

// Jest
'jest/valid-title': 'off', // Prevents using "<function-name>.name" as a test name
project: ['./tsconfig.json', './packages/*/tsconfig.json'],
},
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"keywords": [],
"license": "MIT",
"devDependencies": {
"@api3/commons": "^0.1.0",
"@types/jest": "^29.5.5",
"@types/node": "^20.8.0",
"@typescript-eslint/eslint-plugin": "^6.7.3",
Expand Down
1 change: 1 addition & 0 deletions packages/api/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const config = require('../../jest.config');

module.exports = {
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SignedData } from './schema';
import type { SignedData } from './schema';

type SignedDataCache = Record<
string, // Airnode ID.
Expand Down
25 changes: 14 additions & 11 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node: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';
import { logger } from './logger';
import { type Config, configSchema } from './schema';

let config: Config | undefined;

Expand All @@ -23,13 +25,14 @@ export const fetchAndCacheConfig = async (): Promise<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();
switch (source) {
case 'local': {
return JSON.parse(readFileSync(join(__dirname, '../config/signed-api.json'), 'utf8'));
}
case 'aws-s3': {
return fetchConfigFromS3();
}
}
throw new Error(`Unable to load config CONFIG_SOURCE:${source}`);
};

const fetchConfigFromS3 = async (): Promise<any> => {
Expand All @@ -43,7 +46,7 @@ const fetchConfigFromS3 = async (): Promise<any> => {
};

logger.info(`Fetching config from AWS S3 region:${region}...`);
const res = await go(() => s3.getObject(params), { retries: 1 });
const res = await go(async () => s3.getObject(params), { retries: 1 });
if (!res.success) {
logger.error('Error fetching config from AWS S3:', res.error);
throw res.error;
Expand Down
6 changes: 4 additions & 2 deletions packages/api/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { join } from 'path';
import { join } from 'node:path';

import dotenv from 'dotenv';
import { EnvConfig, envConfigSchema } from './schema';

import { type EnvConfig, envConfigSchema } from './schema';

let env: EnvConfig | undefined;

Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/evm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ethers } from 'ethers';
import { SignedData } from './schema';

import type { SignedData } from './schema';

export const decodeData = (data: string) => ethers.utils.defaultAbiCoder.decode(['int256'], data);

Expand Down
34 changes: 19 additions & 15 deletions packages/api/src/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { omit } from 'lodash';

import { createSignedData, generateRandomWallet } from '../test/utils';

import * as cacheModule from './cache';
import * as configModule from './config';
import { batchInsertData, getData, listAirnodeAddresses } from './handlers';
import { createSignedData, generateRandomWallet } from '../test/utils';

afterEach(() => {
cacheModule.setCache({});
});

// eslint-disable-next-line jest/no-hooks
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
beforeEach(() => {
jest
.spyOn(configModule, 'getConfig')
.mockImplementation(() => JSON.parse(readFileSync(join(__dirname, '../config/signed-api.example.json'), 'utf8')));
});

afterEach(() => {
cacheModule.setCache({});
});

describe(batchInsertData.name, () => {
it('drops the batch if it is invalid', async () => {
const invalidData = await createSignedData({ signature: '0xInvalid' });
const batchData = [await createSignedData(), invalidData];

const result = await batchInsertData(batchData);

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({
message: 'Unable to recover signer address',
detail:
Expand All @@ -37,15 +41,15 @@ describe(batchInsertData.name, () => {
},
statusCode: 400,
});
expect(cacheModule.getCache()).toEqual({});
expect(cacheModule.getCache()).toStrictEqual({});
});

it('inserts the batch if data is valid', async () => {
const batchData = [await createSignedData(), await createSignedData()];

const result = await batchInsertData(batchData);

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({ count: 2 }),
headers: {
'access-control-allow-methods': '*',
Expand All @@ -54,7 +58,7 @@ describe(batchInsertData.name, () => {
},
statusCode: 201,
});
expect(cacheModule.getCache()).toEqual({
expect(cacheModule.getCache()).toStrictEqual({
[batchData[0]!.airnode]: {
[batchData[0]!.templateId]: [batchData[0]],
},
Expand All @@ -72,7 +76,7 @@ describe(getData.name, () => {

const result = await getData('0xInvalid', 0);

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({ message: 'Invalid request, airnode address must be an EVM address' }),
headers: {
'access-control-allow-methods': '*',
Expand All @@ -90,7 +94,7 @@ describe(getData.name, () => {

const result = await getData(airnodeWallet.address, 0);

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({
count: 2,
data: {
Expand Down Expand Up @@ -120,7 +124,7 @@ describe(getData.name, () => {

const result = await getData(airnodeWallet.address, 30);

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({
count: 1,
data: {
Expand All @@ -147,7 +151,7 @@ describe(listAirnodeAddresses.name, () => {

const result = await listAirnodeAddresses();

expect(result).toEqual({
expect(result).toStrictEqual({
body: JSON.stringify({
count: 1,
'available-airnodes': [airnodeWallet.address],
Expand Down
52 changes: 32 additions & 20 deletions packages/api/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,85 @@
import { go, goSync } from '@api3/promise-utils';
import { isEmpty, isNil, omit, size } from 'lodash';

import { getConfig } from './config';
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, isBatchUnique } from './utils';
import { batchSignedDataSchema, evmAddressSchema } from './schema';
import { getConfig } from './config';
import type { ApiResponse } from './types';
import { generateErrorResponse, isBatchUnique } from './utils';

// 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.
//
// Otherwise, each data is inserted to the storage even though they might already be more fresh data. This might be
// important for the delayed endpoint which may not be allowed to return the fresh data yet.
export const batchInsertData = async (requestBody: unknown): Promise<ApiResponse> => {
const goValidateSchema = await go(() => batchSignedDataSchema.parseAsync(requestBody));
if (!goValidateSchema.success)
const goValidateSchema = await go(async () => batchSignedDataSchema.parseAsync(requestBody));
if (!goValidateSchema.success) {
return generateErrorResponse(
400,
'Invalid request, body must fit schema for batch of signed data',
goValidateSchema.error.message
);
}

// Ensure there is at least one signed data to push
const batchSignedData = goValidateSchema.data;
if (isEmpty(batchSignedData)) return generateErrorResponse(400, 'No signed data to push');

// Check whether the size of batch exceeds a maximum batch size
const { maxBatchSize, endpoints } = getConfig();
if (size(batchSignedData) > maxBatchSize)
if (size(batchSignedData) > maxBatchSize) {
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
return generateErrorResponse(400, `Maximum batch size (${maxBatchSize}) exceeded`);
}

// Check whether any duplications exist
if (!isBatchUnique(batchSignedData)) return generateErrorResponse(400, 'No duplications are allowed');

// Check validations that can be done without using http request, returns fail response in first error
const signedDataValidationResults = batchSignedData.map((signedData) => {
const goRecoverSigner = goSync(() => recoverSignerAddress(signedData));
if (!goRecoverSigner.success)
if (!goRecoverSigner.success) {
return generateErrorResponse(400, 'Unable to recover signer address', goRecoverSigner.error.message, signedData);
}

if (signedData.airnode !== goRecoverSigner.data)
if (signedData.airnode !== goRecoverSigner.data) {
return generateErrorResponse(400, 'Signature is invalid', undefined, signedData);
}

const goDeriveBeaconId = goSync(() => deriveBeaconId(signedData.airnode, signedData.templateId));
if (!goDeriveBeaconId.success)
if (!goDeriveBeaconId.success) {
return generateErrorResponse(
400,
'Unable to derive beaconId by given airnode and templateId',
goDeriveBeaconId.error.message,
signedData
);
}

if (signedData.beaconId !== goDeriveBeaconId.data)
if (signedData.beaconId !== goDeriveBeaconId.data) {
return generateErrorResponse(400, 'beaconId is invalid', undefined, signedData);
}

return null;
});
const firstError = signedDataValidationResults.find(Boolean);
if (firstError) return firstError;

// Write batch of validated data to the database
const goBatchWriteDb = await go(() => putAll(batchSignedData));
if (!goBatchWriteDb.success)
const goBatchWriteDb = await go(async () => putAll(batchSignedData));
if (!goBatchWriteDb.success) {
return generateErrorResponse(500, 'Unable to send batch of signed data to database', goBatchWriteDb.error.message);
}

// Prune the cache with the data that is too old (no endpoint will ever return it)
const maxDelay = endpoints.reduce((acc, endpoint) => Math.max(acc, endpoint.delaySeconds), 0);
const maxIgnoreAfterTimestamp = Math.floor(Date.now() / 1000 - maxDelay);
const goPruneCache = await go(() => prune(batchSignedData, maxIgnoreAfterTimestamp));
if (!goPruneCache.success)
const goPruneCache = await go(async () => prune(batchSignedData, maxIgnoreAfterTimestamp));
if (!goPruneCache.success) {
return generateErrorResponse(500, 'Unable to remove outdated cache data', goPruneCache.error.message);
}

return { statusCode: 201, headers: COMMON_HEADERS, body: JSON.stringify({ count: batchSignedData.length }) };
};
Expand All @@ -81,14 +90,16 @@ export const batchInsertData = async (requestBody: unknown): Promise<ApiResponse
export const getData = async (airnodeAddress: string, delaySeconds: number): Promise<ApiResponse> => {
if (isNil(airnodeAddress)) return generateErrorResponse(400, 'Invalid request, airnode address is missing');

const goValidateSchema = await go(() => evmAddressSchema.parseAsync(airnodeAddress));
if (!goValidateSchema.success)
const goValidateSchema = await go(async () => evmAddressSchema.parseAsync(airnodeAddress));
if (!goValidateSchema.success) {
return generateErrorResponse(400, 'Invalid request, airnode address must be an EVM address');
}

const ignoreAfterTimestamp = Math.floor(Date.now() / 1000 - delaySeconds);
const goReadDb = await go(() => getAll(airnodeAddress, ignoreAfterTimestamp));
if (!goReadDb.success)
const goReadDb = await go(async () => getAll(airnodeAddress, ignoreAfterTimestamp));
if (!goReadDb.success) {
return generateErrorResponse(500, 'Unable to get signed data from database', goReadDb.error.message);
}

const data = goReadDb.data.reduce((acc, signedData) => {
return { ...acc, [signedData.beaconId]: omit(signedData, 'beaconId') };
Expand All @@ -104,9 +115,10 @@ export const getData = async (airnodeAddress: string, delaySeconds: number): Pro
// Returns all airnode addresses for which there is data. Note, that the delayed endpoint may not be allowed to show
// it.
export const listAirnodeAddresses = async (): Promise<ApiResponse> => {
const goAirnodeAddresses = await go(() => getAllAirnodeAddresses());
if (!goAirnodeAddresses.success)
const goAirnodeAddresses = await go(async () => getAllAirnodeAddresses());
if (!goAirnodeAddresses.success) {
return generateErrorResponse(500, 'Unable to scan database', goAirnodeAddresses.error.message);
}
const airnodeAddresses = goAirnodeAddresses.data;

return {
Expand Down
Loading