diff --git a/README.md b/README.md index 185578b..f4eb200 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,41 @@ [![.github/workflows/bun-test.yml](https://github.com/pinax-network/antelope-token-api/actions/workflows/bun-test.yml/badge.svg)](https://github.com/pinax-network/antelope-token-api/actions/workflows/bun-test.yml) -> Token balances, supply and transfers from the Antelope blockchains +> Tokens information from the Antelope blockchains, powered by [Substreams](https://substreams.streamingfast.io/) ## REST API +### Usage + | Method | Path | Description | | :---: | --- | --- | | GET
`text/html` | `/` | [Swagger](https://swagger.io/) API playground | -| GET
`application/json` | `/supply` | Antelope Tokens total supply | -| GET
`application/json` | `/balance` | Antelope Tokens balance changes | -| GET
`application/json` | `/transfers` | Antelope Tokens transfers | -| GET
`text/plain` | `/health` | Performs health checks and checks if the database is accessible | +| GET
`application/json` | `/balance` | Balances of an account. | | GET
`application/json` | `/head` | Information about the current head block in the database | -| GET
`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics for the API | +| GET
`application/json` | `/holders` | List of holders of a token | +| GET
`application/json` | `/supply` | Total supply for a token | +| GET
`application/json` | `/tokens` | List of available tokens | +| GET
`application/json` | `/transfers` | All transfers related to a token | +| GET
`application/json` | `/transfers/{trx_id}` | Specific transfer related to a token | + +### Docs + +| Method | Path | Description | +| :---: | --- | --- | | GET
`application/json` | `/openapi` | [OpenAPI](https://www.openapis.org/) specification | -| GET
`application/json` | `/version` | API version and commit hash | +| GET
`application/json` | `/version` | API version and Git short commit hash | + +### Monitoring + +| Method | Path | Description | +| :---: | --- | --- | +| GET
`text/plain` | `/health` | Checks database connection | +| GET
`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics | ## Requirements - [ClickHouse](clickhouse.com/) -- (Optional) A [Substream sink](https://substreams.streamingfast.io/reference-and-specs/glossary#sink) for loading data into ClickHouse. We recommend [Substreams Sink ClickHouse](https://github.com/pinax-network/substreams-sink-clickhouse/) or [Substreams Sink SQL](https://github.com/streamingfast/substreams-sink-sql). +- (Optional) A [Substream sink](https://substreams.streamingfast.io/reference-and-specs/glossary#sink) for loading data into ClickHouse. We recommend [Substreams Sink ClickHouse](https://github.com/pinax-network/substreams-sink-clickhouse/) or [Substreams Sink SQL](https://github.com/streamingfast/substreams-sink-sql). You should use the generated [`protobuf` files](tsp-output/@typespec/protobuf) to build your substream. ## Quick start @@ -40,10 +55,11 @@ $ bun test ## [`Bun` Binary Releases](https://github.com/pinax-network/antelope-token-api/releases) -> For Linux x86 +> [!WARNING] +> Linux x86 only ```console -$ wget https://github.com/pinax-network/antelope-token-api/releases/download/v2.0.0/antelope-token-api +$ wget https://github.com/pinax-network/antelope-token-api/releases/download/v3.0.0/antelope-token-api $ chmod +x ./antelope-token-api $ ./antelope-token-api --help Usage: antelope-token-api [options] @@ -90,6 +106,7 @@ VERBOSE=true ```bash docker pull ghcr.io/pinax-network/antelope-token-api:latest ``` + **For head of `develop` branch** ```bash docker pull ghcr.io/pinax-network/antelope-token-api:develop diff --git a/bun.lockb b/bun.lockb index f5c34e3..7e5a13a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.ts b/index.ts index 24d3f09..b5c9872 100644 --- a/index.ts +++ b/index.ts @@ -1,16 +1,153 @@ -import { config } from "./src/config.js"; -import { logger } from "./src/logger.js"; -import GET from "./src/fetch/GET.js"; -import { APIError } from "./src/fetch/utils.js"; - -const app = Bun.serve({ - hostname: config.hostname, - port: config.port, - fetch(req: Request) { - let pathname = new URL(req.url).pathname; - if (req.method === "GET") return GET(req); - return APIError(pathname, 405, "invalid_request_method", "Invalid request method, only GET allowed"); - } -}); - -logger.info(`Server listening on http://${app.hostname}:${app.port}`); \ No newline at end of file +import client from './src/clickhouse/client.js'; +import openapi from "./tsp-output/@typespec/openapi3/openapi.json"; + +import { Hono } from "hono"; +import { ZodBigInt, ZodBoolean, ZodDate, 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 type { Context } from "hono"; +import type { EndpointParameters, EndpointReturnTypes, UsageEndpoints } from "./src/types/api.js"; + +function AntelopeTokenAPI() { + const app = new Hono(); + + app.use(async (ctx: Context, next) => { + const pathname = ctx.req.path; + logger.trace(`Incoming request: [${pathname}]`); + prometheus.request.inc({ pathname }); + + await next(); + }); + + app.get( + "/", + async (_) => new Response(Bun.file("./swagger/index.html")) + ); + + app.get( + "/favicon.ico", + async (_) => new Response(Bun.file("./swagger/favicon.ico")) + ); + + app.get( + "/openapi", + async (ctx: Context) => ctx.json<{ [key: string]: EndpointReturnTypes<"/openapi">; }, 200>(openapi) + ); + + app.get( + "/version", + async (ctx: Context) => ctx.json, 200>(APP_VERSION) + ); + + app.get( + "/health", + async (ctx: Context) => { + const response = await client.ping(); + + if (!response.success) { + return APIErrorResponse(ctx, 500, "bad_database_response", response.error.message); + } + + return new Response("OK"); + } + ); + + app.get( + "/metrics", + async (_) => new Response(await prometheus.registry.metrics(), { headers: { "Content-Type": prometheus.registry.contentType } }) + ); + + const createUsageEndpoint = (endpoint: UsageEndpoints) => app.get( + // Hono using different syntax than OpenAPI for path parameters + // `/{path_param}` (OpenAPI) VS `/:path_param` (Hono) + endpoint.replace(/{([^}]+)}/, ":$1"), + async (ctx: Context) => { + // Add type coercion for query and path parameters since the codegen doesn't coerce types natively + const endpoint_parameters = Object.values(EndpointByMethod["get"][endpoint].parameters.shape).map(p => p.shape); + endpoint_parameters.forEach( + // `p` can query or path parameters + (p) => Object.keys(p).forEach( + (key, _) => { + const zod_type = p[key] as ZodTypeAny; + let underlying_zod_type: ZodTypeAny; + let isOptional = false; + + // 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; + } + + // Query and path user input parameters come as strings and we need to coerce them to the right type using Zod + if (underlying_zod_type instanceof ZodNumber) { + p[key] = z.coerce.number(); + } else if (underlying_zod_type instanceof ZodBoolean) { + p[key] = z.coerce.boolean(); + } else if (underlying_zod_type instanceof ZodBigInt) { + p[key] = z.coerce.bigint(); + } else if (underlying_zod_type instanceof ZodDate) { + p[key] = z.coerce.date(); + // Any other type will be coerced as string value directly + } else { + p[key] = z.coerce.string(); + } + + 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); + + } + ) + ); + + const result = EndpointByMethod["get"][endpoint].parameters.safeParse({ + query: ctx.req.query(), + path: ctx.req.param() + }) as z.SafeParseSuccess>; + + if (result.success) { + return makeUsageQuery( + ctx, + endpoint, + { + ...result.data.query, + // Path parameters may not always be present + ...("path" in result.data ? result.data.path : {}) + } + ); + } else { + return APIErrorResponse(ctx, 400, "bad_query_input", result.error); + } + } + ); + + createUsageEndpoint("/balance"); // TODO: Maybe separate `block_num`/`timestamp` queries with path parameters (additional response schemas) + createUsageEndpoint("/head"); + createUsageEndpoint("/holders"); + createUsageEndpoint("/supply"); // TODO: Same as `balance`` + createUsageEndpoint("/tokens"); + createUsageEndpoint("/transfers"); // TODO: Redefine `block_range` params + createUsageEndpoint("/transfers/{trx_id}"); + + app.notFound((ctx: Context) => APIErrorResponse(ctx, 404, "route_not_found", `Path not found: ${ctx.req.method} ${ctx.req.path}`)); + + return app; +} + +export default AntelopeTokenAPI(); \ No newline at end of file diff --git a/package.json b/package.json index bd9d7b2..d3eac6a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { "name": "antelope-token-api", "description": "Token balances, supply and transfers from the Antelope blockchains", - "version": "2.3.0", + "version": "3.0.0", "homepage": "https://github.com/pinax-network/antelope-token-api", "license": "MIT", - "type": "module", "authors": [ { "name": "Etienne Donneger", @@ -17,24 +16,38 @@ "url": "https://github.com/DenisCarriere/" } ], - "scripts": { - "start": "export APP_VERSION=$(git rev-parse --short HEAD) && bun index.ts", - "dev": "export APP_VERSION=$(git rev-parse --short HEAD) && bun --watch index.ts", - "lint": "export APP_VERSION=$(git rev-parse --short HEAD) && bunx tsc --noEmit --skipLibCheck --pretty", - "test": "export APP_VERSION=$(git rev-parse --short HEAD) && bun test --coverage", - "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile ./index.ts --outfile antelope-token-api" - }, "dependencies": { "@clickhouse/client-web": "latest", - "commander": "latest", - "dotenv": "latest", - "openapi3-ts": "latest", - "prom-client": "latest", - "tslog": "latest", + "@typespec/compiler": "latest", + "@typespec/openapi3": "latest", + "@typespec/protobuf": "latest", + "commander": "^12.1.0", + "dotenv": "^16.4.5", + "hono": "latest", + "prom-client": "^15.1.2", + "tslog": "^4.9.2", + "typed-openapi": "latest", "zod": "latest" }, + "private": true, + "scripts": { + "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile index.ts --outfile antelope-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": { "bun-types": "latest", "typescript": "latest" + }, + "prettier": { + "tabWidth": 4 } -} +} \ No newline at end of file diff --git a/src/clickhouse/createClient.ts b/src/clickhouse/client.ts similarity index 85% rename from src/clickhouse/createClient.ts rename to src/clickhouse/client.ts index 79ae600..17d484f 100644 --- a/src/clickhouse/createClient.ts +++ b/src/clickhouse/client.ts @@ -2,15 +2,17 @@ import { createClient } from "@clickhouse/client-web"; import { ping } from "./ping.js"; import { APP_NAME, config } from "../config.js"; +// TODO: Check how to abort previous queries if haven't returned yet // TODO: Make client connect to all DB instances const client = createClient({ ...config, clickhouse_settings: { allow_experimental_object_type: 1, readonly: "1", + exact_rows_before_limit: 1 }, application: APP_NAME, -}) +}); // These overrides should not be required but the @clickhouse/client-web instance // does not work well with Bun's implementation of Node streams. diff --git a/src/clickhouse/makeQuery.ts b/src/clickhouse/makeQuery.ts index 7922578..d30d1dc 100644 --- a/src/clickhouse/makeQuery.ts +++ b/src/clickhouse/makeQuery.ts @@ -1,36 +1,25 @@ +import client from "./client.js"; + import { logger } from "../logger.js"; import * as prometheus from "../prometheus.js"; -import client from "./createClient.js"; -export interface Meta { - name: string, - type: string -} -export interface Query { - meta: Meta[], - data: T[], - rows: number, - rows_before_limit_at_least: number, - statistics: { - elapsed: number, - rows_read: number, - bytes_read: number, - } -} +import type { ResponseJSON } from "@clickhouse/client-web"; +import type { ValidQueryParams } from "../types/api.js"; -export async function makeQuery(query: string) { - try { - const response = await client.query({ query }) - const data: Query = await response.json(); +export async function makeQuery(query: string, query_params: ValidQueryParams) { + logger.trace({ query, query_params }); - prometheus.query.inc(); + const response = await client.query({ query, query_params, format: "JSON" }); + const data: ResponseJSON = await response.json(); + + prometheus.query.inc(); + if ( data.statistics ) { prometheus.bytes_read.inc(data.statistics.bytes_read); prometheus.rows_read.inc(data.statistics.rows_read); prometheus.elapsed.inc(data.statistics.elapsed); - logger.trace("\n", { query, statistics: data.statistics, rows: data.rows }); - - return data; - } catch (e: any) { - throw new Error(e.message); } + + logger.trace({ statistics: data.statistics, rows: data.rows, rows_before_limit_at_least: data.rows_before_limit_at_least }); + + return data; } \ No newline at end of file diff --git a/src/clickhouse/ping.ts b/src/clickhouse/ping.ts index f751036..5cb8720 100644 --- a/src/clickhouse/ping.ts +++ b/src/clickhouse/ping.ts @@ -1,5 +1,5 @@ import { PingResult } from "@clickhouse/client-web"; -import client from "./createClient.js"; +import client from "./client.js"; import { logger } from "../logger.js"; // Does not work with Bun's implementation of Node streams. diff --git a/src/config.ts b/src/config.ts index 1fbd3f4..d6de2cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,12 +14,15 @@ export const DEFAULT_MAX_LIMIT = 10000; export const DEFAULT_VERBOSE = false; export const DEFAULT_SORT_BY = "DESC"; export const APP_NAME = pkg.name; -export const APP_VERSION = `${pkg.version}+${process.env.APP_VERSION || "unknown"}`; +export const APP_VERSION = { + version: pkg.version, + commit: process.env.APP_VERSION || "unknown" +}; // parse command line options const opts = program .name(pkg.name) - .version(APP_VERSION) + .version(`${APP_VERSION.version}+${APP_VERSION.commit}`) .description(pkg.description) .showHelpAfterError() .addOption(new Option("-p, --port ", "HTTP port on which to attach the API").env("PORT").default(DEFAULT_PORT)) diff --git a/src/fetch/GET.ts b/src/fetch/GET.ts deleted file mode 100644 index 5fc3396..0000000 --- a/src/fetch/GET.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { registry } from "../prometheus.js"; -import openapi from "./openapi.js"; -import health from "./health.js"; -import head from "./head.js"; -import balance from "./balance.js"; -import supply from "./supply.js"; -import * as prometheus from "../prometheus.js"; -import swaggerHtml from "../../swagger/index.html" -import swaggerFavicon from "../../swagger/favicon.png" -import transfers from "./transfers.js"; -import { APIError, toJSON } from "./utils.js"; -import { APP_VERSION } from "../config.js"; -import { logger } from "../logger.js"; - -export default async function (req: Request) { - const { pathname } = new URL(req.url); - logger.trace(`Incoming request: [${pathname}]`) - prometheus.request.inc({ pathname }); - - // Landing page - if (pathname === "/") return new Response(Bun.file(swaggerHtml)); - if (pathname === "/favicon.png") return new Response(Bun.file(swaggerFavicon)); - - // Utils - if (pathname === "/health") return health(req); - if (pathname === "/metrics") return new Response(await registry.metrics(), { headers: { "Content-Type": registry.contentType } }); - if (pathname === "/openapi") return new Response(openapi, { headers: { "Content-Type": "application/json" } }); - if (pathname === "/version") return toJSON({ version: APP_VERSION.split('+')[0], commit: APP_VERSION.split('+')[1] }); - - // Token endpoints - if (pathname === "/head") return head(req); - if (pathname === "/supply") return supply(req); - if (pathname === "/balance") return balance(req); - if (pathname === "/transfers") return transfers(req); - - return APIError(pathname, 404, "path_not_found", "Invalid pathname"); -} diff --git a/src/fetch/balance.ts b/src/fetch/balance.ts deleted file mode 100644 index 57243a0..0000000 --- a/src/fetch/balance.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { makeQuery } from "../clickhouse/makeQuery.js"; -import { logger } from "../logger.js"; -import { getBalanceChanges } from "../queries.js"; -import { APIError, addMetadata, toJSON } from "./utils.js"; -import { parseLimit, parsePage } from "../utils.js"; - -function verifyParams(searchParams: URLSearchParams) { - const account = searchParams.get("account"); - const contract = searchParams.get("contract"); - - if (!account && !contract) throw new Error("account or contract is required"); -} - -export default async function (req: Request) { - const { pathname, searchParams } = new URL(req.url); - logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) }); - - try { - verifyParams(searchParams); - } catch (e: any) { - return APIError(pathname, 400, "bad_query_input", e.message); - } - - const query = getBalanceChanges(searchParams); - let response; - - try { - response = await makeQuery(query); - } catch (e: any) { - return APIError(pathname, 500, "failed_database_query", e.message); - } - - try { - return toJSON( - addMetadata( - response, - parseLimit(searchParams.get("limit")), - parsePage(searchParams.get("page")) - ) - ); - } catch (e: any) { - return APIError(pathname, 500, "failed_response", e.message); - } -} \ No newline at end of file diff --git a/src/fetch/head.ts b/src/fetch/head.ts deleted file mode 100644 index 1778183..0000000 --- a/src/fetch/head.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { APIError, addMetadata, toJSON } from "./utils.js"; -import { makeQuery } from "../clickhouse/makeQuery.js"; - -export default async function (req: Request) { - let query = "SELECT block_num FROM cursors ORDER BY block_num DESC LIMIT 1"; - let pathname = new URL(req.url).pathname; - let response; - - try { - response = await makeQuery(query); - } catch (e: any) { - return APIError(pathname, 500, "failed_database_query", e.message); - } - - try { - return toJSON(addMetadata(response)); - } catch (e: any) { - return APIError(pathname, 500, "failed_response", e.message); - } -} \ No newline at end of file diff --git a/src/fetch/health.ts b/src/fetch/health.ts deleted file mode 100644 index 105a2b0..0000000 --- a/src/fetch/health.ts +++ /dev/null @@ -1,12 +0,0 @@ -import client from "../clickhouse/createClient.js"; -import { APIError } from "./utils.js"; - -export default async function (req: Request) { - const response = await client.ping(); - - if (!response.success) { - return APIError(new URL(req.url).pathname, 503, "failed_ping_database", response.error.message); - } - - return new Response("OK"); -} \ No newline at end of file diff --git a/src/fetch/openapi.ts b/src/fetch/openapi.ts deleted file mode 100644 index 80d95c2..0000000 --- a/src/fetch/openapi.ts +++ /dev/null @@ -1,229 +0,0 @@ -import pkg from "../../package.json" assert { type: "json" }; - -import { OpenApiBuilder, SchemaObject, ExampleObject, ParameterObject } from "openapi3-ts/oas31"; -import { config } from "../config.js"; -import { registry } from "../prometheus.js"; -import { makeQuery } from "../clickhouse/makeQuery.js"; -import { getBalanceChanges, getTotalSupply, getTransfers } from "../queries.js"; -import { APIError, addMetadata } from "./utils.js"; -import { logger } from "../logger.js"; -const TAGS = { - MONITORING: "Monitoring", - HEALTH: "Health", - USAGE: "Usage", - DOCS: "Documentation", -} as const; - -const timestampExamplesArrayFilter = ["greater_or_equals_by_timestamp", "greater_by_timestamp", "less_or_equals_by_timestamp", "less_by_timestamp"]; -const blockExamplesArrayFilter = ["greater_or_equals_by_block", "greater_by_block", "less_or_equals_by_block", "less_by_block"]; -const amountExamplesArrayFilter = ["amount_greater_or_equals", "amount_greater", "amount_less_or_equals", "amount_less"]; - -const head_example = addMetadata({ - meta: [], - data: [{ block_num: "107439534" }], - rows: 0, - rows_before_limit_at_least: 0, - statistics: { - elapsed: 0.00132, - rows_read: 4, - bytes_read: 32 - } -}); - -logger.debug("Querying examples for OpenAPI..."); -const supply_example = await makeQuery( - getTotalSupply(new URLSearchParams({ limit: "1" }), true) -).then( - res => addMetadata(res, 1, 1) -).catch( - e => APIError("/openapi", 500, "failed_database_query", e.message) -); - -const balance_example = await makeQuery( - getBalanceChanges(new URLSearchParams({ limit: "2" }), true) -).then( - res => addMetadata(res, 2, 1) -).catch( - e => APIError("/openapi", 500, "failed_database_query", e.message) -); - -const transfers_example = await makeQuery( - getTransfers(new URLSearchParams({ limit: "5" }), true) -).then( - res => addMetadata(res, 5, 1) -).catch( - e => APIError("/openapi", 500, "failed_database_query", e.message) -); - -const timestampSchema: SchemaObject = { - anyOf: [ - { type: "number" }, - { type: "string", format: "date" }, - { type: "string", format: "date-time" } - ] -}; -const timestampExamples: ExampleObject = { - unix: { summary: `Unix Timestamp (seconds)` }, - date: { summary: `Full-date notation`, value: '2023-10-18' }, - datetime: { summary: `Date-time notation`, value: '2023-10-18T00:00:00Z' }, -}; - -const parameterString = (name: string = "address", required = false) => ({ - name, - in: "query", - description: `Filter by ${name}`, - required, - schema: { type: "string" }, -} as ParameterObject); - -const parameterLimit: ParameterObject = { - name: "limit", - in: "query", - description: "Maximum number of records to return per query.", - required: false, - schema: { type: "number", maximum: config.maxLimit, minimum: 1 }, -}; - -const parameterOffset: ParameterObject = { - name: "page", - in: "query", - description: "Page index for results pagination.", - required: false, - schema: { type: "number", minimum: 1 }, -}; - -const timestampFilter = timestampExamplesArrayFilter.map(name => { - return { - name, - in: "query", - description: "Filter " + name.replace(/_/g, " "), - required: false, - schema: timestampSchema, - examples: timestampExamples, - } as ParameterObject; -}); - -const blockFilter = blockExamplesArrayFilter.map(name => { - return { - name, - in: "query", - description: "Filter " + name.replace(/_/g, " "), - required: false, - schema: { type: "number" }, - } as ParameterObject; -}); - -const amountFilter = amountExamplesArrayFilter.map(name => { - return { - name, - in: "query", - description: "Filter " + name.replace(/_/g, " "), - required: false, - schema: { type: "number" }, - } as ParameterObject; -}); - -export default new OpenApiBuilder() - .addInfo({ - title: pkg.name, - version: pkg.version, - description: pkg.description, - license: { name: `License: ${pkg.license}`, url: `${pkg.homepage}/blob/main/LICENSE` }, - }) - .addExternalDocs({ url: pkg.homepage, description: "Homepage" }) - .addSecurityScheme("auth-key", { type: "http", scheme: "bearer" }) - .addPath("/supply", { - get: { - tags: [TAGS.USAGE], - summary: "Antelope tokens latest finalized supply", - parameters: [ - parameterString("contract"), - parameterString("issuer"), - parameterString("symbol"), - ...amountFilter, - ...timestampFilter, - ...blockFilter, - parameterLimit, - parameterOffset, - ], - responses: { - 200: { description: "Latest finalized supply", content: { "application/json": { example: supply_example, schema: { type: "array" } } } }, - 400: { description: "Bad request" }, - }, - }, - }) - .addPath("/balance", { - get: { - tags: [TAGS.USAGE], - summary: "Antelope tokens latest finalized balance change", - parameters: [ - parameterString("account"), - parameterString("contract"), - ...amountFilter, - ...timestampFilter, - ...blockFilter, - parameterLimit, - parameterOffset, - ], - responses: { - 200: { description: "Latest finalized balance change", content: { "application/json": { example: balance_example, schema: { type: "array" } } } }, - 400: { description: "Bad request" }, - }, - }, - }).addPath("/transfers", { - get: { - tags: [TAGS.USAGE], - summary: "Antelope tokens transfers", - parameters: [ - parameterString("contract"), - parameterString("from"), - parameterString("to"), - parameterString("transaction_id"), - ...amountFilter, - ...timestampFilter, - ...blockFilter, - parameterLimit, - parameterOffset, - ], - responses: { - 200: { description: "Array of transfers", content: { "application/json": { example: transfers_example, schema: { type: "array" } } } }, - 400: { description: "Bad request" }, - }, - }, - }) - .addPath("/health", { - get: { - tags: [TAGS.HEALTH], - summary: "Performs health checks and checks if the database is accessible", - responses: { 200: { description: "OK", content: { "text/plain": { example: "OK" } } } }, - }, - }) - .addPath("/head", { - get: { - tags: [TAGS.MONITORING], - summary: "Information about the current head block in the database", - responses: { 200: { description: "Information about the current head block in the database", content: { "application/json": { example: head_example } } } }, - }, - }) - .addPath("/metrics", { - get: { - tags: [TAGS.MONITORING], - summary: "Prometheus metrics for the API", - responses: { 200: { description: "Prometheus metrics for the API", content: { "text/plain": { example: await registry.metrics(), schema: { type: "string" } } } } }, - }, - }) - .addPath("/openapi", { - get: { - tags: [TAGS.DOCS], - summary: "OpenAPI JSON specification", - responses: { 200: { description: "OpenAPI JSON specification", content: { "application/json": {} } } }, - }, - }) - .addPath("/version", { - get: { - tags: [TAGS.DOCS], - summary: "API version", - responses: { 200: { description: "API version and commit hash", content: { "application/json": {} } } }, - }, - }) - .getSpecAsJson(); \ No newline at end of file diff --git a/src/fetch/supply.ts b/src/fetch/supply.ts deleted file mode 100644 index 4671aeb..0000000 --- a/src/fetch/supply.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { makeQuery } from "../clickhouse/makeQuery.js"; -import { logger } from "../logger.js"; -import { getTotalSupply } from "../queries.js"; -import { APIError, addMetadata, toJSON } from "./utils.js"; -import { parseLimit, parsePage } from "../utils.js"; - -function verifyParams(searchParams: URLSearchParams) { - const contract = searchParams.get("contract"); - const issuer = searchParams.get("issuer"); - - if (!issuer && !contract) throw new Error("issuer or contract is required"); -} - -export default async function (req: Request) { - const { pathname, searchParams } = new URL(req.url); - logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) }); - - try { - verifyParams(searchParams); - } catch (e: any) { - return APIError(pathname, 400, "bad_query_input", e.message); - } - - const query = getTotalSupply(searchParams); - let response; - - try { - response = await makeQuery(query); - } catch (e: any) { - return APIError(pathname, 500, "failed_database_query", e.message); - } - - try { - return toJSON( - addMetadata( - response, - parseLimit(searchParams.get("limit")), - parsePage(searchParams.get("page")) - ) - ); - } catch (e: any) { - return APIError(pathname, 500, "failed_response", e.message); - } -} \ No newline at end of file diff --git a/src/fetch/transfers.ts b/src/fetch/transfers.ts deleted file mode 100644 index 622892a..0000000 --- a/src/fetch/transfers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { makeQuery } from "../clickhouse/makeQuery.js"; -import { logger } from "../logger.js"; -import { getTransfers } from "../queries.js"; -import { APIError, addMetadata, toJSON } from "./utils.js"; -import { parseLimit, parsePage } from "../utils.js"; - -export default async function (req: Request) { - const { pathname, searchParams } = new URL(req.url); - logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) }); - - const query = getTransfers(searchParams); - let response; - - try { - response = await makeQuery(query); - } catch (e: any) { - return APIError(pathname, 500, "failed_database_query", e.message); - } - - try { - return toJSON( - addMetadata( - response, - parseLimit(searchParams.get("limit")), - parsePage(searchParams.get("page")) - ) - ); - } catch (e: any) { - return APIError(pathname, 500, "failed_response", e.message); - } -} \ No newline at end of file diff --git a/src/fetch/utils.spec.ts b/src/fetch/utils.spec.ts deleted file mode 100644 index fe1f8f9..0000000 --- a/src/fetch/utils.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "bun:test"; -import { addMetadata } from "./utils.js"; -import { Query } from "../clickhouse/makeQuery.js"; - -const limit = 5; -const mock_query_reponse: Query = { - meta: [], - data: Array(limit), - rows: limit, - rows_before_limit_at_least: 5 * limit, // Simulate query with more total results than the query limit making pagination relevant - statistics: { - elapsed: 0, - rows_read: 0, - bytes_read: 0, - } -}; - -test("addMetadata pagination", () => { - const first_page = addMetadata(mock_query_reponse, limit, 1); - expect(first_page.meta.next_page).toBe(2); - expect(first_page.meta.previous_page).toBe(1); // Previous page should be set to 1 on first page - expect(first_page.meta.total_pages).toBe(5); - expect(first_page.meta.total_results).toBe(5 * limit); - - const odd_page = addMetadata(mock_query_reponse, limit, 3); - expect(odd_page.meta.next_page).toBe(4); - expect(odd_page.meta.previous_page).toBe(2); - expect(odd_page.meta.total_pages).toBe(5); - expect(odd_page.meta.total_results).toBe(5 * limit); - - const even_page = addMetadata(mock_query_reponse, limit, 4); - expect(even_page.meta.next_page).toBe(5); - expect(even_page.meta.previous_page).toBe(3); - expect(even_page.meta.total_pages).toBe(5); - expect(even_page.meta.total_results).toBe(5 * limit); - - const last_page = addMetadata(mock_query_reponse, limit, 5); - // @ts-ignore - expect(last_page.meta.next_page).toBe(last_page.meta.total_pages); // Next page should be capped to total_pages on last page - expect(last_page.meta.previous_page).toBe(4); - expect(last_page.meta.total_pages).toBe(5); - expect(last_page.meta.total_results).toBe(5 * limit); - - // Expect error message on beyond last page - expect(() => addMetadata(mock_query_reponse, limit, limit + 1)).toThrow(`Requested page (${limit + 1}) exceeds total pages (${limit})`); -}); - -test("addMetadata no pagination", () => { - const no_pagination = addMetadata(mock_query_reponse); - - expect(no_pagination.meta.next_page).toBeUndefined(); - expect(no_pagination.meta.previous_page).toBeUndefined(); - expect(no_pagination.meta.total_pages).toBeUndefined(); - expect(no_pagination.meta.total_results).toBeUndefined(); -}); \ No newline at end of file diff --git a/src/fetch/utils.ts b/src/fetch/utils.ts deleted file mode 100644 index 1cbf5af..0000000 --- a/src/fetch/utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Query } from "../clickhouse/makeQuery.js"; -import { logger } from "../logger.js"; -import * as prometheus from "../prometheus.js"; - -interface APIError { - status: number, - code?: string, - detail?: string; -} - -export function APIError(pathname: string, status: number, code?: string, detail?: string) { - const api_error: APIError = { - status, - code: code ? code : "unknown", - detail: detail ? detail : "" - }; - - logger.error("\n", api_error); - prometheus.request_error.inc({ pathname, status }); - return toJSON(api_error, status); -} - -export function toJSON(data: any, status: number = 200) { - return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); -} - -export function addMetadata(response: Query, req_limit?: number, req_page?: number) { - if (typeof (req_limit) !== 'undefined' && typeof (req_page) !== 'undefined') { - const total_pages = Math.max(Math.ceil(response.rows_before_limit_at_least / req_limit), 1); // Always have a least one total page - - if (req_page > total_pages) - throw Error(`Requested page (${req_page}) exceeds total pages (${total_pages})`); - - return { - data: response.data, - meta: { - statistics: response.statistics, - next_page: (req_page * req_limit >= response.rows_before_limit_at_least) ? req_page : req_page + 1, - previous_page: (req_page <= 1) ? req_page : req_page - 1, - total_pages, - total_results: response.rows_before_limit_at_least - } - }; - } else { - return { - data: response.data, - meta: { - statistics: response.statistics, - } - }; - } -} \ No newline at end of file diff --git a/src/logger.ts b/src/logger.ts index f6b718f..3f142c3 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -5,7 +5,7 @@ class TsLogger extends Logger { constructor() { super(); this.settings.minLevel = 5; - this.settings.name = `${APP_NAME}:${APP_VERSION}`; + this.settings.name = `${APP_NAME}:${APP_VERSION.version}+${APP_VERSION.commit}`; } public enable(type: "pretty" | "json" = "pretty") { diff --git a/src/prometheus.ts b/src/prometheus.ts index f86e91c..baa906f 100644 --- a/src/prometheus.ts +++ b/src/prometheus.ts @@ -30,7 +30,7 @@ export function registerGauge(name: string, help = "help", labelNames: string[] export async function getSingleMetric(name: string) { const metric = registry.getSingleMetric(name); const get = await metric?.get(); - return get?.values[0].value; + return get?.values[0]?.value; } // REST API metrics diff --git a/src/queries.spec.ts b/src/queries.spec.ts deleted file mode 100644 index 7351e14..0000000 --- a/src/queries.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { expect, test } from "bun:test"; -import { - getTotalSupply, - getBalanceChanges, - addTimestampBlockFilter, - getTransfers, - addAmountFilter, -} from "./queries.js"; - -const contract = "eosio.token"; -const account = "push.sx"; -const limit = "1"; -const symbol = "EOS"; -const issuer = "test"; -const greater_or_equals_by_timestamp = "1697587200"; -const less_or_equals_by_timestamp = "1697587100"; -const transaction_id = - "ab3612eed62a184eed2ae86bcad766183019cf40f82e5316f4d7c4e61f4baa44"; - -function formatSQL(query: string) { - return query.replace(/\s+/g, ""); -} - -test("addTimestampBlockFilter", () => { - let where: any[] = []; - const searchParams = new URLSearchParams({ - greater_or_equals_by_timestamp: "1697587200", - less_or_equals_by_timestamp: "1697587100", - greater_or_equals_by_block: "123", - less_or_equals_by_block: "123", - }); - addTimestampBlockFilter(searchParams, where); - - expect(where).toContain("block_num >= 123"); - expect(where).toContain("block_num <= 123"); - expect(where).toContain("toUnixTimestamp(timestamp) >= 1697587200"); - expect(where).toContain("toUnixTimestamp(timestamp) <= 1697587100"); -}); - -test("addAmountFilter", () => { - let where: any[] = []; - const searchParams = new URLSearchParams({ - amount_greater_or_equals: "123123", - amount_less_or_equals: "123123", - amount_greater: "2323", - amount_less: "2332", - }); - addAmountFilter(searchParams, where); - - expect(where).toContain("amount >= 123123"); - expect(where).toContain("amount <= 123123"); - expect(where).toContain("amount > 2323"); - expect(where).toContain("amount < 2332"); -}); - -test("getTotalSupply", () => { - const parameters = new URLSearchParams({ contract }); - const query = formatSQL(getTotalSupply(parameters)); - - expect(query).toContain(formatSQL('SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM token_supplies')); - expect(query).toContain( - formatSQL( - `WHERE(contract == '${contract}')` - ) - ); - expect(query).toContain(formatSQL(`ORDER BY block_num DESC`)); - expect(query).toContain(formatSQL(`LIMIT 1`)); -}); - -test("getTotalSupply with options", () => { - const parameters = new URLSearchParams({ - contract, - symbol, - greater_or_equals_by_timestamp, - less_or_equals_by_timestamp, - issuer, - limit, - }); - - expect(formatSQL(getTotalSupply(parameters))).toContain( - formatSQL( - `WHERE(contract == '${contract}' - AND symcode == '${symbol}' AND issuer == '${issuer}' - AND toUnixTimestamp(timestamp) >= ${greater_or_equals_by_timestamp} - AND toUnixTimestamp(timestamp) <= ${less_or_equals_by_timestamp})` - ) - ); -}); - -test("getBalanceChange", () => { - const parameters = new URLSearchParams({ account, contract }); - const query = formatSQL(getBalanceChanges(parameters)); - - expect(query).toContain(formatSQL(`SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM account_balances`)); - expect(query).toContain( - formatSQL( - `WHERE(account == '${account}' AND contract == '${contract}')` - ) - ); - expect(query).toContain(formatSQL(`ORDER BY block_num DESC`)); - expect(query).toContain(formatSQL(`LIMIT 1`)); -}); - -test("getBalanceChanges with options", () => { - const parameters = new URLSearchParams({ - account, - transaction_id, - greater_or_equals_by_timestamp, - less_or_equals_by_timestamp, - limit, - }); - - expect(formatSQL(getBalanceChanges(parameters))).toContain( - formatSQL( - `WHERE(account == '${account}' - AND toUnixTimestamp(timestamp) >= ${greater_or_equals_by_timestamp} - AND toUnixTimestamp(timestamp) <= ${less_or_equals_by_timestamp})` - ) - ); -}); - -test("getTransfers", () => { - const parameters = new URLSearchParams({ contract, from: account, to: account, transaction_id }); - const query = formatSQL(getTransfers(parameters)); - - expect(query).toContain( - formatSQL(`SELECT *`) - ); - expect(query).toContain( - formatSQL( - `WHERE(contract == '${contract}' - AND from == '${account}' AND to == '${account}' AND trx_id == '${transaction_id}')` - ) - ); - expect(query).toContain(formatSQL(`ORDER BY block_num DESC`)); - expect(query).toContain(formatSQL(`LIMIT 1`)); -}); \ No newline at end of file diff --git a/src/queries.ts b/src/queries.ts deleted file mode 100644 index a27cd87..0000000 --- a/src/queries.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { DEFAULT_SORT_BY, config } from "./config.js"; -import { parseLimit, parsePage, parseTimestamp } from "./utils.js"; - -// For reference on Clickhouse Database tables: -// https://raw.githubusercontent.com/pinax-network/substreams-antelope-tokens/main/schema.sql - -// Query for count of unique token holders grouped by token (contract, symcode) pairs -/* -SELECT - Count(*), - contract, - symcode -FROM - ( - SELECT - DISTINCT account, - contract, - symcode - FROM - eos_tokens_v1.account_balances FINAL - ) -GROUP BY - (contract, symcode) -order BY - (contract, symcode) ASC -*/ -export function addTimestampBlockFilter(searchParams: URLSearchParams, where: any[]) { - const operators = [ - ["greater_or_equals", ">="], - ["greater", ">"], - ["less_or_equals", "<="], - ["less", "<"], - ]; - - for (const [key, operator] of operators) { - const block_number = searchParams.get(`${key}_by_block`); - const timestamp = parseTimestamp(searchParams.get(`${key}_by_timestamp`)); - - if (block_number) where.push(`block_num ${operator} ${block_number}`); - if (timestamp) where.push(`toUnixTimestamp(timestamp) ${operator} ${timestamp}`); - } -} - -export function addAmountFilter(searchParams: URLSearchParams, where: any[]) { - const operators = [ - ["greater_or_equals", ">="], - ["greater", ">"], - ["less_or_equals", "<="], - ["less", "<"], - ]; - - for (const [key, operator] of operators) { - const amount = searchParams.get(`amount_${key}`); - - if (amount) where.push(`amount ${operator} ${amount}`); - } -} - -export function getTotalSupply(searchParams: URLSearchParams, example?: boolean) { - //const chain = searchParams.get("chain"); - const contract = searchParams.get("contract"); - const symbol = searchParams.get("symbol"); - const issuer = searchParams.get("issuer"); - - let query = 'SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM token_supplies'; - - if (!example) { - // WHERE statements - const where = []; - - //if (chain) where.push(`chain == '${chain}'`); - if (contract) where.push(`contract == '${contract}'`); - if (symbol) where.push(`symcode == '${symbol}'`); - if (issuer) where.push(`issuer == '${issuer}'`); - - addAmountFilter(searchParams, where); - addTimestampBlockFilter(searchParams, where); - - if (where.length) query += ` FINAL WHERE(${where.join(' AND ')})`; - - const sort_by = searchParams.get("sort_by"); - query += ` ORDER BY block_num ${sort_by ?? DEFAULT_SORT_BY} `; - } - - const limit = parseLimit(searchParams.get("limit")); - if (limit) query += ` LIMIT ${limit}`; - - const page = parsePage(searchParams.get("page")); - if (page) query += ` OFFSET ${limit * (page - 1)} `; - - return query; -} - -export function getBalanceChanges(searchParams: URLSearchParams, example?: boolean) { - const contract = searchParams.get("contract"); - const account = searchParams.get("account"); - - let query = 'SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM account_balances'; - - if (!example) { - // WHERE statements - const where = []; - - //if (chain) where.push(`chain == '${chain}'`); - if (account) where.push(`account == '${account}'`); - if (contract) where.push(`contract == '${contract}'`); - - addAmountFilter(searchParams, where); - addTimestampBlockFilter(searchParams, where); - - if (where.length) query += ` WHERE(${where.join(' AND ')})`; - - if (contract && account) query += ` ORDER BY block_num DESC`; - //if (!contract && account) query += `GROUP BY (contract, account) ORDER BY timestamp DESC`; - //if (contract && !account) query += `GROUP BY (contract, account) ORDER BY timestamp DESC`; - } - - const limit = parseLimit(searchParams.get("limit")); - if (limit) query += ` LIMIT ${limit}`; - - const page = parsePage(searchParams.get("page")); - if (page) query += ` OFFSET ${limit * (page - 1)} `; - - return query; -} - -export function getTransfers(searchParams: URLSearchParams, example?: boolean) { - const contract = searchParams.get("contract"); - const from = searchParams.get("from"); - const to = searchParams.get("to"); - const transaction_id = searchParams.get("transaction_id"); - - let query = "SELECT * FROM "; - - if (from && !to) query += "transfers_from" - else if (!from && to) query += "transfers_to" - else query += "transfers_block_num" - - if (!example) { - // WHERE statements - const where = []; - - if (contract) where.push(`contract == '${contract}'`); - if (from) where.push(`from == '${from}'`); - if (to) where.push(`to == '${to}'`); - if (transaction_id) where.push(`trx_id == '${transaction_id}'`); - - addAmountFilter(searchParams, where); - addTimestampBlockFilter(searchParams, where); - - if (where.length) query += ` WHERE(${where.join(' AND ')})`; - - query += ` ORDER BY block_num DESC`; - } - - const limit = parseLimit(searchParams.get("limit")); - if (limit) query += ` LIMIT ${limit}`; - - const page = parsePage(searchParams.get("page")); - if (page) query += ` OFFSET ${limit * (page - 1)} `; - - return query; -} diff --git a/src/types.d.ts b/src/types.d.ts deleted file mode 100644 index 8fc1e89..0000000 --- a/src/types.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare module "*.png" { - const content: string; - export default content; -} - -declare module "*.html" { - const content: string; - export default content; -} - -declare module "*.sql" { - const content: string; - export default content; -} \ No newline at end of file diff --git a/src/types/README.md b/src/types/README.md new file mode 100644 index 0000000..cbd2004 --- /dev/null +++ b/src/types/README.md @@ -0,0 +1,10 @@ +### `zod.gen.ts` + +> [!WARNING] +> **DO NOT EDIT**: Auto-generated [Zod](https://zod.dev/) schemas definitions from the [OpenAPI3](../tsp-output/@typespec/openapi3/openapi.json) specification using [`typed-openapi`](https://github.com/astahmer/typed-openapi/). + +Use `bun run types` to run the code generation for Zod schemas. + +### `api.ts` + +Utility types based on the generated Zod schemas. \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..44d9344 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,18 @@ +import z from "zod"; + +import type { GetEndpoints } from './zod.gen.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; +export type UsageResponse = z.infer; + +export type ValidUserParams = { path: unknown; } extends EndpointParameters ? + // 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"]; +// 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 & { offset?: number; }; \ No newline at end of file diff --git a/src/types/zod.gen.ts b/src/types/zod.gen.ts new file mode 100644 index 0000000..c668b6e --- /dev/null +++ b/src/types/zod.gen.ts @@ -0,0 +1,383 @@ +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({ + trx_id: z.string(), + action_index: z.number(), + contract: z.string(), + symcode: z.string(), + precision: z.number(), + amount: z.number(), + value: z.number(), + block_num: z.number(), + timestamp: z.number(), + account: z.string(), + balance: z.string(), + balance_delta: z.number(), +}); + +export type Holder = z.infer; +export const Holder = z.object({ + account: z.string(), + balance: z.number(), +}); + +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({ + trx_id: z.string(), + action_index: z.number(), + contract: z.string(), + symcode: z.string(), + precision: z.number(), + amount: z.number(), + value: z.number(), + block_num: z.number(), + timestamp: z.number(), + issuer: z.string(), + max_supply: z.string(), + supply: z.string(), + supply_delta: z.number(), +}); + +export type Transfer = z.infer; +export const Transfer = z.object({ + trx_id: z.string(), + action_index: z.number(), + contract: z.string(), + symcode: z.string(), + precision: z.number(), + amount: z.number(), + value: z.number(), + block_num: z.number(), + timestamp: z.number(), + from: z.string(), + to: z.string(), + quantity: z.string(), + memo: z.string(), +}); + +export type Version = z.infer; +export const Version = z.object({ + version: z.string(), + commit: z.string(), +}); + +export type get_Usage_balance = typeof get_Usage_balance; +export const get_Usage_balance = { + method: z.literal("GET"), + path: z.literal("/balance"), + parameters: z.object({ + query: z.object({ + block_num: z.union([z.number(), z.undefined()]), + contract: z.union([z.string(), z.undefined()]), + symcode: z.union([z.string(), z.undefined()]), + account: z.string(), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + }), + response: z.object({ + data: z.array(BalanceChange), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_head = typeof get_Usage_head; +export const get_Usage_head = { + method: z.literal("GET"), + path: z.literal("/head"), + parameters: z.object({ + query: z.object({ + limit: z.number().optional(), + page: z.number().optional(), + }), + }), + response: z.object({ + data: z.array( + z.object({ + 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_Usage_holders = typeof get_Usage_holders; +export const get_Usage_holders = { + method: z.literal("GET"), + path: z.literal("/holders"), + parameters: z.object({ + query: z.object({ + contract: z.string(), + symcode: z.string(), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + }), + response: z.object({ + data: z.array(Holder), + meta: ResponseMetadata, + }), +}; + +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_Usage_supply = typeof get_Usage_supply; +export const get_Usage_supply = { + method: z.literal("GET"), + path: z.literal("/supply"), + parameters: z.object({ + query: z.object({ + block_num: z.union([z.number(), z.undefined()]), + issuer: z.union([z.string(), z.undefined()]), + contract: z.string(), + symcode: z.string(), + limit: z.union([z.number(), z.undefined()]), + page: z.union([z.number(), z.undefined()]), + }), + }), + 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("/tokens"), + parameters: z.object({ + query: z.object({ + limit: z.number().optional(), + page: z.number().optional(), + }), + }), + response: z.object({ + data: z.array(Supply), + meta: ResponseMetadata, + }), +}; + +export type get_Usage_transfers = typeof get_Usage_transfers; +export const get_Usage_transfers = { + method: z.literal("GET"), + path: z.literal("/transfers"), + parameters: z.object({ + query: z.object({ + block_range: z.array(z.number()).optional(), + from: z.string().optional(), + to: z.string().optional(), + contract: z.string().optional(), + symcode: z.string().optional(), + limit: z.number().optional(), + page: z.number().optional(), + }), + }), + 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("/transfers/{trx_id}"), + parameters: z.object({ + query: z.object({ + limit: z.number().optional(), + page: z.number().optional(), + }), + path: z.object({ + trx_id: z.string(), + }), + }), + response: z.object({ + data: z.array(Transfer), + meta: ResponseMetadata, + }), +}; + +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 const EndpointByMethod = { + get: { + "/balance": get_Usage_balance, + "/head": get_Usage_head, + "/health": get_Monitoring_health, + "/holders": get_Usage_holders, + "/metrics": get_Monitoring_metrics, + "/openapi": get_Docs_openapi, + "/supply": get_Usage_supply, + "/tokens": get_Usage_tokens, + "/transfers": get_Usage_transfers, + "/transfers/{trx_id}": get_Usage_transfer, + "/version": get_Docs_version, + }, +}; +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 ?? ""); +} + +/** + 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)); +*/ + +// TypeSpec is a language for defining cloud service APIs and shapes. TypeSpec is a highly extensible language with primitives that can describe API shapes common among REST, OpenAPI, gRPC, and other protocols. + +For Pinax's API projects, Typespec allows for both generating the [protobuf](./protobuf.tsp) definitions used at the *substreams* level **and** the [OpenAPI3](openapi3.tsp) specification, ensuring consistent data models for the whole pipeline. + +See https://typespec.io/docs to get started. + +## Common models + +The data models used for both outputs can be found in [`models.tsp`](./models.tsp). + +## Compiling definitions + +Use the `bun run types:watch` to auto-compile the definitions on file changes. Generated outputs can be found in the [`tsp-output`](/tsp-output/) folder. + +Typescript compiler options can be found in [`tspconfig.yaml`](/tspconfig.yaml). \ No newline at end of file diff --git a/src/typespec/main.tsp b/src/typespec/main.tsp new file mode 100644 index 0000000..4eaf0f3 --- /dev/null +++ b/src/typespec/main.tsp @@ -0,0 +1,5 @@ +/** + * Main file to allow compiling for both protobuf and openapi3 specs with single command `tsp compile .` + */ +import "./protobuf.tsp"; +import "./openapi3.tsp"; diff --git a/src/typespec/models.tsp b/src/typespec/models.tsp new file mode 100644 index 0000000..b33e43e --- /dev/null +++ b/src/typespec/models.tsp @@ -0,0 +1,56 @@ +/** + * Common models used for protobuf and openapi3 outputs + */ +namespace Models { + model TraceInformation { + trx_id: string; + action_index: uint32; + } + + model Scope { + contract: string; + symcode: string; + } + + model Extras { + precision: uint32; + amount: int64; + value: float64; + } + + // Use a generic to allow the model to represent a timestamp using different types for protobuf/openapi3 + model BlockInfo { + block_num: uint64; + timestamp: TimestampType; + } + + model CommonAntelope { + ...TraceInformation; + ...Scope; + ...Extras; + ...BlockInfo; + } + + model Transfer { + ...CommonAntelope; + from: string; + to: string; + quantity: string; + memo: string; + } + + model BalanceChange { + ...CommonAntelope; + account: string; + balance: string; + balance_delta: int64; + } + + model Supply { + ...CommonAntelope; + issuer: string; + max_supply: string; + supply: string; + supply_delta: int64; + } +} diff --git a/src/typespec/openapi3.tsp b/src/typespec/openapi3.tsp new file mode 100644 index 0000000..32356b1 --- /dev/null +++ b/src/typespec/openapi3.tsp @@ -0,0 +1,223 @@ +import "@typespec/http"; +import "./models.tsp"; + +using TypeSpec.Http; + +@service({ + title: "Antelope Token API", +}) +namespace AntelopeTokenAPI; + +// Error codes adapted from https://github.com/pinax-network/golang-base/blob/develop/response/errors.go +alias APIErrorCode = + | "bad_database_response" // invalid response from the database + | "bad_header" // invalid or malformed header given + | "missing_required_header" // request is missing a header + | "bad_query_input" // given query input is missing or malformed + | "database_timeout" // timeout while connecting to database + | "forbidden" // not allowed to access this endpoint + | "internal_server_error" // an unknown error occurred on the backend + | "method_not_allowed" // http method is not allowed on this endpoint + | "route_not_found" // the requested route was not found + | "unauthorized"; // invalid authorization information given + +alias ErrorStatusCode = 500 | 504 | 400 | 401 | 403 | 404 | 405; + +@error +model APIError { + status: ErrorStatusCode; + code: APIErrorCode; + message: string; +} + +// Models will be present in the OpenAPI components +model Transfer is Models.Transfer; +model BalanceChange is Models.BalanceChange; +model Supply is Models.Supply; +model Holder { + account: BalanceChange.account; + balance: BalanceChange.value; +} + +model QueryStatistics { + elapsed: float; + rows_read: safeint; + bytes_read: safeint; +} + +model Pagination { + next_page: safeint; + previous_page: safeint; + total_pages: safeint; + total_results: safeint; +} + +model ResponseMetadata { + statistics: QueryStatistics | null; + ...Pagination; +} + +model UsageResponse { + data: T; + meta: ResponseMetadata; +} + +// Alias will *not* be present in the OpenAPI components. +// This also helps preventing self-references in generated `components` for codegen to work properly. +alias APIResponse = T | APIError; +alias PaginationQueryParams = { + @query limit?: uint64 = 10; + @query page?: uint64 = 1; +}; + +// Helper aliases for accessing underlying properties +alias BlockInfo = Models.BlockInfo; +alias TokenIdentifier = Models.Scope; + +@tag("Usage") +interface Usage { + /** + Balances of an account. + @returns Array of balances. + */ + @summary("Token balance") + @route("/balance") + @get + balance( + @query block_num?: BlockInfo.block_num, + @query contract?: TokenIdentifier.contract, + @query symcode?: TokenIdentifier.symcode, + @query account: BalanceChange.account, + ...PaginationQueryParams, + ): APIResponse>; + + /** + Information about the current head block in the database. + @returns Array of block information. + */ + @summary("Head block information") + @route("/head") + @get + head(...PaginationQueryParams): APIResponse>; + + /** + List of holders of a token. + @returns Array of accounts. + */ + @summary("Token holders") + @route("/holders") + @get + holders( + @query contract: TokenIdentifier.contract, + @query symcode: TokenIdentifier.symcode, + ...PaginationQueryParams, + ): APIResponse>; + + /** + Total supply for a token. + @returns Array of supplies. + */ + @summary("Token supply") + @route("/supply") + @get + supply( + @query block_num?: BlockInfo.block_num, + @query issuer?: Supply.issuer, + @query contract: TokenIdentifier.contract, + @query symcode: TokenIdentifier.symcode, + ...PaginationQueryParams, + ): APIResponse>; + + /** + List of available tokens. + @returns Array of supplies. + */ + @summary("Tokens") + @route("/tokens") + @get + tokens(...PaginationQueryParams): APIResponse>; + + /** + All transfers related to a token. + @returns Array of transfers. + */ + @summary("Token transfers") + @route("/transfers") + @get + transfers( + @query({ + format: "csv", + }) + block_range?: BlockInfo.block_num[], + + @query from?: Transfer.from, + @query to?: Transfer.to, + @query contract?: TokenIdentifier.contract, + @query symcode?: TokenIdentifier.symcode, + ...PaginationQueryParams, + ): APIResponse>; + + /** + Specific transfer related to a token. + @returns Array of transfers. + */ + @summary("Token transfer") + @route("/transfers/{trx_id}") + @get + transfer( + @path trx_id: Models.TraceInformation.trx_id, + ...PaginationQueryParams, + ): APIResponse>; +} + +model Version { + @pattern("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$") // Adapted from https://semver.org/ + version: string; + + @pattern("^[0-9a-f]{7}$") + commit: string; +} + +@tag("Docs") +interface Docs { + /** + Reflection endpoint to return OpenAPI JSON spec. Also used by Swagger to generate the frontpage. + @returns The OpenAPI JSON spec + */ + @summary("OpenAPI JSON spec") + @route("/openapi") + @get + openapi(): APIResponse>; + + /** + API version and Git short commit hash. + @returns The API version and commit hash. + */ + @summary("API version") + @route("/version") + @get + version(): APIResponse; +} + +@tag("Monitoring") +interface Monitoring { + /** + Checks database connection. + @returns OK or APIError. + */ + @summary("Health check") + @route("/health") + @get + health(): APIResponse; + + /** + Prometheus metrics. + @returns Metrics as text. + */ + @summary("Prometheus metrics") + @route("/metrics") + @get + metrics(): string; +} diff --git a/src/typespec/protobuf.tsp b/src/typespec/protobuf.tsp new file mode 100644 index 0000000..9bfc8bd --- /dev/null +++ b/src/typespec/protobuf.tsp @@ -0,0 +1,43 @@ +import "@typespec/protobuf"; +import "./models.tsp"; + +using TypeSpec.Protobuf; + +@package({ + name: "antelope.eosio.token.v1", +}) +namespace AntelopeTokensV1; + +// `is` or `extends` syntax doesn't work here, see https://github.com/microsoft/typespec/issues/3266 +model Transfer { + ...Models.Transfer; +} +@@field(Transfer.trx_id, 1); +@@field(Transfer.action_index, 2); +@@field(Transfer.contract, 3); +@@field(Transfer.symcode, 4); +@@field(Transfer.from, 5); +@@field(Transfer.to, 6); +@@field(Transfer.quantity, 7); +@@field(Transfer.memo, 8); +@@field(Transfer.precision, 9); +@@field(Transfer.amount, 10); +@@field(Transfer.value, 11); +@@field(Transfer.block_num, 12); +@@field(Transfer.timestamp, 13); + +model BalanceChange { + ...Models.BalanceChange; +} +@@field(BalanceChange.trx_id, 1); +@@field(BalanceChange.action_index, 2); +@@field(BalanceChange.contract, 3); +@@field(BalanceChange.symcode, 4); +@@field(BalanceChange.account, 5); +@@field(BalanceChange.balance, 6); +@@field(BalanceChange.balance_delta, 7); +@@field(BalanceChange.precision, 8); +@@field(BalanceChange.amount, 9); +@@field(BalanceChange.value, 10); +@@field(BalanceChange.block_num, 11); +@@field(BalanceChange.timestamp, 12); diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..1d625a0 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,104 @@ +import { makeQuery } from "./clickhouse/makeQuery.js"; +import { APIErrorResponse } from "./utils.js"; + +import type { Context } from "hono"; +import type { EndpointReturnTypes, UsageEndpoints, UsageResponse, ValidUserParams } from "./types/api.js"; + +export async function makeUsageQuery(ctx: Context, endpoint: UsageEndpoints, user_params: ValidUserParams) { + type EndpointElementReturnType = EndpointReturnTypes[number]; + + let { page, ...query_params } = user_params; + + if (!query_params.limit) + query_params.limit = 10; + + if (!page) + page = 1; + + let query = ""; + if (endpoint == "/balance" || endpoint == "/supply") { + // Need to narrow the type of `query_params` explicitly to access properties based on endpoint value + // See https://github.com/microsoft/TypeScript/issues/33014 + const q = query_params as ValidUserParams; + if (q.block_num) + query += + `SELECT *` + + ` FROM ${endpoint == "/balance" ? "balance_change_events" : "supply_change_events"}` + + ` FINAL`; + else + query += + `SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp` + + ` FROM ${endpoint == "/balance" ? "account_balances" : "token_supplies"}` + + ` FINAL`; + } else if (endpoint == "/transfers") { + query += `SELECT * FROM `; + + const q = query_params as ValidUserParams; + if (q.block_range) { + query += `transfers_block_num `; + } else if (q.from) { + query += `transfers_from `; + } else if (q.to) { + query += `transfers_to `; + } else if (q.contract) { + query += `transfers_contract `; + } else { + query += `transfer_events `; + } + + query += `FINAL`; + } else if (endpoint == "/holders") { + query += `SELECT DISTINCT account, value FROM eos_tokens_v1.account_balances FINAL WHERE value > 0`; + } else if (endpoint == "/head") { + query += `SELECT block_num FROM cursors` + } else if (endpoint == "/transfers/{trx_id}") { + query += `SELECT * FROM transfer_events FINAL`; + } else { + query += `SELECT DISTINCT *, updated_at_block_num AS block_num FROM eos_tokens_v1.token_supplies FINAL`; + } + + query += endpoint == "/holders" ? " AND" : " WHERE"; + for (const k of Object.keys(query_params).filter(k => k !== "limit")) // Don't add limit to WHERE clause + query += ` ${k} == {${k}: String} AND`; + query = query.substring(0, query.lastIndexOf(' ')); // Remove last item ` AND` + + query += endpoint == "/holders" ? " ORDER BY value DESC" : " ORDER BY block_num DESC"; + query += " LIMIT {limit: int}"; + query += " OFFSET {offset: int}"; + + let query_results; + try { + query_results = await makeQuery(query, { ...query_params, offset: query_params.limit * (page - 1) }); + } catch (err) { + return APIErrorResponse(ctx, 500, "bad_database_response", err); + } + + // Always have a least one total page + const total_pages = Math.max(Math.ceil((query_results.rows_before_limit_at_least ?? 0) / query_params.limit), 1); + + if (page > total_pages) + return APIErrorResponse(ctx, 400, "bad_query_input", `Requested page (${page}) exceeds total pages (${total_pages})`); + + /* Solving the `data` type issue: + type A = string[] | number[]; // This is union of array types + type B = A[number][]; // This is array of elements of union type + + let t: A; + let v: B; + + t = v; // Error + */ + + return ctx.json({ + // @ts-ignore + data: query_results.data, + meta: { + statistics: query_results.statistics ?? null, + next_page: (page * query_params.limit >= (query_results.rows_before_limit_at_least ?? 0)) ? page : page + 1, + previous_page: (page <= 1) ? page : page - 1, + total_pages, + total_results: query_results.rows_before_limit_at_least ?? 0 + } + }); +} + diff --git a/src/utils.spec.ts b/src/utils.spec.ts deleted file mode 100644 index 5b6911d..0000000 --- a/src/utils.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from "bun:test"; -import { parseBlockId, parseLimit, parsePage, parseTimestamp } from "./utils.js"; -import { config } from "./config.js"; - -test("parseBlockId", () => { - expect(parseBlockId("0x123") as string).toBe("123"); -}); - -test("parseLimit", () => { - expect(parseLimit("1")).toBe(1); - expect(parseLimit("0")).toBe(1); - expect(parseLimit(10)).toBe(10); - expect(parseLimit(config.maxLimit + 1)).toBe(config.maxLimit); -}); - -test("parsePage", () => { - expect(parsePage("1")).toBe(1); - expect(parsePage("0")).toBe(1); - expect(parsePage(10)).toBe(10); -}); - -test("parseTimestamp", () => { - expect(parseTimestamp("1697587100")).toBe(1697587100); - expect(parseTimestamp("1697587100000")).toBe(1697587100); - expect(parseTimestamp("awdawd")).toBeNaN(); - expect(parseTimestamp(null)).toBeUndefined(); -}); \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 83d4dd5..ce6162e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,53 +1,29 @@ -import { config } from "./config.js"; +import { ZodError } from "zod"; -export function parseBlockId(block_id?: string | null) { - return block_id ? block_id.replace("0x", "") : undefined; -} +import type { Context } from "hono"; +import type { APIError } from "./types/zod.gen.js"; +import { logger } from "./logger.js"; +import * as prometheus from "./prometheus.js"; -export function parseLimit(limit?: string | null | number, defaultLimit?: number) { - let value = 1; // default 1 - if (defaultLimit) - value = defaultLimit; - if (limit) { - if (typeof limit === "string") value = parseInt(limit); - if (typeof limit === "number") value = limit; - } - // limit must be between 1 and maxLimit - if (value <= 0) value = 1; - if (value > config.maxLimit) value = config.maxLimit; - return value; -} - -export function parsePage(page?: string | null | number) { - let value = 1; +export function APIErrorResponse(ctx: Context, status: APIError["status"], code: APIError["code"], err: unknown) { + let message = "An unexpected error occured"; - if (page) { - if (typeof page === "string") value = parseInt(page); - if (typeof page === "number") value = page; + if (typeof err === "string") { + message = err; + } else if (err instanceof ZodError) { + message = err.issues.map(issue => `[${issue.code}] ${issue.path.join('/')}: ${issue.message}`).join('\n'); + } else if (err instanceof Error) { + message = err.message; } - if (value <= 0) - value = 1; + const api_error = { + status, + code, + message + }; - return value; -} + logger.error(api_error); + prometheus.request_error.inc({ pathname: ctx.req.path, status }); -export function parseTimestamp(timestamp?: string | null | number) { - if (timestamp !== undefined && timestamp !== null) { - if (typeof timestamp === "string") { - if (/^[0-9]+$/.test(timestamp)) { - return parseTimestamp(parseInt(timestamp)); - } - // append "Z" to timestamp if it doesn't have it - if (!timestamp.endsWith("Z")) timestamp += "Z"; - return Math.floor(Number(new Date(timestamp)) / 1000); - } - if (typeof timestamp === "number") { - const length = timestamp.toString().length; - if (length === 10) return timestamp; // seconds - if (length === 13) return Math.floor(timestamp / 1000); // convert milliseconds to seconds - throw new Error("Invalid timestamp"); - } - } - return undefined; -} + return ctx.json(api_error, status); +} \ No newline at end of file diff --git a/swagger/favicon.ico b/swagger/favicon.ico new file mode 100644 index 0000000..647590e Binary files /dev/null and b/swagger/favicon.ico differ diff --git a/swagger/favicon.png b/swagger/favicon.png deleted file mode 100644 index ee33cc0..0000000 Binary files a/swagger/favicon.png and /dev/null differ diff --git a/swagger/index.html b/swagger/index.html index 77a6915..b17f0bf 100644 --- a/swagger/index.html +++ b/swagger/index.html @@ -9,7 +9,7 @@ /> Substreams Antelope Token API - SwaggerUI - +
diff --git a/tsconfig.json b/tsconfig.json index 7d8b6dd..fbfdeeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "strictNullChecks": true, "alwaysStrict": true, "skipLibCheck": true, + "noUncheckedIndexedAccess": true, "types": ["bun-types"] } } diff --git a/tsp-output/@typespec/openapi3/openapi.json b/tsp-output/@typespec/openapi3/openapi.json new file mode 100644 index 0000000..13ccea5 --- /dev/null +++ b/tsp-output/@typespec/openapi3/openapi.json @@ -0,0 +1,1126 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Antelope Token API", + "version": "0.0.0" + }, + "tags": [ + { + "name": "Usage" + }, + { + "name": "Docs" + }, + { + "name": "Monitoring" + } + ], + "paths": { + "/balance": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_balance", + "summary": "Token balance", + "description": "Balances of an account.", + "parameters": [ + { + "name": "block_num", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64" + } + }, + { + "name": "contract", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "symcode", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "account", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of balances.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BalanceChange" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/head": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_head", + "summary": "Head block information", + "description": "Information about the current head block in the database.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of block information.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "block_num": { + "type": "integer", + "format": "uint64" + } + }, + "required": [ + "block_num" + ] + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "Monitoring" + ], + "operationId": "Monitoring_health", + "summary": "Health check", + "description": "Checks database connection.", + "parameters": [], + "responses": { + "200": { + "description": "OK or APIError.", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/holders": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_holders", + "summary": "Token holders", + "description": "List of holders of a token.", + "parameters": [ + { + "name": "contract", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "symcode", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of accounts.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Holder" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "tags": [ + "Monitoring" + ], + "operationId": "Monitoring_metrics", + "summary": "Prometheus metrics", + "description": "Prometheus metrics.", + "parameters": [], + "responses": { + "200": { + "description": "Metrics as text.", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/openapi": { + "get": { + "tags": [ + "Docs" + ], + "operationId": "Docs_openapi", + "summary": "OpenAPI JSON spec", + "description": "Reflection endpoint to return OpenAPI JSON spec. Also used by Swagger to generate the frontpage.", + "parameters": [], + "responses": { + "200": { + "description": "The OpenAPI JSON spec", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/supply": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_supply", + "summary": "Token supply", + "description": "Total supply for a token.", + "parameters": [ + { + "name": "block_num", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64" + } + }, + { + "name": "issuer", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "contract", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "symcode", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of supplies.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Supply" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/tokens": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_tokens", + "summary": "Tokens", + "description": "List of available tokens.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of supplies.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Supply" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/transfers": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_transfers", + "summary": "Token transfers", + "description": "All transfers related to a token.", + "parameters": [ + { + "name": "block_range", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "uint64" + } + }, + "style": "form", + "explode": false + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "contract", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "symcode", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of transfers.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transfer" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/transfers/{trx_id}": { + "get": { + "tags": [ + "Usage" + ], + "operationId": "Usage_transfer", + "summary": "Token transfer", + "description": "Specific transfer related to a token.", + "parameters": [ + { + "name": "trx_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 10 + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "uint64", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "Array of transfers.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transfer" + } + }, + "meta": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + }, + "/version": { + "get": { + "tags": [ + "Docs" + ], + "operationId": "Docs_version", + "summary": "API version", + "description": "API version and Git short commit hash.", + "parameters": [], + "responses": { + "200": { + "description": "The API version and commit hash.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "APIError": { + "type": "object", + "required": [ + "status", + "code", + "message" + ], + "properties": { + "status": { + "type": "number", + "enum": [ + 500, + 504, + 400, + 401, + 403, + 404, + 405 + ] + }, + "code": { + "type": "string", + "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": { + "type": "string" + } + } + }, + "BalanceChange": { + "type": "object", + "required": [ + "trx_id", + "action_index", + "contract", + "symcode", + "precision", + "amount", + "value", + "block_num", + "timestamp", + "account", + "balance", + "balance_delta" + ], + "properties": { + "trx_id": { + "type": "string" + }, + "action_index": { + "type": "integer", + "format": "uint32" + }, + "contract": { + "type": "string" + }, + "symcode": { + "type": "string" + }, + "precision": { + "type": "integer", + "format": "uint32" + }, + "amount": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "number", + "format": "double" + }, + "block_num": { + "type": "integer", + "format": "uint64" + }, + "timestamp": { + "type": "integer", + "format": "int32" + }, + "account": { + "type": "string" + }, + "balance": { + "type": "string" + }, + "balance_delta": { + "type": "integer", + "format": "int64" + } + } + }, + "Holder": { + "type": "object", + "required": [ + "account", + "balance" + ], + "properties": { + "account": { + "type": "string" + }, + "balance": { + "type": "number", + "format": "double" + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "next_page", + "previous_page", + "total_pages", + "total_results" + ], + "properties": { + "next_page": { + "type": "integer", + "format": "int64" + }, + "previous_page": { + "type": "integer", + "format": "int64" + }, + "total_pages": { + "type": "integer", + "format": "int64" + }, + "total_results": { + "type": "integer", + "format": "int64" + } + } + }, + "QueryStatistics": { + "type": "object", + "required": [ + "elapsed", + "rows_read", + "bytes_read" + ], + "properties": { + "elapsed": { + "type": "number" + }, + "rows_read": { + "type": "integer", + "format": "int64" + }, + "bytes_read": { + "type": "integer", + "format": "int64" + } + } + }, + "ResponseMetadata": { + "type": "object", + "required": [ + "statistics", + "next_page", + "previous_page", + "total_pages", + "total_results" + ], + "properties": { + "statistics": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/QueryStatistics" + } + ], + "nullable": true + }, + "next_page": { + "type": "integer", + "format": "int64" + }, + "previous_page": { + "type": "integer", + "format": "int64" + }, + "total_pages": { + "type": "integer", + "format": "int64" + }, + "total_results": { + "type": "integer", + "format": "int64" + } + } + }, + "Supply": { + "type": "object", + "required": [ + "trx_id", + "action_index", + "contract", + "symcode", + "precision", + "amount", + "value", + "block_num", + "timestamp", + "issuer", + "max_supply", + "supply", + "supply_delta" + ], + "properties": { + "trx_id": { + "type": "string" + }, + "action_index": { + "type": "integer", + "format": "uint32" + }, + "contract": { + "type": "string" + }, + "symcode": { + "type": "string" + }, + "precision": { + "type": "integer", + "format": "uint32" + }, + "amount": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "number", + "format": "double" + }, + "block_num": { + "type": "integer", + "format": "uint64" + }, + "timestamp": { + "type": "integer", + "format": "int32" + }, + "issuer": { + "type": "string" + }, + "max_supply": { + "type": "string" + }, + "supply": { + "type": "string" + }, + "supply_delta": { + "type": "integer", + "format": "int64" + } + } + }, + "Transfer": { + "type": "object", + "required": [ + "trx_id", + "action_index", + "contract", + "symcode", + "precision", + "amount", + "value", + "block_num", + "timestamp", + "from", + "to", + "quantity", + "memo" + ], + "properties": { + "trx_id": { + "type": "string" + }, + "action_index": { + "type": "integer", + "format": "uint32" + }, + "contract": { + "type": "string" + }, + "symcode": { + "type": "string" + }, + "precision": { + "type": "integer", + "format": "uint32" + }, + "amount": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "number", + "format": "double" + }, + "block_num": { + "type": "integer", + "format": "uint64" + }, + "timestamp": { + "type": "integer", + "format": "int32" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "memo": { + "type": "string" + } + } + }, + "Version": { + "type": "object", + "required": [ + "version", + "commit" + ], + "properties": { + "version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{7}$" + } + } + } + } + } +} diff --git a/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto b/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto new file mode 100644 index 0000000..76f028a --- /dev/null +++ b/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto @@ -0,0 +1,38 @@ +// Generated by Microsoft TypeSpec + +syntax = "proto3"; + +package antelope.eosio.token.v1; + +import "google/protobuf/timestamp.proto"; + +message Transfer { + string trx_id = 1; + uint32 action_index = 2; + string contract = 3; + string symcode = 4; + uint32 precision = 9; + int64 amount = 10; + double value = 11; + uint64 block_num = 12; + google.protobuf.Timestamp timestamp = 13; + string from = 5; + string to = 6; + string quantity = 7; + string memo = 8; +} + +message BalanceChange { + string trx_id = 1; + uint32 action_index = 2; + string contract = 3; + string symcode = 4; + uint32 precision = 8; + int64 amount = 9; + double value = 10; + uint64 block_num = 11; + google.protobuf.Timestamp timestamp = 12; + string account = 5; + string balance = 6; + int64 balance_delta = 7; +} diff --git a/tspconfig.yaml b/tspconfig.yaml new file mode 100644 index 0000000..bf6f357 --- /dev/null +++ b/tspconfig.yaml @@ -0,0 +1,26 @@ +# Typespec compiler configuration file +# See https://typespec.io/docs/handbook/configuration + +# extends: ../tspconfig.yaml # Extend another config file +# emit: # Emitter name +# - ": +# "": "" +# environment-variables: # Environment variables which can be used to interpolate emitter options +# : +# default: "" +# parameters: # Parameters which can be used to interpolate emitter options +# : +# default: "" +# trace: # Trace areas to enable tracing +# - "" +# warn-as-error: true # Treat warnings as errors +# output-dir: "{project-root}/_generated" # Configure the base output directory for all emitters +warn-as-error: true +emit: + - "@typespec/protobuf" + - "@typespec/openapi3" +options: + "@typespec/openapi3": + "file-type": "json"