-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
40 changed files
with
2,309 additions
and
985 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.