Skip to content

Commit

Permalink
Refactor to use Typespec stack (#29)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
0237h authored May 27, 2024
1 parent 9e05168 commit 4f4bf36
Show file tree
Hide file tree
Showing 40 changed files with 2,309 additions and 985 deletions.
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br>`text/html` | `/` | [Swagger](https://swagger.io/) API playground |
| GET <br>`application/json` | `/supply` | Antelope Tokens total supply |
| GET <br>`application/json` | `/balance` | Antelope Tokens balance changes |
| GET <br>`application/json` | `/transfers` | Antelope Tokens transfers |
| GET <br>`text/plain` | `/health` | Performs health checks and checks if the database is accessible |
| GET <br>`application/json` | `/balance` | Balances of an account. |
| GET <br>`application/json` | `/head` | Information about the current head block in the database |
| GET <br>`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics for the API |
| GET <br>`application/json` | `/holders` | List of holders of a token |
| GET <br>`application/json` | `/supply` | Total supply for a token |
| GET <br>`application/json` | `/tokens` | List of available tokens |
| GET <br>`application/json` | `/transfers` | All transfers related to a token |
| GET <br>`application/json` | `/transfers/{trx_id}` | Specific transfer related to a token |

### Docs

| Method | Path | Description |
| :---: | --- | --- |
| GET <br>`application/json` | `/openapi` | [OpenAPI](https://www.openapis.org/) specification |
| GET <br>`application/json` | `/version` | API version and commit hash |
| GET <br>`application/json` | `/version` | API version and Git short commit hash |

### Monitoring

| Method | Path | Description |
| :---: | --- | --- |
| GET <br>`text/plain` | `/health` | Checks database connection |
| GET <br>`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

Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down
Binary file modified bun.lockb
Binary file not shown.
169 changes: 153 additions & 16 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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}`);
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<EndpointReturnTypes<"/version">, 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<EndpointParameters<typeof endpoint>>;

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();
43 changes: 28 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
}
}
}
4 changes: 3 additions & 1 deletion src/clickhouse/createClient.ts → src/clickhouse/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 15 additions & 26 deletions src/clickhouse/makeQuery.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<T = unknown>(query: string) {
try {
const response = await client.query({ query })
const data: Query<T> = await response.json();
export async function makeQuery<T = unknown>(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<T> = 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("<makeQuery>\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;
}
2 changes: 1 addition & 1 deletion src/clickhouse/ping.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading

0 comments on commit 4f4bf36

Please sign in to comment.