diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 0000000000..3004188572 --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,11 @@ +import { FetchBaseQueryMeta } from '@reduxjs/toolkit/dist/query' +import { captureException } from '@sentry/react' + +export class ApiValidateError extends Error { + constructor(res: any, meta: FetchBaseQueryMeta | undefined) { + super('Data error. Please try again later.') + const e = new Error(`[API validate fail] ${meta?.request.url}`) + e.name = 'APIValidateFail' + captureException(e, { level: 'error', extra: { res, meta } }) + } +} diff --git a/src/services/blackjack.ts b/src/services/blackjack.ts index df8333139e..57eaa4b84f 100644 --- a/src/services/blackjack.ts +++ b/src/services/blackjack.ts @@ -1,18 +1,20 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { BLACKJACK_API } from 'constants/env' +import { ApiValidateError } from 'constants/errors' +import { isArray, isBoolean, isNumber, isString, isStruct } from 'utils/validate' -type BlackjackCheck = { - blacklisted: boolean - expiryMs: string - reason: number -} - -type BlackjackResponse = { - data: { - wallets: BlackjackCheck[] - } -} +const verifyBlackjackResponse = isStruct({ + data: isStruct({ + wallets: isArray( + isStruct({ + blacklisted: isBoolean, + updatedMs: isString, + reason: isNumber, + }), + ), + }), +}) const blackjackApi = createApi({ reducerPath: 'blackjackApi', @@ -20,15 +22,18 @@ const blackjackApi = createApi({ baseUrl: `${BLACKJACK_API}/v1`, }), endpoints: builder => ({ - checkBlackjack: builder.query({ + checkBlackjack: builder.query({ query: (address: string) => ({ url: '/check', params: { wallets: address, }, }), - transformResponse: (res: BlackjackResponse): BlackjackCheck => { - return res.data.wallets[0] + transformResponse: (res: unknown, meta) => { + if (verifyBlackjackResponse(res)) { + return res.data.wallets[0] + } + throw new ApiValidateError(res, meta) }, }), }), diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000000..990f9d86eb --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +type GuardedType = T extends (x: any) => x is infer U ? U : never + +function isNull(input: unknown): input is null | undefined { + if (input === undefined) return true + if (input === null) return true + return false +} +export function isNumber(input: unknown): input is number { + return typeof input === 'number' +} + +export function isBoolean(input: unknown | any): input is boolean { + return typeof input === 'boolean' +} + +export function isString(input: unknown): input is string { + return typeof input === 'string' +} + +// @ts-ignore +export function isArray(elementVerifier: T): (input: unknown) => input is GuardedType[] { + return (input: unknown): input is GuardedType[] => { + if (!Array.isArray(input)) return false + // @ts-ignore + return input.every(elementVerifier) + } +} + +export function isStruct( + struct: T, +): (input: unknown) => input is { readonly [keys in U]: GuardedType } { + const verifiers = Object.entries(struct) + + return (input: unknown): input is { [keys in U]: GuardedType } => { + if (typeof input !== 'object') return false + if (isNull(input)) return false + return verifiers.every(([key, verify]) => verify((input as Record)[key])) + } +} + +// @ts-ignore +type VerifierTypes = + | typeof isString + | typeof isNumber + | typeof isBoolean + | ReturnType + | ReturnType + +/** + * Use for fields that acceptable to be `undefined` or `null` + * + * Do not use this for fields expected to be non-null to be run correctly, e.g: `response.data` + * + * If you are using nesting, it's likely you've used it incorrectly. + */ +export function isNullable( + verifier: T, +): (input: unknown) => input is null | undefined | GuardedType { + return (input: unknown): input is null | undefined | GuardedType => { + if (input === undefined) return true + if (input === null) return true + return verifier(input) + } +} + +/* eslint-disable @typescript-eslint/no-unused-vars */ +const ValidateBalanceExample = isArray( + isStruct({ + result: isNullable(isArray(isString)), + }), +) +const ValidateComplexResponseExample = isStruct({ + data: isStruct({ + wallets: isArray( + isStruct({ + address: isString, + ens: isNullable(isString), + ETH: isNumber, + reward: isNullable(isNumber), + txs: isArray(isStruct({ id: isString })), + balances: isNullable(ValidateBalanceExample), + }), + ), + pagination: isStruct({ + pagination: isNumber, + totalItems: isNumber, + }), + }), +}) +type ExtractedBalances = GuardedType + +let complexResponseExample: any +if (ValidateComplexResponseExample(complexResponseExample)) { + // `complexResponseExample` has been generated type now. You can try: + type Wallets = typeof complexResponseExample.data.wallets + const balance: ExtractedBalances | null | undefined = complexResponseExample.data.wallets[0].balances +} else { + // complexResponseExample is unknown type + complexResponseExample +} +/* eslint-enable @typescript-eslint/no-unused-vars */