Skip to content

Commit

Permalink
Migrate to common ESlint configuration (#68)
Browse files Browse the repository at this point in the history
* Install commons from verdaccio

* Fix lint issues in pusher

* Fix lint issues in api

* Fix lint issues in common

* Fix lint issues in e2e

* Finish eslint migration

* Finish eslint migration

* Update to latest commons eslint

* Use released package
  • Loading branch information
Siegrift authored Oct 9, 2023
1 parent b6e0d07 commit 71b83f0
Show file tree
Hide file tree
Showing 53 changed files with 1,127 additions and 384 deletions.
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
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) {
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

0 comments on commit 71b83f0

Please sign in to comment.