From 4f4bf36341b794c0ccf5b7a14fdf810be06462d2 Mon Sep 17 00:00:00 2001 From: Etienne Donneger Date: Mon, 27 May 2024 17:18:37 -0400 Subject: [PATCH] Refactor to use Typespec stack (#29) Use Typespec to generate OpenAPI3 specification (and protobuf). From the OpenAPI schema, auto-generate Zod types and build the API endpoints from those definitions. Make it easier to extend new feature while keeping data models consistent across the whole data pipeline. --- README.md | 37 +- bun.lockb | Bin 5934 -> 150440 bytes index.ts | 169 ++- package.json | 43 +- src/clickhouse/{createClient.ts => client.ts} | 4 +- src/clickhouse/makeQuery.ts | 41 +- src/clickhouse/ping.ts | 2 +- src/config.ts | 7 +- src/fetch/GET.ts | 37 - src/fetch/balance.ts | 44 - src/fetch/head.ts | 20 - src/fetch/health.ts | 12 - src/fetch/openapi.ts | 229 ---- src/fetch/supply.ts | 44 - src/fetch/transfers.ts | 31 - src/fetch/utils.spec.ts | 55 - src/fetch/utils.ts | 52 - src/logger.ts | 2 +- src/prometheus.ts | 2 +- src/queries.spec.ts | 137 -- src/queries.ts | 163 --- src/types.d.ts | 14 - src/types/README.md | 10 + src/types/api.ts | 18 + src/types/zod.gen.ts | 383 ++++++ src/typespec/README.md | 19 + src/typespec/main.tsp | 5 + src/typespec/models.tsp | 56 + src/typespec/openapi3.tsp | 223 ++++ src/typespec/protobuf.tsp | 43 + src/usage.ts | 104 ++ src/utils.spec.ts | 27 - src/utils.ts | 68 +- swagger/favicon.ico | Bin 0 -> 128561 bytes swagger/favicon.png | Bin 14354 -> 0 bytes swagger/index.html | 2 +- tsconfig.json | 1 + tsp-output/@typespec/openapi3/openapi.json | 1126 +++++++++++++++++ .../protobuf/antelope/eosio/token/v1.proto | 38 + tspconfig.yaml | 26 + 40 files changed, 2309 insertions(+), 985 deletions(-) rename src/clickhouse/{createClient.ts => client.ts} (85%) delete mode 100644 src/fetch/GET.ts delete mode 100644 src/fetch/balance.ts delete mode 100644 src/fetch/head.ts delete mode 100644 src/fetch/health.ts delete mode 100644 src/fetch/openapi.ts delete mode 100644 src/fetch/supply.ts delete mode 100644 src/fetch/transfers.ts delete mode 100644 src/fetch/utils.spec.ts delete mode 100644 src/fetch/utils.ts delete mode 100644 src/queries.spec.ts delete mode 100644 src/queries.ts delete mode 100644 src/types.d.ts create mode 100644 src/types/README.md create mode 100644 src/types/api.ts create mode 100644 src/types/zod.gen.ts create mode 100644 src/typespec/README.md create mode 100644 src/typespec/main.tsp create mode 100644 src/typespec/models.tsp create mode 100644 src/typespec/openapi3.tsp create mode 100644 src/typespec/protobuf.tsp create mode 100644 src/usage.ts delete mode 100644 src/utils.spec.ts create mode 100644 swagger/favicon.ico delete mode 100644 swagger/favicon.png create mode 100644 tsp-output/@typespec/openapi3/openapi.json create mode 100644 tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto create mode 100644 tspconfig.yaml 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 f5c34e3b0c6d04665213fa0978f0a6e035c0f39b..7e5a13a68db599a8aa1ff1c3f1ea14b960561271 100755 GIT binary patch literal 150440 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 VTLWP{4a56G;sg` delta 2275 zcmcgtd010d7QgqAfDy7ZMNQZYD4Gxx2nf+}G>IZhp)#VfC@4V!MY92E%UU88w^|82 z@i8-2sr9R<)tc!rR2D6MLqTlaMgxo^)Z&6AF8Ga(M1{F8@{~X3?>YHi&i&nU?mNqS z@BA7F#!0C~8kDhA+!gWsiuOW$WS2v8QMz@r?$&5rD<|sU5A+vDe>-wuI&cabRHyjO zTE%{iUjK6eFc%xLoWN9|HRPaG0Wrr(i(}hJJLmJotAXVGMF^_j{sPN)(o^C zh%^ddzKH?AN7@_VsYs)UZ@_dHSiZ|#FD8t_)M8~aiIkwvc-z1N~IbJjkY9iSvr%cx9G}(B7q64x@ zQYLG1pXuEF-5xHaJO1+_hC4$Y=z zm-+VF5uuG!c>@jp9>;9|`MRS2cU9n&lb89G+2{>!N&S6uL!$1iZF z{|VPwctjf=#=TEIaY|^3+ePMG-Cayv*qF+kd-sX;aB%v$9-COn3Ggoc;AVYCYJ303 zo_VuM$Bf&h`MYyt9{Ar)9=IzWk1dv+|4meg`&RP^ohyvP=zwT;g-LjCHM4wS?q(&! zcCf$1{(MbE!?o7#r(d&5=oe?5jrnYl$&MPViE=bQZ0THg)$f&UP3YtADg|3czZ;b*(lod4S-3k-hPTjbAI}XxcRH!QTW8&NT)b7`Io3J3R2R8eaJ&<}|QBqv*hy7mw^M7W$$3 zb(-eCZ;F?WmU-MR+TR|tCHPcW{$Tf>CB~B78$9q-bz`K#NkDsoJ6?weuL#J9!P1C~uq{ByprF0R?coRl5;6;ML^E>XBj)YMSK z7`j&>>KE@#kb-mK)C`C4m~14J+b1j=N>6WnJjQ=fbG5xRWxIFh{XyFLZBNtI{E~V7 z_~-$f2)~K4OHZ}7y+0cWm@BflWj0K}9xhzycJGsq!SKKjhm%A8<@ITIa&Luj(N9Bj zA`FesepE=~>Z|q!E&k`BfEwb*(uPUdQQsh$A+2uD^pn+gZGca^B5lT&)H7%2tD<+E z>6n-JoBr|jNSPl7kjV3ST<~E+bEpj^yJaok(Z$wI@GR za+sl>0atX<&_zer6m*d{ZJ4-`Cl?S33k(dWsDqmJQ!uUCq$w}~K#>c@2X8Hb#1|IG zFGsW3$vPB~U~i66yhTyXTMNZ-6e+#6B%r&I+!vaaqmy4t4tZ;#xUwtm=8Z>(@9`%r z#23}j7jpn&=rW8~P?Slr90u2iO3@DhiYzI9!(a$9%tzre$l{UIqh?&W4i%_z1jhzL zydzN?QMb%n7li)=3t3~ACmi=p2gD5<4CJcyCXLo$)JRN*%^JN#ZOkRuJy7Jc(V)|* z^;w#HWYeSFFo~(am~YTYHfnSC&BXkOV?B@wLy23x(4W|*z!F?Sq#;DF0&6CI?SlbC zo*nuSTypiT9kN`)q+x{h1{4r?lo*#7d4~4+Dr^Rn5i%7vn=r>>vlz}|q^6u=;+I&A wPjtj$%eim%GIRC8rh)=ZK4F}LqMv>My-@U(<0!iHJ&flTB9%LjB8IQ~FOO%fNdN!< 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 0000000000000000000000000000000000000000..647590e06171fb799da8bfa65ee8d6ef00df5c6e GIT binary patch literal 128561 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 literal 0 HcmV?d00001 diff --git a/swagger/favicon.png b/swagger/favicon.png deleted file mode 100644 index ee33cc031a5124249874670431a2356b86d62228..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14354 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 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"