diff --git a/index.ts b/index.ts index 7f927bf..1c050d7 100644 --- a/index.ts +++ b/index.ts @@ -2,17 +2,17 @@ import client from './src/clickhouse/client.js'; import openapi from "./tsp-output/@typespec/openapi3/openapi.json"; import { Hono } from "hono"; -import { z } from "zod"; -import { paths } from './src/types/zod.gen.js'; +import { ZodBigInt, ZodBoolean, ZodDate, ZodDefault, ZodNumber, ZodOptional, ZodTypeAny, ZodUndefined, ZodUnion, z } from "zod"; +import { EndpointByMethod } from './src/types/zod.gen.js'; import { APP_VERSION } from "./src/config.js"; import { logger } from './src/logger.js'; import * as prometheus from './src/prometheus.js'; import { makeUsageQuery } from "./src/usage.js"; import { APIErrorResponse } from "./src/utils.js"; - +import { fixEndpointParametersCoercion } from "./src/types/api.js"; import type { Context } from "hono"; -import type { EndpointParameters, EndpointReturnTypes, UsageEndpoints, ValidQueryParams } from "./src/types/api.js"; +import type { EndpointParameters, EndpointReturnTypes, UsageEndpoints } from "./src/types/api.js"; function ERC20TokenAPI() { const app = new Hono(); @@ -63,41 +63,31 @@ function ERC20TokenAPI() { async (_) => new Response(await prometheus.registry.metrics(), { headers: { "Content-Type": prometheus.registry.contentType } }) ); + // Call once + fixEndpointParametersCoercion(); const createUsageEndpoint = (endpoint: UsageEndpoints) => app.get( // Hono using different syntax than OpenAPI for path parameters // `/{path_param}` (OpenAPI) VS `/:path_param` (Hono) endpoint.replace(/{([^}]+)}/g, ":$1"), async (ctx: Context) => { + const result = EndpointByMethod["get"][endpoint].parameters.safeParse({ + query: ctx.req.query(), + path: ctx.req.param() + }) as z.SafeParseSuccess>; - let resultQuery; - let resultPath; - - console.log(ctx.req.param()) - if (paths[endpoint]["get"]["parameters"]["path"] != undefined) { - - resultPath = paths[endpoint]["get"]["parameters"]["path"].safeParse(ctx.req.param()) as z.SafeParseSuccess["path"]>; - } - - if (paths[endpoint]["get"]["parameters"]["query"] != undefined) { - - resultQuery = paths[endpoint]["get"]["parameters"]["query"].safeParse(ctx.req.query()) as z.SafeParseSuccess["query"]>; - } - - - if ((resultPath == undefined || resultPath.success) && (resultQuery == undefined || resultQuery.success)) { - console.log("Success") + if (result.success) { return makeUsageQuery( ctx, endpoint, { - ...resultQuery?.data, + ...result.data.query, // Path parameters may not always be present - ...resultPath?.data - } as ValidQueryParams + ...("path" in result.data ? result.data.path : {}) + } ); } else { - return APIErrorResponse(ctx, 400, "bad_query_input", resultPath?.error || resultQuery?.error); + return APIErrorResponse(ctx, 400, "bad_query_input", result.error); } } ); diff --git a/package.json b/package.json index e4023f1..2ac2145 100644 --- a/package.json +++ b/package.json @@ -1,62 +1,61 @@ { - "name": "erc20-token-api", - "description": "Get informations about ERC20 tokens", - "version": "0.0.1", - "homepage": "https://github.com/pinax-network/erc20-token-api", - "license": "MIT", - "authors": [ - { - "name": "Etienne Donneger", - "email": "etienne@pinax.network", - "url": "https://github.com/0237h" - }, - { - "name": "Denis Carriere", - "email": "denis@pinax.network", - "url": "https://github.com/DenisCarriere/" - }, - { - "name": "Mathieu Lefebvre", - "email": "mathieu@pinax.network", - "url": "https://github.com/Matlefebvre1234/" - } - ], - "dependencies": { - "@clickhouse/client-web": "latest", - "@kubb/cli": "^2.23.2", - "@kubb/core": "^2.23.2", - "commander": "latest", - "dotenv": "latest", - "ethers": "^6.12.1", - "hono": "latest", - "prom-client": "latest", - "tslog": "latest", - "typed-openapi": "latest", - "zod": "latest" + "name": "erc20-token-api", + "description": "Get informations about ERC20 tokens", + "version": "0.0.1", + "homepage": "https://github.com/pinax-network/erc20-token-api", + "license": "MIT", + "authors": [ + { + "name": "Etienne Donneger", + "email": "etienne@pinax.network", + "url": "https://github.com/0237h" }, - "private": true, - "scripts": { - "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile index.ts --outfile erc20-token-api", - "clean": "bun i --force", - "dev": "export APP_VERSION=$(git rev-parse --short HEAD) && bun --watch index.ts", - "lint": "export APP_VERSION=$(git rev-parse --short HEAD) && bun run tsc --noEmit --skipLibCheck --pretty", - "start": "export APP_VERSION=$(git rev-parse --short HEAD) && bun index.ts", - "test": "bun test --coverage", - "types": "bun run tsp compile ./src/typespec && bun run typed-openapi ./tsp-output/@typespec/openapi3/openapi.json -o ./src/types/zod.gen.ts -r zod", - "types:check": "bun run tsp compile ./src/typespec --no-emit --pretty --warn-as-error", - "types:format": "bun run tsp format src/typespec/**/*.tsp", - "types:watch": "bun run tsp compile ./src/typespec --watch --pretty --warn-as-error", - "generate:": "bun run kubb --config ./kubb.config.ts" + { + "name": "Denis Carriere", + "email": "denis@pinax.network", + "url": "https://github.com/DenisCarriere/" }, - "type": "module", - "devDependencies": { - "@typespec/compiler": "latest", - "@typespec/openapi3": "latest", - "@typespec/protobuf": "latest", - "bun-types": "latest", - "typescript": "latest" - }, - "prettier": { - "tabWidth": 4 + { + "name": "Mathieu Lefebvre", + "email": "mathieu@pinax.network", + "url": "https://github.com/Matlefebvre1234/" } + ], + "dependencies": { + "@clickhouse/client-web": "latest", + "@kubb/cli": "^2.23.2", + "@kubb/core": "^2.23.2", + "commander": "latest", + "dotenv": "latest", + "ethers": "^6.12.1", + "hono": "latest", + "prom-client": "latest", + "tslog": "latest", + "typed-openapi": "latest", + "zod": "latest" + }, + "private": true, + "scripts": { + "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile index.ts --outfile erc20-token-api", + "clean": "bun i --force", + "dev": "export APP_VERSION=$(git rev-parse --short HEAD) && bun --watch index.ts", + "lint": "export APP_VERSION=$(git rev-parse --short HEAD) && bun run tsc --noEmit --skipLibCheck --pretty", + "start": "export APP_VERSION=$(git rev-parse --short HEAD) && bun index.ts", + "test": "bun test --coverage", + "types": "bun run tsp compile ./src/typespec && bun run typed-openapi ./tsp-output/@typespec/openapi3/openapi.json -o ./src/types/zod.gen.ts -r zod", + "types:check": "bun run tsp compile ./src/typespec --no-emit --pretty --warn-as-error", + "types:format": "bun run tsp format src/typespec/**/*.tsp", + "types:watch": "bun run tsp compile ./src/typespec --watch --pretty --warn-as-error" + }, + "type": "module", + "devDependencies": { + "@typespec/compiler": "latest", + "@typespec/openapi3": "latest", + "@typespec/protobuf": "latest", + "bun-types": "latest", + "typescript": "latest" + }, + "prettier": { + "tabWidth": 4 + } } diff --git a/src/queries.ts b/src/queries.ts index fed15e0..91688bf 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,6 +1,8 @@ -import { DATABASE_SUFFIX, DEFAULT_SORT_BY } from "./config.js"; -import type { UsageEndpoints, ValidUserParams } from "./types/api.js"; - +import { DEFAULT_SORT_BY } from "./config.js"; +import { getAddress, parseLimit, parseTimestamp, formatTxid } from "./utils.js"; +import type { EndpointReturnTypes, UsageEndpoints, UsageResponse, ValidUserParams } from "./types/api.js"; +import { Contract } from "ethers"; +import { SupportedChains } from "./types/zod.gen.js"; export function addBlockFilter(q: any, additional_query_params: any, where: any[]) { @@ -43,7 +45,7 @@ export function getChains() { // Use a for loop to iterate over each item for (const chain of supportedChain) { - queries.push(`SELECT '${chain}' as chain, MIN(block_num) as block_num FROM ${chain}_${DATABASE_SUFFIX}.cursors`) + queries.push(`SELECT '${chain}' as chain, MIN(block_num) as block_num FROM ${chain}_erc20_token.cursors`) } let query = queries.join(' UNION ALL '); @@ -51,20 +53,19 @@ export function getChains() { } - export function getTotalSupply(endpoint: UsageEndpoints, query_param: any, example?: boolean) { if (endpoint === "/{chain}/supply") { const q = query_param as ValidUserParams; - let contract = q.contract + let contract = q.contract; let additional_query_params = {}; // Query - const table = `${q.chain}_${DATABASE_SUFFIX}.mv_supply_contract` - const contractTable = `${q.chain}_${DATABASE_SUFFIX}.contracts`; + const table = `${q.chain}_erc20_token.mv_supply_contract` + const contractTable = `${q.chain}_erc20_token.contracts`; let query = `SELECT ${table}.contract, ${table}.supply as supply, @@ -122,7 +123,7 @@ export function getContracts(endpoint: UsageEndpoints, query_param: any, example let name = q.name; // Query - const table = `${q.chain}_${DATABASE_SUFFIX}.contracts` + const table = `${q.chain}_erc20_token.contracts` let query = `SELECT ${table}.contract, ${table}.name, @@ -168,8 +169,8 @@ function getBalanceChanges_latest(q: any) { let contract = q.contract; let account = q.account; - let table = `${q.chain}_${DATABASE_SUFFIX}.account_balances` - const contractTable = `${q.chain}_${DATABASE_SUFFIX}.contracts`; + let table = `${q.chain}_erc20_token.account_balances` + const contractTable = `${q.chain}_erc20_token.contracts`; let query = `SELECT ${table}.account, ${table}.contract, @@ -224,10 +225,10 @@ function getBalanceChanges_historical(q: any) { let additional_query_params = {}; let table; - const contractTable = `${q.chain}_${DATABASE_SUFFIX}.contracts`; + const contractTable = `${q.chain}_erc20_token.contracts`; // SQL Query - if (contract) table = `${q.chain}_${DATABASE_SUFFIX}.balance_changes_contract_historical_mv`; - else table = `${q.chain}_${DATABASE_SUFFIX}.balance_changes_account_historical_mv` + if (contract) table = `${q.chain}_erc20_token.balance_changes_contract_historical_mv`; + else table = `${q.chain}_erc20_token.balance_changes_account_historical_mv` let query = `SELECT ${table}.owner, @@ -322,7 +323,7 @@ export function getBalanceChanges(endpoint: UsageEndpoints, query_param: any) { function getHolder_latest(q: any) { let contract = q.contract; // SQL Query - const table = `${q.chain}_${DATABASE_SUFFIX}.token_holders_mv` + const table = `${q.chain}_erc20_token.token_holders_mv` let query = `SELECT account, amount, @@ -353,7 +354,7 @@ function getHolder_historical(q: any) { let contract = q.contract; let additional_query_params = {}; // SQL Query - const table = `${q.chain}_${DATABASE_SUFFIX}.balance_changes_contract_historical_mv` + const table = `${q.chain}_erc20_token.balance_changes_contract_historical_mv` let query = `SELECT owner as account, new_balance AS amount, @@ -431,10 +432,10 @@ export function getTransfers(endpoint: UsageEndpoints, query_param: any) { let additional_query_params = {}; // SQL Query - let table = `${q.chain}_${DATABASE_SUFFIX}.transfers` - let mvFromTable = `${q.chain}_${DATABASE_SUFFIX}.transfers_from_historical_mv` - let mvToTable = `${q.chain}_${DATABASE_SUFFIX}.transfers_to_historical_mv` - let mvContractTable = `${q.chain}_${DATABASE_SUFFIX}.transfers_contract_historical_mv` + let table = `${q.chain}_erc20_token.transfers` + let mvFromTable = `${q.chain}_erc20_token.transfers_from_historical_mv` + let mvToTable = `${q.chain}_erc20_token.transfers_to_historical_mv` + let mvContractTable = `${q.chain}_erc20_token.transfers_contract_historical_mv` let query = `SELECT contract, @@ -490,7 +491,7 @@ export function getTransfer(endpoint: UsageEndpoints, query_param: any) { const transaction_id = q.trx_id; // SQL Query - let table = `${q.chain}_${DATABASE_SUFFIX}.transfers` + let table = `${q.chain}_erc20_token.transfers` let query = `SELECT contract, diff --git a/src/types/api.ts b/src/types/api.ts index ca89a4a..3b55eea 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,42 +1,94 @@ -import { z } from "zod"; -import { paths } from "./zod.gen.js"; - -export type EndpointReturnTypes = - E extends UsageEndpoints - ? UsageResponse["data"] - : z.infer<(typeof paths)[E]["get"]["responses"]["default"]>; -export type EndpointParameters = { - path: z.infer>; - query: z.infer< - NonNullable<(typeof paths)[E]["get"]["parameters"]["query"]> - >; -}; +import { ZodArray, ZodBigInt, ZodBoolean, ZodDate, ZodDefault, ZodNumber, ZodOptional, ZodType, ZodTypeAny, ZodUndefined, ZodUnion, z } from "zod"; + +import { EndpointByMethod, type GetEndpoints } from './zod.gen.js'; +import { config } from "../config.js"; + +export type EndpointReturnTypes = E extends UsageEndpoints ? UsageResponse["data"] : z.infer; +export type EndpointParameters = z.infer; // Usage endpoints interacts with the database -export type UsageEndpoints = Exclude< - keyof typeof paths, - "/health" | "/metrics" | "/version" | "/openapi" ->; -export type UsageResponse = z.infer< - (typeof paths)[UsageEndpoints]["get"]["responses"]["default"] ->; - -export type ValidUserParams = - EndpointParameters extends { path: unknown; query: unknown } - ? Extract< - EndpointParameters, - { query: unknown; path: unknown } - >["query"] & - Extract, { path: unknown }>["path"] +export type UsageEndpoints = Exclude; +export type UsageResponse = z.infer; + +export type ValidUserParams = EndpointParameters extends { path: unknown; } ? + // Combine path and query parameters only if path exists to prevent "never" on intersection + Extract, { query: unknown; }>["query"] & Extract, { path: unknown; }>["path"] : - | Extract, { query: unknown }>["query"] - | Extract, { path: unknown }>["path"]; - -export type AdditionalQueryParams = { - offset?: number; - min_block?: number; - max_block?: number; -}; + Extract, { query: unknown; }>["query"]; +export type AdditionalQueryParams = { offset?: number; min_block?: number; max_block?: number; } // Allow any valid parameters from the endpoint to be used as SQL query parameters with the addition of the `OFFSET` for pagination -export type ValidQueryParams = ValidUserParams & - AdditionalQueryParams; +export type ValidQueryParams = ValidUserParams & AdditionalQueryParams; + +export function fixEndpointParametersCoercion() { + // Add type coercion for query and path parameters since the codegen doesn't coerce types natively + for (const endpoint in EndpointByMethod["get"]) { + if (EndpointByMethod["get"][endpoint as UsageEndpoints].parameters.shape) { + Object.values(EndpointByMethod["get"][endpoint as UsageEndpoints].parameters.shape).map(p => p.shape).forEach( + // `p` can be query or path parameters + (p) => Object.keys(p).filter(k => k !== "chain").forEach( + (key, _) => { + let zod_type = p[key] as ZodTypeAny; + let underlying_zod_type: ZodTypeAny; + let isOptional = false; + + // Strip default layer for value + if (zod_type instanceof ZodDefault) { + zod_type = zod_type.removeDefault(); + } + + // Detect the underlying type from the codegen + if (zod_type instanceof ZodUnion) { + underlying_zod_type = zod_type.options[0]; + isOptional = zod_type.options.some((o: ZodTypeAny) => o instanceof ZodUndefined); + } else if (zod_type instanceof ZodOptional) { + underlying_zod_type = zod_type.unwrap(); + isOptional = true; + } else { + underlying_zod_type = zod_type; + } + + const coercePrimitive = (zod_type: ZodType) => { + if (zod_type instanceof ZodNumber) { + return z.coerce.number(); + } else if (zod_type instanceof ZodBoolean) { + return z.coerce.boolean(); + } else if (zod_type instanceof ZodBigInt) { + return z.coerce.bigint(); + } else if (zod_type instanceof ZodDate) { + return z.coerce.date(); + // Any other type will be coerced as string value directly + } else { + return z.string(); + } + }; + + if (underlying_zod_type instanceof ZodArray && underlying_zod_type.element instanceof ZodNumber) { + // Special case for `block_range` coercion, input is expected to be one or two values separated by comma + p[key] = z.preprocess( + (x) => String(x).split(','), + z.coerce.number().positive().array().min(1).max(2) + ); + } else { + p[key] = coercePrimitive(underlying_zod_type); + } + + if (key === "limit") + p[key] = p[key].max(config.maxLimit); + + // Need to mark optional before adding defaults + if (isOptional) + p[key] = p[key].optional(); + + // Mark parameters with default values explicitly as a workaround + // See https://github.com/astahmer/typed-openapi/issues/34 + if (key === "limit") + p[key] = p[key].default(10); + else if (key === "page") + p[key] = p[key].default(1); + + } + ) + ); + } + } +} \ No newline at end of file diff --git a/src/types/zod.gen.ts b/src/types/zod.gen.ts index 7f521e3..84a2c4b 100644 --- a/src/types/zod.gen.ts +++ b/src/types/zod.gen.ts @@ -1,418 +1,402 @@ -import { z } from "zod"; +import z from "zod"; + +export type APIError = z.infer; +export const APIError = z.object({ + status: z.union([ + z.literal(500), + z.literal(504), + z.literal(400), + z.literal(401), + z.literal(403), + z.literal(404), + z.literal(405), + ]), + code: z.union([ + z.literal("bad_database_response"), + z.literal("bad_header"), + z.literal("missing_required_header"), + z.literal("bad_query_input"), + z.literal("database_timeout"), + z.literal("forbidden"), + z.literal("internal_server_error"), + z.literal("method_not_allowed"), + z.literal("route_not_found"), + z.literal("unauthorized"), + ]), + message: z.string(), +}); + +export type BalanceChange = z.infer; +export const BalanceChange = z.object({ + contract: z.string(), + owner: z.string(), + amount: z.string(), + old_balance: z.string(), + new_balance: z.string(), + change_type: z.number(), + block_num: z.number(), + timestamp: z.number(), + trx_id: z.string(), +}); + +export type Contract = z.infer; +export const Contract = z.object({ + contract: z.string(), + name: z.string(), + symbol: z.string(), + decimals: z.number(), + block_num: z.number(), + timestamp: z.number(), +}); + +export type Holder = z.infer; +export const Holder = z.object({ + account: z.string(), + balance: z.string(), +}); + +export type Pagination = z.infer; +export const Pagination = z.object({ + next_page: z.number(), + previous_page: z.number(), + total_pages: z.number(), + total_results: z.number(), +}); + +export type QueryStatistics = z.infer; +export const QueryStatistics = z.object({ + elapsed: z.number(), + rows_read: z.number(), + bytes_read: z.number(), +}); + +export type ResponseMetadata = z.infer; +export const ResponseMetadata = z.object({ + statistics: z.union([QueryStatistics, z.null()]), + next_page: z.number(), + previous_page: z.number(), + total_pages: z.number(), + total_results: z.number(), +}); + +export type Supply = z.infer; +export const Supply = z.object({ + contract: z.string(), + supply: z.string(), + block_num: z.number(), + timestamp: z.number(), +}); + +export type SupportedChains = z.infer; +export const SupportedChains = z.union([z.literal("eth"), z.literal("polygon")]); + +export type Transfer = z.infer; +export const Transfer = z.object({ + contract: z.string(), + from: z.string(), + to: z.string(), + value: z.string(), + block_num: z.number(), + timestamp: z.number(), + trx_id: z.string(), + action_index: z.number(), +}); + +export type TypeSpec_OpenAPI_Contact = z.infer; +export const TypeSpec_OpenAPI_Contact = z.object({ + name: z.string().optional(), + url: z.string().optional(), + email: z.string().optional(), +}); + +export type Version = z.infer; +export const Version = z.object({ + version: z.string(), + commit: z.string(), +}); + +export type get_Usage_chains = typeof get_Usage_chains; +export const get_Usage_chains = { + method: z.literal("GET"), + path: z.literal("/chains"), + parameters: z.object({ + query: z.object({ + limit: z.number().optional(), + page: z.number().optional(), + }), + }), + response: z.object({ + data: z.array( + z.object({ + chain: SupportedChains, + block_num: z.number(), + }), + ), + meta: ResponseMetadata, + }), +}; + +export type get_Monitoring_health = typeof get_Monitoring_health; +export const get_Monitoring_health = { + method: z.literal("GET"), + path: z.literal("/health"), + parameters: z.never(), + response: z.string(), +}; + +export type get_Monitoring_metrics = typeof get_Monitoring_metrics; +export const get_Monitoring_metrics = { + method: z.literal("GET"), + path: z.literal("/metrics"), + parameters: z.never(), + response: z.string(), +}; + +export type get_Docs_openapi = typeof get_Docs_openapi; +export const get_Docs_openapi = { + method: z.literal("GET"), + path: z.literal("/openapi"), + parameters: z.never(), + response: z.unknown(), +}; + +export type get_Docs_version = typeof get_Docs_version; +export const get_Docs_version = { + method: z.literal("GET"), + path: z.literal("/version"), + parameters: z.never(), + response: Version, +}; + +export type get_Usage_balance = typeof get_Usage_balance; +export const get_Usage_balance = { + method: z.literal("GET"), + path: z.literal("/{chain}/balance"), + parameters: z.object({ + query: z.object({ + contract: z.union([z.string(), z.undefined()]), + account: z.string(), + block_num: z.union([z.number(), z.undefined()]), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + }), + }), + response: z.object({ + data: z.array(BalanceChange), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_holders = typeof get_Usage_holders; +export const get_Usage_holders = { + method: z.literal("GET"), + path: z.literal("/{chain}/holders"), + parameters: z.object({ + query: z.object({ + contract: z.string(), + block_num: z.union([z.number(), z.undefined()]), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + }), + }), + response: z.object({ + data: z.array(Holder), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_supply = typeof get_Usage_supply; +export const get_Usage_supply = { + method: z.literal("GET"), + path: z.literal("/{chain}/supply"), + parameters: z.object({ + query: z.object({ + contract: z.string(), + block_num: z.union([z.number(), z.undefined()]), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + }), + }), + response: z.object({ + data: z.array(Supply), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_tokens = typeof get_Usage_tokens; +export const get_Usage_tokens = { + method: z.literal("GET"), + path: z.literal("/{chain}/tokens"), + parameters: z.object({ + query: z.object({ + contract: z.string().optional(), + symbol: z.string().optional(), + name: z.string().optional(), + limit: z.number().optional(), + page: z.number().optional(), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + }), + }), + response: z.object({ + data: TypeSpec_OpenAPI_Contact, + meta: ResponseMetadata, + }), +}; + +export type get_Usage_transfers = typeof get_Usage_transfers; +export const get_Usage_transfers = { + method: z.literal("GET"), + path: z.literal("/{chain}/transfers"), + parameters: z.object({ + query: z.object({ + from: z.string().optional(), + to: z.string().optional(), + contract: z.string().optional(), + block_range: z.array(z.number()).optional(), + limit: z.number().optional(), + page: z.number().optional(), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + }), + }), + response: z.object({ + data: z.array(Transfer), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_transfer = typeof get_Usage_transfer; +export const get_Usage_transfer = { + method: z.literal("GET"), + path: z.literal("/{chain}/transfers/{trx_id}"), + parameters: z.object({ + query: z.object({ + limit: z.number().optional(), + page: z.number().optional(), + }), + path: z.object({ + chain: z.union([z.literal("eth"), z.literal("polygon")]), + trx_id: z.string(), + }), + }), + response: z.object({ + data: z.array(Transfer), + meta: ResponseMetadata, + }), +}; + +// +export const EndpointByMethod = { + get: { + "/chains": get_Usage_chains, + "/health": get_Monitoring_health, + "/metrics": get_Monitoring_metrics, + "/openapi": get_Docs_openapi, + "/version": get_Docs_version, + "/{chain}/balance": get_Usage_balance, + "/{chain}/holders": get_Usage_holders, + "/{chain}/supply": get_Usage_supply, + "/{chain}/tokens": get_Usage_tokens, + "/{chain}/transfers": get_Usage_transfers, + "/{chain}/transfers/{trx_id}": get_Usage_transfer, + }, +}; +export type EndpointByMethod = typeof EndpointByMethod; +// + +// +export type GetEndpoints = EndpointByMethod["get"]; +export type AllEndpoints = EndpointByMethod[keyof EndpointByMethod]; +// + +// +export type EndpointParameters = { + body?: unknown; + query?: Record; + header?: Record; + path?: Record; +}; + +export type MutationMethod = "post" | "put" | "patch" | "delete"; +export type Method = "get" | "head" | MutationMethod; + +export type DefaultEndpoint = { + parameters?: EndpointParameters | undefined; + response: unknown; +}; + +export type Endpoint = { + operationId: string; + method: Method; + path: string; + parameters?: TConfig["parameters"]; + meta: { + alias: string; + hasParameters: boolean; + areParametersRequired: boolean; + }; + response: TConfig["response"]; +}; + +type Fetcher = ( + method: Method, + url: string, + parameters?: EndpointParameters | undefined, +) => Promise; + +type RequiredKeys = { + [P in keyof T]-?: undefined extends T[P] ? never : P; +}[keyof T]; + +type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; + +// + +// +export class ApiClient { + baseUrl: string = ""; + + constructor(public fetcher: Fetcher) {} + + setBaseUrl(baseUrl: string) { + this.baseUrl = baseUrl; + return this; + } + + // + get( + path: Path, + ...params: MaybeOptionalArg> + ): Promise> { + return this.fetcher("get", this.baseUrl + path, params[0]) as Promise>; + } + // +} + +export function createApiClient(fetcher: Fetcher, baseUrl?: string) { + return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); +} - -export const apiErrorSchema = z.object({ "status": z.union([z.literal(500), z.literal(504), z.literal(400), z.literal(401), z.literal(403), z.literal(404), z.literal(405)]), "code": z.enum(["bad_database_response", "bad_header", "missing_required_header", "bad_query_input", "database_timeout", "forbidden", "internal_server_error", "method_not_allowed", "route_not_found", "unauthorized"]), "message": z.coerce.string() }); -export type ApiErrorSchema = z.infer; - - -export const balanceChangeSchema = z.object({ "contract": z.coerce.string(), "owner": z.coerce.string(), "amount": z.coerce.string(), "old_balance": z.coerce.string(), "new_balance": z.coerce.string(), "change_type": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.number(), "trx_id": z.coerce.string() }); -export type BalanceChangeSchema = z.infer; - - -export const contractSchema = z.object({ "contract": z.coerce.string(), "name": z.coerce.string(), "symbol": z.coerce.string(), "decimals": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.number() }); -export type ContractSchema = z.infer; - - -export const holderSchema = z.object({ "account": z.coerce.string(), "balance": z.coerce.string() }); -export type HolderSchema = z.infer; - - -export const paginationSchema = z.object({ "next_page": z.coerce.number(), "previous_page": z.coerce.number(), "total_pages": z.coerce.number(), "total_results": z.coerce.number() }); -export type PaginationSchema = z.infer; - - -export const queryStatisticsSchema = z.object({ "elapsed": z.coerce.number(), "rows_read": z.coerce.number(), "bytes_read": z.coerce.number() }); -export type QueryStatisticsSchema = z.infer; - - -export const responseMetadataSchema = z.object({ "statistics": z.lazy(() => queryStatisticsSchema).nullable(), "next_page": z.coerce.number(), "previous_page": z.coerce.number(), "total_pages": z.coerce.number(), "total_results": z.coerce.number() }); -export type ResponseMetadataSchema = z.infer; - - -export const supplySchema = z.object({ "contract": z.coerce.string(), "supply": z.coerce.string(), "block_num": z.coerce.number(), "timestamp": z.coerce.number() }); -export type SupplySchema = z.infer; - - -export const supportedChainsSchema = z.enum(["eth", "polygon"]); -export type SupportedChainsSchema = z.infer; - - -export const transferSchema = z.object({ "contract": z.coerce.string(), "from": z.coerce.string(), "to": z.coerce.string(), "value": z.coerce.string(), "block_num": z.coerce.number(), "timestamp": z.coerce.number(), "trx_id": z.coerce.string(), "action_index": z.coerce.number() }); -export type TransferSchema = z.infer; - - /** - * @description Contact information for the exposed API. - */ -export const typeSpecOpenApiContactSchema = z.object({ "name": z.coerce.string().describe("The identifying name of the contact person/organization.").optional(), "url": z.coerce.string().url().describe("The URL pointing to the contact information. MUST be in the format of a URL.").optional(), "email": z.coerce.string().describe("The email address of the contact person/organization. MUST be in the format of an email address.").optional() }).describe("Contact information for the exposed API."); -export type TypeSpecOpenApiContactSchema = z.infer; - - -export const versionSchema = z.object({ "version": z.coerce.string().regex(new RegExp("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$")), "commit": z.coerce.string().regex(new RegExp("^[0-9a-f]{7}$")) }); -export type VersionSchema = z.infer; - - -export const usageChainsQueryParamsSchema = z.object({ "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }).optional(); -export type UsageChainsQueryParamsSchema = z.infer; -/** - * @description Array of block information. - */ -export const usageChains200Schema = z.object({ "data": z.array(z.object({ "chain": z.lazy(() => supportedChainsSchema), "block_num": z.coerce.number() })), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageChains200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageChainsErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageChainsErrorSchema = z.infer; -/** - * @description Array of block information. - */ -export const usageChainsQueryResponseSchema = z.object({ "data": z.array(z.object({ "chain": z.lazy(() => supportedChainsSchema), "block_num": z.coerce.number() })), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageChainsQueryResponseSchema = z.infer; - - /** - * @description OK or APIError. - */ -export const monitoringHealth200Schema = z.coerce.string(); -export type MonitoringHealth200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const monitoringHealthErrorSchema = z.lazy(() => apiErrorSchema); -export type MonitoringHealthErrorSchema = z.infer; -/** - * @description OK or APIError. - */ -export const monitoringHealthQueryResponseSchema = z.coerce.string(); -export type MonitoringHealthQueryResponseSchema = z.infer; - - /** - * @description Metrics as text. - */ -export const monitoringMetrics200Schema = z.coerce.string(); -export type MonitoringMetrics200Schema = z.infer; -/** - * @description Metrics as text. - */ -export const monitoringMetricsQueryResponseSchema = z.coerce.string(); -export type MonitoringMetricsQueryResponseSchema = z.infer; - - /** - * @description The OpenAPI JSON spec - */ -export const docsOpenapi200Schema = z.object({}); -export type DocsOpenapi200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const docsOpenapiErrorSchema = z.lazy(() => apiErrorSchema); -export type DocsOpenapiErrorSchema = z.infer; -/** - * @description The OpenAPI JSON spec - */ -export const docsOpenapiQueryResponseSchema = z.object({}); -export type DocsOpenapiQueryResponseSchema = z.infer; - - /** - * @description The API version and commit hash. - */ -export const docsVersion200Schema = z.lazy(() => versionSchema); -export type DocsVersion200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const docsVersionErrorSchema = z.lazy(() => apiErrorSchema); -export type DocsVersionErrorSchema = z.infer; -/** - * @description The API version and commit hash. - */ -export const docsVersionQueryResponseSchema = z.lazy(() => versionSchema); -export type DocsVersionQueryResponseSchema = z.infer; - - -export const usageBalancePathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema) }); -export type UsageBalancePathParamsSchema = z.infer; - - export const usageBalanceQueryParamsSchema = z.object({ "contract": z.coerce.string().optional(), "account": z.coerce.string(), "block_num": z.coerce.number().optional(), "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }); -export type UsageBalanceQueryParamsSchema = z.infer; -/** - * @description Array of balances. - */ -export const usageBalance200Schema = z.object({ "data": z.array(z.lazy(() => balanceChangeSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageBalance200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageBalanceErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageBalanceErrorSchema = z.infer; -/** - * @description Array of balances. - */ -export const usageBalanceQueryResponseSchema = z.object({ "data": z.array(z.lazy(() => balanceChangeSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageBalanceQueryResponseSchema = z.infer; - - -export const usageHoldersPathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema) }); -export type UsageHoldersPathParamsSchema = z.infer; - - export const usageHoldersQueryParamsSchema = z.object({ "contract": z.coerce.string(), "block_num": z.coerce.number().optional(), "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }); -export type UsageHoldersQueryParamsSchema = z.infer; -/** - * @description Array of accounts. - */ -export const usageHolders200Schema = z.object({ "data": z.array(z.lazy(() => holderSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageHolders200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageHoldersErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageHoldersErrorSchema = z.infer; -/** - * @description Array of accounts. - */ -export const usageHoldersQueryResponseSchema = z.object({ "data": z.array(z.lazy(() => holderSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageHoldersQueryResponseSchema = z.infer; - - -export const usageSupplyPathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema) }); -export type UsageSupplyPathParamsSchema = z.infer; - - export const usageSupplyQueryParamsSchema = z.object({ "contract": z.coerce.string(), "block_num": z.coerce.number().optional(), "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }); -export type UsageSupplyQueryParamsSchema = z.infer; -/** - * @description Array of supplies. - */ -export const usageSupply200Schema = z.object({ "data": z.array(z.lazy(() => supplySchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageSupply200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageSupplyErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageSupplyErrorSchema = z.infer; -/** - * @description Array of supplies. - */ -export const usageSupplyQueryResponseSchema = z.object({ "data": z.array(z.lazy(() => supplySchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageSupplyQueryResponseSchema = z.infer; - - -export const usageTokensPathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema) }); -export type UsageTokensPathParamsSchema = z.infer; - - export const usageTokensQueryParamsSchema = z.object({ "contract": z.coerce.string().optional(), "symbol": z.coerce.string().optional(), "name": z.coerce.string().optional(), "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }).optional(); -export type UsageTokensQueryParamsSchema = z.infer; -/** - * @description One contract. - */ -export const usageTokens200Schema = z.object({ "data": z.lazy(() => typeSpecOpenApiContactSchema), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTokens200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageTokensErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageTokensErrorSchema = z.infer; -/** - * @description One contract. - */ -export const usageTokensQueryResponseSchema = z.object({ "data": z.lazy(() => typeSpecOpenApiContactSchema), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTokensQueryResponseSchema = z.infer; - - -export const usageTransfersPathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema) }); -export type UsageTransfersPathParamsSchema = z.infer; - - export const usageTransfersQueryParamsSchema = z.object({ "from": z.coerce.string().optional(), "to": z.coerce.string().optional(), "contract": z.coerce.string().optional(), "block_range": z.array(z.coerce.number()).optional(), "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }).optional(); -export type UsageTransfersQueryParamsSchema = z.infer; -/** - * @description Array of transfers. - */ -export const usageTransfers200Schema = z.object({ "data": z.array(z.lazy(() => transferSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTransfers200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageTransfersErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageTransfersErrorSchema = z.infer; -/** - * @description Array of transfers. - */ -export const usageTransfersQueryResponseSchema = z.object({ "data": z.array(z.lazy(() => transferSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTransfersQueryResponseSchema = z.infer; - - -export const usageTransferPathParamsSchema = z.object({ "chain": z.lazy(() => supportedChainsSchema), "trx_id": z.coerce.string() }); -export type UsageTransferPathParamsSchema = z.infer; - - export const usageTransferQueryParamsSchema = z.object({ "limit": z.coerce.number().optional(), "page": z.coerce.number().optional() }).optional(); -export type UsageTransferQueryParamsSchema = z.infer; -/** - * @description Array of transfers. - */ -export const usageTransfer200Schema = z.object({ "data": z.array(z.lazy(() => transferSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTransfer200Schema = z.infer; -/** - * @description An unexpected error response. - */ -export const usageTransferErrorSchema = z.lazy(() => apiErrorSchema); -export type UsageTransferErrorSchema = z.infer; /** - * @description Array of transfers. - */ -export const usageTransferQueryResponseSchema = z.object({ "data": z.array(z.lazy(() => transferSchema)), "meta": z.lazy(() => responseMetadataSchema) }); -export type UsageTransferQueryResponseSchema = z.infer; - - export const operations = { "Usage_chains": { - request: undefined, - parameters: { - path: undefined, - query: usageChainsQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageChainsQueryResponseSchema, - default: usageChainsQueryResponseSchema - }, - errors: {} - }, "Monitoring_health": { - request: undefined, - parameters: { - path: undefined, - query: undefined, - header: undefined - }, - responses: { - 200: monitoringHealthQueryResponseSchema, - default: monitoringHealthQueryResponseSchema - }, - errors: {} - }, "Monitoring_metrics": { - request: undefined, - parameters: { - path: undefined, - query: undefined, - header: undefined - }, - responses: { - 200: monitoringMetricsQueryResponseSchema, - default: monitoringMetricsQueryResponseSchema - }, - errors: {} - }, "Docs_openapi": { - request: undefined, - parameters: { - path: undefined, - query: undefined, - header: undefined - }, - responses: { - 200: docsOpenapiQueryResponseSchema, - default: docsOpenapiQueryResponseSchema - }, - errors: {} - }, "Docs_version": { - request: undefined, - parameters: { - path: undefined, - query: undefined, - header: undefined - }, - responses: { - 200: docsVersionQueryResponseSchema, - default: docsVersionQueryResponseSchema - }, - errors: {} - }, "Usage_balance": { - request: undefined, - parameters: { - path: usageBalancePathParamsSchema, - query: usageBalanceQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageBalanceQueryResponseSchema, - default: usageBalanceQueryResponseSchema - }, - errors: {} - }, "Usage_holders": { - request: undefined, - parameters: { - path: usageHoldersPathParamsSchema, - query: usageHoldersQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageHoldersQueryResponseSchema, - default: usageHoldersQueryResponseSchema - }, - errors: {} - }, "Usage_supply": { - request: undefined, - parameters: { - path: usageSupplyPathParamsSchema, - query: usageSupplyQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageSupplyQueryResponseSchema, - default: usageSupplyQueryResponseSchema - }, - errors: {} - }, "Usage_tokens": { - request: undefined, - parameters: { - path: usageTokensPathParamsSchema, - query: usageTokensQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageTokensQueryResponseSchema, - default: usageTokensQueryResponseSchema - }, - errors: {} - }, "Usage_transfers": { - request: undefined, - parameters: { - path: usageTransfersPathParamsSchema, - query: usageTransfersQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageTransfersQueryResponseSchema, - default: usageTransfersQueryResponseSchema - }, - errors: {} - }, "Usage_transfer": { - request: undefined, - parameters: { - path: usageTransferPathParamsSchema, - query: usageTransferQueryParamsSchema, - header: undefined - }, - responses: { - 200: usageTransferQueryResponseSchema, - default: usageTransferQueryResponseSchema - }, - errors: {} - } } as const; -export const paths = { "/chains": { - get: operations["Usage_chains"] - }, "/health": { - get: operations["Monitoring_health"] - }, "/metrics": { - get: operations["Monitoring_metrics"] - }, "/openapi": { - get: operations["Docs_openapi"] - }, "/version": { - get: operations["Docs_version"] - }, "/{chain}/balance": { - get: operations["Usage_balance"] - }, "/{chain}/holders": { - get: operations["Usage_holders"] - }, "/{chain}/supply": { - get: operations["Usage_supply"] - }, "/{chain}/tokens": { - get: operations["Usage_tokens"] - }, "/{chain}/transfers": { - get: operations["Usage_transfers"] - }, "/{chain}/transfers/{trx_id}": { - get: operations["Usage_transfer"] - } } as const; \ No newline at end of file + Example usage: + const api = createApiClient((method, url, params) => + fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), + ); + api.get("/users").then((users) => console.log(users)); + api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); + api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); +*/ + +// ) { type EndpointElementReturnType = EndpointReturnTypes; - let page; - if (user_params && "page" in user_params) page = user_params.page; - - let limit = 100; - if (user_params && "limit" in user_params && user_params.limit) limit = user_params.limit; + let { page, ...query_params } = user_params; + let limit; + if (!query_params.limit) + query_params.limit = 100; if (!page) page = 1; @@ -20,26 +19,28 @@ export async function makeUsageQuery(ctx: Context, endpoint: UsageEndpoints, use let additional_query_params = {}; try { - user_params = formatQueryParams(user_params); + query_params = formatQueryParams(query_params); } catch (err) { return APIErrorResponse(ctx, 400, "bad_query_input", err); } - + if (query_params.limit) limit = query_params.limit; + else + limit = 100; switch (endpoint) { - case "/{chain}/balance": ({ query, additional_query_params } = getBalanceChanges(endpoint, user_params)); break; - case "/{chain}/supply": ({ query, additional_query_params } = getTotalSupply(endpoint, user_params)); break; - case "/{chain}/transfers": query = getTransfers(endpoint, user_params); break; - case "/{chain}/holders": ({ query, additional_query_params } = getHolders(endpoint, user_params)); break; + case "/{chain}/balance": ({ query, additional_query_params } = getBalanceChanges(endpoint, query_params)); break; + case "/{chain}/supply": ({ query, additional_query_params } = getTotalSupply(endpoint, query_params)); break; + case "/{chain}/transfers": query = getTransfers(endpoint, query_params); break; + case "/{chain}/holders": ({ query, additional_query_params } = getHolders(endpoint, query_params)); break; case "/chains": query = getChains(); break; - case "/{chain}/transfers/{trx_id}": query = getTransfer(endpoint, user_params); break; - case "/{chain}/tokens": query = getContracts(endpoint, user_params); break; + case "/{chain}/transfers/{trx_id}": query = getTransfer(endpoint, query_params); break; + case "/{chain}/tokens": query = getContracts(endpoint, query_params); break; } let query_results; try { - query_results = await makeQuery(query, { ...user_params, ...additional_query_params, offset: user_params.limit }); + query_results = await makeQuery(query, { ...query_params, ...additional_query_params, offset: query_params.limit }); } catch (err) { return APIErrorResponse(ctx, 500, "bad_database_response", err); } diff --git a/src/utils.ts b/src/utils.ts index a629e14..a2bd346 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import { ZodError } from "zod"; import type { Context } from "hono"; -import type { ApiErrorSchema as APIError } from "./types/zod.gen.js"; +import type { APIError } from "./types/zod.gen.js"; import { logger } from "./logger.js"; import * as prometheus from "./prometheus.js"; import { ethers } from "ethers";