Skip to content

Commit

Permalink
Add GraphQL support (#63)
Browse files Browse the repository at this point in the history
- `/graphql` endpoint presenting graphical interface for queries
- GraphQL endpoint supports the same endpoint as the REST API
  • Loading branch information
0237h authored Aug 20, 2024
1 parent 2d7155d commit cddab66
Show file tree
Hide file tree
Showing 16 changed files with 1,575 additions and 56 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@
| GET <br>`text/plain` | `/health` | Checks database connection |
| GET <br>`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics |

## GraphQL

Go to `/graphql` for a GraphIQL interface.

## Requirements

- [ClickHouse](clickhouse.com/), databases should follow a `{chain}_tokens_{version}` naming scheme. Database tables can be setup using the [`schema.sql`](./schema.sql) definitions created by the [`create_schema.sh`](./create_schema.sh) script.
- 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/pinax-network/substreams-sink-sql). You should use the generated [`protobuf` files](tsp-output/@typespec/protobuf) to build your substream. This Token API makes use of the [`substreams-antelope-tokens`](https://github.com/pinax-network/substreams-antelope-tokens/) substream.
- 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/pinax-network/substreams-sink-sql). You should use the generated [`protobuf` files](static/@typespec/protobuf) to build your substream. This Token API makes use of the [`substreams-antelope-tokens`](https://github.com/pinax-network/substreams-antelope-tokens/) substream.

### API stack architecture

Expand Down
Binary file modified bun.lockb
Binary file not shown.
93 changes: 75 additions & 18 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import client from './src/clickhouse/client.js';
import openapi from "./tsp-output/@typespec/openapi3/openapi.json";

import { Hono } from "hono";
import { Hono, type Context } from "hono";
import { type RootResolver, graphqlServer } from '@hono/graphql-server';
import { buildSchema } from 'graphql';
import { z } from 'zod';
import { paths } from './src/types/zod.gen.js';

import client from './src/clickhouse/client.js';
import openapi from "./static/@typespec/openapi3/openapi.json";
import * as prometheus from './src/prometheus.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 { usageOperationsToEndpointsMap, type EndpointReturnTypes, type UsageEndpoints, type ValidPathParams, type ValidUserParams } from "./src/types/api.js";
import { paths } from './src/types/zod.gen.js';

import type { Context } from "hono";
import type { EndpointReturnTypes, UsageEndpoints, ValidPathParams, ValidUserParams } from "./src/types/api.js";

function AntelopeTokenAPI() {
async function AntelopeTokenAPI() {
const app = new Hono();

// Tracking all incoming requests
app.use(async (ctx: Context, next) => {
const pathname = ctx.req.path;
logger.trace(`Incoming request: [${pathname}]`);
Expand All @@ -24,6 +25,10 @@ function AntelopeTokenAPI() {
await next();
});

// ---------------
// --- Swagger ---
// ---------------

app.get(
"/",
async (_) => new Response(Bun.file("./swagger/index.html"))
Expand All @@ -34,6 +39,10 @@ function AntelopeTokenAPI() {
async (_) => new Response(Bun.file("./swagger/favicon.ico"))
);

// ------------
// --- Docs ---
// ------------

app.get(
"/openapi",
async (ctx: Context) => ctx.json<{ [key: string]: EndpointReturnTypes<"/openapi">; }, 200>(openapi)
Expand All @@ -44,6 +53,10 @@ function AntelopeTokenAPI() {
async (ctx: Context) => ctx.json<EndpointReturnTypes<"/version">, 200>(APP_VERSION)
);

// ------------------
// --- Monitoring ---
// ------------------

app.get(
"/health",
async (ctx: Context) => {
Expand All @@ -62,6 +75,10 @@ function AntelopeTokenAPI() {
async (_) => new Response(await prometheus.registry.metrics(), { headers: { "Content-Type": prometheus.registry.contentType } })
);

// --------------------------
// --- REST API endpoints ---
// --------------------------

const createUsageEndpoint = (endpoint: UsageEndpoints) => app.get(
// Hono using different syntax than OpenAPI for path parameters
// `/{path_param}` (OpenAPI) VS `/:path_param` (Hono)
Expand All @@ -88,17 +105,57 @@ function AntelopeTokenAPI() {
}
);

createUsageEndpoint("/{chain}/balance");
createUsageEndpoint("/chains");
createUsageEndpoint("/{chain}/holders");
createUsageEndpoint("/{chain}/supply");
createUsageEndpoint("/{chain}/tokens");
createUsageEndpoint("/{chain}/transfers");
createUsageEndpoint("/{chain}/transfers/{trx_id}");
// Create all API endpoints interacting with DB
Object.values(usageOperationsToEndpointsMap).forEach(e => createUsageEndpoint(e));

// ------------------------
// --- GraphQL endpoint ---
// ------------------------

const schema = buildSchema(await Bun.file("./static/@openapi-to-graphql/graphql/schema.graphql").text());
const rootResolver: RootResolver = async (ctx?: Context) => {
if (ctx) {
const createGraphQLUsageResolver = (endpoint: UsageEndpoints) =>
async (args: ValidUserParams<typeof endpoint>) => await (await makeUsageQuery(ctx, endpoint, { ...args })).json();

return Object.keys(usageOperationsToEndpointsMap).reduce(
// SQL queries endpoints
(resolver, op) => Object.assign(
resolver,
{
[op]: createGraphQLUsageResolver(usageOperationsToEndpointsMap[op] as UsageEndpoints)
}
),
// Other endpoints
{
health: async () => {
const response = await client.ping();
return response.success ? "OK" : `[500] bad_database_response: ${response.error.message}`;
},
openapi: () => openapi,
metrics: async () => await prometheus.registry.getMetricsAsJSON(),
version: () => APP_VERSION
}
);
}
};

app.use(
'/graphql',
graphqlServer({
schema,
rootResolver,
graphiql: true, // if `true`, presents GraphiQL when the GraphQL endpoint is loaded in a browser.
})
);

// -------------
// --- Miscs ---
// -------------

app.notFound((ctx: Context) => APIErrorResponse(ctx, 404, "route_not_found", `Path not found: ${ctx.req.method} ${ctx.req.path}`));

return app;
}

export default AntelopeTokenAPI();
export default await AntelopeTokenAPI();
2 changes: 1 addition & 1 deletion kubb.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default defineConfig(() => {
return {
root: '.',
input: {
path: './tsp-output/@typespec/openapi3/openapi.json',
path: './static/@typespec/openapi3/openapi.json',
},
output: {
path: './src/types'
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "antelope-token-api",
"description": "Token balances, supply and transfers from the Antelope blockchains",
"version": "4.0.0",
"version": "5.0.0",
"homepage": "https://github.com/pinax-network/antelope-token-api",
"license": "MIT",
"authors": [
Expand All @@ -18,6 +18,7 @@
],
"dependencies": {
"@clickhouse/client-web": "latest",
"@hono/graphql-server": "^0.5.0",
"@kubb/cli": "^2.23.3",
"@kubb/core": "^2.23.3",
"@kubb/plugin-oas": "^2.23.3",
Expand All @@ -37,18 +38,19 @@
"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 kubb",
"types": "bun run tsp compile ./src/typespec --output-dir static && bun run openapi-to-graphql ./static/@typespec/openapi3/openapi.json --save static/@openapi-to-graphql/graphql/schema.graphql --simpleNames --singularNames && bun run kubb",
"types:check": "bun run tsp compile ./src/typespec --no-emit --pretty --warn-as-error",
"types:format": "bun run tsp format src/typespec/**/*.tsp",
"types:watch": "bun run tsp compile ./src/typespec --watch --pretty --warn-as-error"
},
"type": "module",
"devDependencies": {
"@typespec/compiler": "latest",
"@typespec/openapi": "latest",
"@typespec/openapi3": "latest",
"@typespec/protobuf": "latest",
"@typespec/openapi": "latest",
"bun-types": "latest",
"openapi-to-graphql-cli": "^3.0.7",
"typescript": "latest"
},
"prettier": {
Expand Down
7 changes: 3 additions & 4 deletions src/types/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
### `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 [`Kubb`](https://kubb.dev).
Use `bun run types` to run the code generation for Zod schemas.
> [!CAUTION]
> Auto-generated [Zod](https://zod.dev/) schemas definitions from the [OpenAPI3](../static/@typespec/openapi3/openapi.json) specification using [`Kubb`](https://kubb.dev). **DO NOT EDIT MANUALLY**.
> Use `bun run types` to run the code generation.
### `api.ts`

Expand Down
13 changes: 12 additions & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { paths } from './zod.gen.js';
import { operations, paths } from './zod.gen.js';

type GetEndpoints = typeof paths;
export type EndpointReturnTypes<E extends keyof GetEndpoints> = z.infer<GetEndpoints[E]["get"]["responses"]["default"]>;
Expand All @@ -21,3 +21,14 @@ export type ValidUserParams<E extends UsageEndpoints> = EndpointParameters<E> ex
export type AdditionalQueryParams = { offset?: number; min_block?: number; max_block?: number; };
// Allow any valid parameters from the endpoint to be used as SQL query parameters
export type ValidQueryParams = ValidUserParams<UsageEndpoints> & AdditionalQueryParams;

// Map stripped operations name (e.g. `Usage_transfers` stripped to `transfers`) to endpoint paths (e.g. `/{chain}/transfers`)
// This is used to map GraphQL operations to REST endpoints
export const usageOperationsToEndpointsMap = Object.entries(operations).filter(([k, _]) => k.startsWith("Usage")).reduce(
(o, [k, v]) => Object.assign(
o,
{
[k.split('_')[1] as string]: Object.entries(paths).find(([k_, v_]) => v_.get === v)?.[0]
}
), {}
) as { [key in string]: UsageEndpoints };
12 changes: 6 additions & 6 deletions src/types/zod.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const apiErrorSchema = z.object({ "status": z.union([z.literal(500), z.li
export type ApiErrorSchema = z.infer<typeof apiErrorSchema>;


export const balanceChangeSchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.number(), "account": z.coerce.string(), "balance": z.coerce.string(), "balance_delta": z.coerce.number() });
export const balanceChangeSchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.string(), "account": z.coerce.string(), "balance": z.coerce.string(), "balance_delta": z.coerce.number() });
export type BalanceChangeSchema = z.infer<typeof balanceChangeSchema>;


Expand All @@ -25,15 +25,15 @@ export const responseMetadataSchema = z.object({ "statistics": z.lazy(() => quer
export type ResponseMetadataSchema = z.infer<typeof responseMetadataSchema>;


export const supplySchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.number(), "issuer": z.coerce.string(), "max_supply": z.coerce.string(), "supply": z.coerce.string(), "supply_delta": z.coerce.number() });
export const supplySchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.string(), "issuer": z.coerce.string(), "max_supply": z.coerce.string(), "supply": z.coerce.string(), "supply_delta": z.coerce.number() });
export type SupplySchema = z.infer<typeof supplySchema>;


export const supportedChainsSchema = z.enum(["eos", "wax"]);
export const supportedChainsSchema = z.enum(["EOS", "WAX"]);
export type SupportedChainsSchema = z.infer<typeof supportedChainsSchema>;


export const transferSchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.number(), "from": z.coerce.string(), "to": z.coerce.string(), "quantity": z.coerce.string(), "memo": z.coerce.string() });
export const transferSchema = z.object({ "trx_id": z.coerce.string(), "action_index": z.coerce.number(), "contract": z.coerce.string(), "symcode": z.coerce.string(), "precision": z.coerce.number(), "amount": z.coerce.number(), "value": z.coerce.number(), "block_num": z.coerce.number(), "timestamp": z.coerce.string(), "from": z.coerce.string(), "to": z.coerce.string(), "quantity": z.coerce.string(), "memo": z.coerce.string() });
export type TransferSchema = z.infer<typeof transferSchema>;


Expand Down Expand Up @@ -78,12 +78,12 @@ export type MonitoringHealthQueryResponseSchema = z.infer<typeof monitoringHealt
/**
* @description Metrics as text.
*/
export const monitoringMetrics200Schema = z.coerce.string();
export const monitoringMetrics200Schema = z.object({});
export type MonitoringMetrics200Schema = z.infer<typeof monitoringMetrics200Schema>;
/**
* @description Metrics as text.
*/
export const monitoringMetricsQueryResponseSchema = z.coerce.string();
export const monitoringMetricsQueryResponseSchema = z.object({});
export type MonitoringMetricsQueryResponseSchema = z.infer<typeof monitoringMetricsQueryResponseSchema>;

/**
Expand Down
2 changes: 1 addition & 1 deletion src/typespec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ The data models used for both outputs can be found in [`models.tsp`](./models.ts

## 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.
Use the `bun run types:watch` to auto-compile the definitions on file changes. Generated outputs can be found in the [`static`](/static/) folder.

Typescript compiler options can be found in [`tspconfig.yaml`](/tspconfig.yaml).
18 changes: 10 additions & 8 deletions src/typespec/openapi3.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ using TypeSpec.OpenAPI;
name: "MIT",
url: "https://github.com/pinax-network/antelope-token-api/blob/4f4bf36341b794c0ccf5b7a14fdf810be06462d2/LICENSE"
},
version: "4.0.0"
version: "5.0.0"
}) // From @typespec/openapi
namespace AntelopeTokenAPI;

Expand All @@ -38,10 +38,12 @@ model APIError {
message: string;
}

alias TimestampType = string;

// Models will be present in the OpenAPI components
model Transfer is Models.Transfer<unixTimestamp32>;
model BalanceChange is Models.BalanceChange<unixTimestamp32>;
model Supply is Models.Supply<unixTimestamp32>;
model Transfer is Models.Transfer<TimestampType>;
model BalanceChange is Models.BalanceChange<TimestampType>;
model Supply is Models.Supply<TimestampType>;
model Holder {
account: BalanceChange.account;
balance: BalanceChange.value;
Expand Down Expand Up @@ -71,8 +73,8 @@ model UsageResponse<T> {
}

enum SupportedChains {
EOS: "eos",
WAX: "wax"
EOS,
WAX
}

// Alias will *not* be present in the OpenAPI components.
Expand All @@ -84,7 +86,7 @@ alias PaginationQueryParams = {
};

// Helper aliases for accessing underlying properties
alias BlockInfo = Models.BlockInfo<unixTimestamp32>;
alias BlockInfo = Models.BlockInfo<TimestampType>;
alias TokenIdentifier = Models.Scope;

@tag("Usage")
Expand Down Expand Up @@ -240,5 +242,5 @@ interface Monitoring {
@summary("Prometheus metrics")
@route("/metrics")
@get
metrics(): string;
metrics(): Record<unknown>;
}
5 changes: 2 additions & 3 deletions src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export async function makeUsageQuery(ctx: Context, endpoint: UsageEndpoints, use

if (endpoint !== "/chains") {
const q = query_params as ValidUserParams<typeof endpoint>;
// TODO: Document required database setup
database = `${q.chain}_tokens_v1`;
database = `${q.chain.toLowerCase()}_tokens_v1`;
}

if (endpoint == "/{chain}/balance" || endpoint == "/{chain}/supply") {
Expand Down Expand Up @@ -104,7 +103,7 @@ export async function makeUsageQuery(ctx: Context, endpoint: UsageEndpoints, use
for (const chain of supportedChainsSchema._def.values)
query +=
`SELECT '${chain}' as chain, MAX(block_num) as block_num`
+ ` FROM ${chain}_tokens_v1.cursors GROUP BY id`
+ ` FROM ${chain.toLowerCase()}_tokens_v1.cursors GROUP BY id`
+ ` UNION ALL `;
query = query.substring(0, query.lastIndexOf(' UNION')); // Remove last item ` UNION`
} else if (endpoint == "/{chain}/transfers/{trx_id}") {
Expand Down
Loading

0 comments on commit cddab66

Please sign in to comment.