From 755d4b1dfd8c7b4eee79f0a8e6f4b16f796690f2 Mon Sep 17 00:00:00 2001 From: Krow10 <23462475+Krow10@users.noreply.github.com> Date: Mon, 30 Oct 2023 07:50:08 -0400 Subject: [PATCH] Init --- .env.example | 14 +++ .github/release-drafter.yml | 54 +++++++++ .github/workflows/bun-build.yml | 27 +++++ .github/workflows/bun-test.yml | 33 ++++++ .github/workflows/ghcr.yml | 42 +++++++ .github/workflows/release-drafter.yml | 22 ++++ .gitignore | 140 ++++++++++++++++++++++ Dockerfile | 4 + LICENSE | 21 ++++ README.md | 77 ++++++++++++ bun.lockb | Bin 0 -> 5934 bytes index.ts | 18 +++ package.json | 40 +++++++ src/clickhouse/createClient.ts | 20 ++++ src/clickhouse/makeQuery.ts | 29 +++++ src/clickhouse/ping.ts | 13 +++ src/config.ts | 47 ++++++++ src/fetch/GET.ts | 24 ++++ src/fetch/block.ts | 18 +++ src/fetch/chains.ts | 20 ++++ src/fetch/health.ts | 16 +++ src/fetch/openapi.ts | 161 ++++++++++++++++++++++++++ src/logger.ts | 21 ++++ src/prometheus.ts | 40 +++++++ src/queries.spec.ts | 14 +++ src/queries.ts | 56 +++++++++ src/types.d.ts | 14 +++ src/utils.spec.ts | 41 +++++++ src/utils.ts | 36 ++++++ swagger/favicon.png | Bin 0 -> 14354 bytes swagger/index.html | 32 +++++ tsconfig.json | 18 +++ 32 files changed, 1112 insertions(+) create mode 100644 .env.example create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/bun-build.yml create mode 100644 .github/workflows/bun-test.yml create mode 100644 .github/workflows/ghcr.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/clickhouse/createClient.ts create mode 100644 src/clickhouse/makeQuery.ts create mode 100644 src/clickhouse/ping.ts create mode 100644 src/config.ts create mode 100644 src/fetch/GET.ts create mode 100644 src/fetch/block.ts create mode 100644 src/fetch/chains.ts create mode 100644 src/fetch/health.ts create mode 100644 src/fetch/openapi.ts create mode 100644 src/logger.ts create mode 100644 src/prometheus.ts create mode 100644 src/queries.spec.ts create mode 100644 src/queries.ts create mode 100644 src/types.d.ts create mode 100644 src/utils.spec.ts create mode 100644 src/utils.ts create mode 100644 swagger/favicon.png create mode 100644 swagger/index.html create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c747410 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# API Server +PORT=8080 +HOSTNAME=localhost + +# Clickhouse Database +HOST=http://127.0.0.1:8123 +DATABASE=default +USERNAME=default +PASSWORD= +TABLE= +MAX_LIMIT=500 + +# Logging +VERBOSE=true diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..141dd73 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,54 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: '✨ Features' + labels: + - 'feature' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '📝 Documentation' + labels: + - 'documentation' + - title: '🔧 Operations' + label: 'ops' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +template: | + ## Changes + $CHANGES + + **Full Changelog**: https://github.com/pinax-network/substreams-clock-api/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION + +autolabeler: + - label: 'documentation' + branch: + - '/docs\/.+/' + files: + - '*.md' + - label: 'bug' + branch: + - '/fix\/.+/' + - label: 'feature' + branch: + - '/feature\/.+/' + - label: 'ops' + files: + - '.github/*.yml' + - '.github/workflows/*.yml' + - '.gitignore' + - 'tsconfig.json' + - 'Dockerfile' \ No newline at end of file diff --git a/.github/workflows/bun-build.yml b/.github/workflows/bun-build.yml new file mode 100644 index 0000000..35ddef1 --- /dev/null +++ b/.github/workflows/bun-build.yml @@ -0,0 +1,27 @@ +name: Build +on: + release: + types: [ published ] + +permissions: + contents: write + +jobs: + bun-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: 'Install Dependencies' + run: bun install + + - name: 'Build app' + run: bun run build + + - uses: softprops/action-gh-release@v1 + with: + files: | + substreams-clock-api diff --git a/.github/workflows/bun-test.yml b/.github/workflows/bun-test.yml new file mode 100644 index 0000000..246edb3 --- /dev/null +++ b/.github/workflows/bun-test.yml @@ -0,0 +1,33 @@ +name: Test + +on: push + +jobs: + bun-test: + runs-on: ubuntu-latest + environment: dev-test + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install bun + uses: oven-sh/setup-bun@v1 + + - name: 'Install Dependencies' + run: | + bun install + + - name: 'Run lint' + run: | + bun lint + + - name: 'Run test' + run: | + bun test + env: + PORT: ${{ vars.PORT }} + HOSTNAME: ${{ vars.HOSTNAME }} + HOST: ${{ vars.HOST }} + USERNAME: ${{ secrets.USERNAME }} + PASSWORD: ${{ secrets.PASSWORD }} + TABLE: ${{ secrets.TABLE }} diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000..feca44f --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,42 @@ +name: GitHub Container Registry +on: + release: + types: [ published ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + ghcr: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{raw}} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..12b1f6c --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,22 @@ +name: Release Drafter + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90c68ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +package-lock.json + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Sublime Text +*.sublime* + +# Local clickhouse DB +cursor.lock + +# CLI +substreams-clock-api \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4472b9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM oven/bun +COPY . . +RUN bun install +ENTRYPOINT [ "bun", "./index.ts" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f7af7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pinax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..107cb12 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Antelope Token API + +[![.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 prices from the Antelope blockchains + +## REST API + +| Pathname | Description | +|-------------------------------------------|-----------------------| +| GET `/chains` | Available `chains` +| GET `/health` | Health check +| GET `/metrics` | Prometheus metrics +| GET `/openapi` | [OpenAPI v3 JSON](https://spec.openapis.org/oas/v3.0.0) + +## Requirements + +- [ClickHouse](clickhouse.com/) +- [Substreams Sink ClickHouse](https://github.com/pinax-network/substreams-sink-clickhouse/) + +## Quickstart + +```console +$ bun install +$ bun dev +``` + +## [`Bun` Binary Releases](https://github.com/pinax-network/substreams-sink-websockets/releases) + +> Linux Only + +```console +$ wget https://github.com/pinax-network/antelope-token-api/releases/download/v0.1.0/antelope-token-api +$ chmod +x ./antelope-token-api +``` + +## `.env` Environment variables + +```env +# API Server +PORT=8080 +HOSTNAME=localhost + +# Clickhouse Database +HOST=http://127.0.0.1:8123 +DATABASE=default +USERNAME=default +PASSWORD= +TABLE= +MAX_LIMIT=500 + +# Logging +VERBOSE=true +``` + +## Help + +```console + +``` + +## Docker environment + +Pull from GitHub Container registry +```bash +docker pull ghcr.io/pinax-network/antelope-token-api:latest +``` + +Build from source +```bash +docker build -t antelope-token-api . +``` + +Run with `.env` file +```bash +docker run -it --rm --env-file .env ghcr.io/pinax-network/antelope-token-api +``` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..e0c047174938a0b65481d2e22626fc83e0f8e772 GIT binary patch literal 5934 zcmeHL2~-qE8g6h#6ghmvGfEsGMi9H_;1CoM6+r{h6%a&07@&dCxtwVb1lECg$5j?Z z$;K#A;}N3^nfR<7b=3T3v70biXa}lDwnu_%}gLM0P3WQxGjoFIspSuPhm z+OA741^Hd*ps!MESIE9^4=fw%zqg#*dF0WdJtLqIBwKw(4Ejl~F}#Q12D*1Ug3zp1 z#Oun8TB=Bd(gJvfKs^&LNu<>b0SYoInpSr}J!;qsPI_pY1N_!hTnMbc`{clb(W3Z7*_4`afosCR zgY~V|{#%Oad3;B4*wVs5i)qPP>4Zt&HAH@)v~cUNIu)K*v@E?SGIve-t=3u_FS*_D zz0V&CAeKFyv_jpk0!D&Fvv093Jl=^cmzQ4585O?xfkb}s-V06cm}bBLb) zB#fT`1$V#?1rWj_9r}|nehC!7S2W;b{l+4GjL(C@T)^WxWQYDFjDHD*`Tqg_H!x^# zz;mHf#EE(P(gC;I0{E$b$9A*m3&@fqe?bpQ>>P1pds^wF1i{WxU)F~D-f|?jG01g*g@>*W*go#)CA$%S0L3sbo@b;hr~Dls(k{s( zs*QWM?9(jnxzFx<1$=(smOxNr)>TJ&um7;3x<38uK>L6{))&S_ai?Y7JsSOF*Xyil znxd|>l*yY|ylg+^1aENGY_7Usz1^ue!g}KU=S2tjQD^IFU3`Z+jLArBANSm{QdC8`z~+htrlK<Rf(fh^vzc{@;@Il;)CRo$YW9qi;IqW&0t=;yat|Lq-X= zmvZx-PINzeGOVCVRN9 z_})VI4&-~77oS9sEjZV#hZDPF()OJ0e9}^y!`)Y~lrH(p@*t``)9PN@?uxlt{6bfi zwrZ=7GJQ+5Z%O*olN?dbzm!ci>yvE4$*N=)FZO>OFR!aoYcpd`W2)3P_EV~?IA=*5 zFKq63&&N><-_^QUG&o#z-S`eCA@o$y8_mSdBWvg9HaqPR&+^S!x`}IcXxVrcFPv3+ z66eB;C8xTwSB^^9S6F&X68~Oinqtdpq9vUFR|mvv~17spHMxI^y{6Tci$SD`rHF%!)XD#%*T6 z=F^pvgP(u=#kgH>iqHG5$*9zH#iYcv43J$DA4_OFJMdV}#6WRQY><7&?m8AP-d}aR zSA(6~dB=;tZOy(~WXnIXn5>$XQFU?BoY#Mj-sCkSG|zrg|ED^#Pq?B;ctft7%2TWS$#O zER`h66$yCn?|!3CXh>+kx@{mFiVppdEWMLmMLvLg3j^{SbPhsi7UX~EEP%e-=o^c^ zhiGp_`xN?yp+3>K!Pmf@+Rr|aUWf-_5eMQz{HRaVH_`$1iTg+WqCO8B_=lkjIV4N( zr6P~0WKXb&N6&bKMvLHkJHAH`GM9=O6c`}z%^u&hjS?IKz;|-<9t_-kkfs`ZYd1=8 z90A|!jS@e=z$i610$`LZFi3Fhz$n4d3>;lBN^rab#~+LmbQ*%uYH-BC+-#YUa-#$U zfny;!HUSa=C8UHzPjm!FE6mM=lz@W!J%QsRI9>r55d}Yjz>yIgsW3MK1my;p7-Vcn z1%~{`3s_CdQfL)5CzYX9a3-QP)uy}JpubdPfZ)DJZ5TW)qd_}948W8M`mh>OLSG`$ zO6p4u9E1ZHE|R*1)`$8(R}C0IK>&N`l{cX(Yc>GIdIQV#tuk8w07DrmqFmu;+R}Re z2ZX*r3OxWK>;u%Zasby;CWYXdeU7g8rHFWX)+J1hVNLbY7 { + meta: Meta[], + data: T[], + rows: number, + statistics: { + elapsed: number, + rows_read: number, + bytes_read: number, + } +} + +export async function makeQuery(query: string) { + const response = await client.query({ query }) + const data: Query = await response.json(); + prometheus.query.inc(); + prometheus.bytes_read.inc(data.statistics.bytes_read); + prometheus.rows_read.inc(data.statistics.rows_read); + prometheus.elapsed.inc(data.statistics.elapsed); + logger.info({ query, statistics: data.statistics, rows: data.rows }); + return data; +} diff --git a/src/clickhouse/ping.ts b/src/clickhouse/ping.ts new file mode 100644 index 0000000..5646c6e --- /dev/null +++ b/src/clickhouse/ping.ts @@ -0,0 +1,13 @@ +import { PingResult } from "@clickhouse/client-web"; +import client from "./createClient.js"; + +// Does not work with Bun's implementation of Node streams. +export async function ping(): Promise { + try { + await client.exec({ query: "SELECT 1" }); + return { success: true }; + } catch (err) { + const message = typeof err === "string" ? err : JSON.stringify(err); + return { success: false, error: new Error(message) }; + } +}; \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0a8dd73 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,47 @@ +import "dotenv/config"; +import { z } from 'zod'; +import { Option, program } from "commander"; + +import pkg from "../package.json"; + +export const DEFAULT_PORT = "8080"; +export const DEFAULT_HOSTNAME = "localhost"; +export const DEFAULT_HOST = "http://localhost:8123"; +export const DEFAULT_DATABASE = "default"; +export const DEFAULT_TABLE = "blocks"; +export const DEFAULT_USERNAME = "default"; +export const DEFAULT_PASSWORD = ""; +export const DEFAULT_MAX_LIMIT = 500; +export const DEFAULT_VERBOSE = false; +export const APP_NAME = pkg.name; +export const DEFAULT_SORT_BY = "DESC"; + +// parse command line options +const opts = program + .name(pkg.name) + .version(pkg.version) + .description(pkg.description) + .showHelpAfterError() + .addOption(new Option("-p, --port ", "HTTP port on which to attach the API").env("PORT").default(DEFAULT_PORT)) + .addOption(new Option("-v, --verbose ", "Enable verbose logging").choices(["true", "false"]).env("VERBOSE").default(DEFAULT_VERBOSE)) + .addOption(new Option("--hostname ", "Server listen on HTTP hostname").env("HOSTNAME").default(DEFAULT_HOSTNAME)) + .addOption(new Option("--host ", "Database HTTP hostname").env("HOST").default(DEFAULT_HOST)) + .addOption(new Option("--username ", "Database user").env("USERNAME").default(DEFAULT_USERNAME)) + .addOption(new Option("--password ", "Password associated with the specified username").env("PASSWORD").default(DEFAULT_PASSWORD)) + .addOption(new Option("--database ", "The database to use inside ClickHouse").env("DATABASE").default(DEFAULT_DATABASE)) + .addOption(new Option("--table ", "Clickhouse table name").env("TABLE").default(DEFAULT_TABLE)) + .addOption(new Option("--max-limit ", "Maximum LIMIT queries").env("MAX_LIMIT").default(DEFAULT_MAX_LIMIT)) + .parse() + .opts(); + +export const config = z.object({ + port: z.string(), + hostname: z.string(), + host: z.string(), + table: z.string(), + database: z.string(), + username: z.string(), + password: z.string(), + maxLimit: z.coerce.number(), + verbose: z.coerce.boolean(), +}).parse(opts); diff --git a/src/fetch/GET.ts b/src/fetch/GET.ts new file mode 100644 index 0000000..2b75de3 --- /dev/null +++ b/src/fetch/GET.ts @@ -0,0 +1,24 @@ +import { registry } from "../prometheus.js"; +import openapi from "./openapi.js"; +import health from "./health.js"; +import chains from "./chains.js"; +import block from "./block.js"; +import * as prometheus from "../prometheus.js"; +import { logger } from "../logger.js"; +import swaggerHtml from "../../swagger/index.html" +import swaggerFavicon from "../../swagger/favicon.png" + +export default async function (req: Request) { + const { pathname} = new URL(req.url); + prometheus.request.inc({pathname}); + if ( pathname === "/" ) return new Response(Bun.file(swaggerHtml)); + if ( pathname === "/favicon.png" ) return new Response(Bun.file(swaggerFavicon)); + 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 === "/chains" ) return chains(req); + //if ( pathname === "/block" ) return block(req); + logger.warn(`Not found: ${pathname}`); + prometheus.request_error.inc({pathname, status: 404}); + return new Response("Not found", { status: 404 }); +} diff --git a/src/fetch/block.ts b/src/fetch/block.ts new file mode 100644 index 0000000..c58dd4a --- /dev/null +++ b/src/fetch/block.ts @@ -0,0 +1,18 @@ +import { makeQuery } from "../clickhouse/makeQuery.js"; +import { logger } from "../logger.js"; +import { Block, getBlock } from "../queries.js"; +import * as prometheus from "../prometheus.js"; + +export default async function (req: Request) { + try { + const { searchParams } = new URL(req.url); + logger.info({searchParams: Object.fromEntries(Array.from(searchParams))}); + const query = await getBlock(searchParams); + const response = await makeQuery(query) + return new Response(JSON.stringify(response.data), { headers: { "Content-Type": "application/json" } }); + } catch (e: any) { + logger.error(e); + prometheus.request_error.inc({pathname: "/block", status: 400}); + return new Response(e.message, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/fetch/chains.ts b/src/fetch/chains.ts new file mode 100644 index 0000000..d8c192d --- /dev/null +++ b/src/fetch/chains.ts @@ -0,0 +1,20 @@ +import { makeQuery } from "../clickhouse/makeQuery.js"; +import { logger } from "../logger.js"; +import * as prometheus from "../prometheus.js"; +import { getChain } from "../queries.js"; + +export async function supportedChainsQuery() { + const response = await makeQuery<{chain: string}>(getChain()); + return response.data.map((r) => r.chain); +} + +export default async function (req: Request) { + try { + const chains = await supportedChainsQuery(); + return new Response(JSON.stringify(chains), { headers: { "Content-Type": "application/json" } }); + } catch (e: any) { + logger.error(e); + prometheus.request_error.inc({pathname: "/chains", status: 400}); + return new Response(e.message, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/fetch/health.ts b/src/fetch/health.ts new file mode 100644 index 0000000..a0b6684 --- /dev/null +++ b/src/fetch/health.ts @@ -0,0 +1,16 @@ +import client from "../clickhouse/createClient.js"; +import { logger } from "../logger.js"; +import * as prometheus from "../prometheus.js"; + +export default async function (req: Request) { + try { + const response = await client.ping(); + if (response.success === false) throw new Error(response.error.message); + if (response.success === true ) return new Response("OK"); + return new Response("Unknown response from ClickHouse"); + } catch (e: any) { + logger.error(e); + prometheus.request_error.inc({ pathname: "/health", status: 500}); + return new Response(e.message, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/fetch/openapi.ts b/src/fetch/openapi.ts new file mode 100644 index 0000000..dbdc721 --- /dev/null +++ b/src/fetch/openapi.ts @@ -0,0 +1,161 @@ +import pkg from "../../package.json" assert { type: "json" }; + +import { OpenApiBuilder, SchemaObject, ExampleObject, ParameterObject } from "openapi3-ts/oas31"; +import { config } from "../config.js"; +import { getBlock } from "../queries.js"; +import { registry } from "../prometheus.js"; +import { makeQuery } from "../clickhouse/makeQuery.js"; +import { supportedChainsQuery } from "./chains.js"; + +const TAGS = { + MONITORING: "Monitoring", + HEALTH: "Health", + USAGE: "Usage", + DOCS: "Documentation", +} as const; + +const chains = await supportedChainsQuery(); +const block_example = (await makeQuery(await getBlock( new URLSearchParams({limit: "2"})))).data; + +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'}, +} + +export default new OpenApiBuilder() + .addInfo({ + title: pkg.name, + version: pkg.version, + description: pkg.description, + license: {name: pkg.license}, + }) + .addExternalDocs({ url: pkg.homepage, description: "Extra documentation" }) + .addSecurityScheme("auth-key", { type: "http", scheme: "bearer" }) + .addPath("/chains", { + get: { + tags: [TAGS.USAGE], + summary: 'Supported chains', + responses: { + 200: { + description: "Array of chains", + content: { + "application/json": { + schema: { type: "array" }, + example: chains, + } + }, + }, + }, + }, + }) + .addPath("/block", { + get: { + tags: [TAGS.USAGE], + summary: "Get block", + description: "Get block by `block_number`, `block_id` or `timestamp`", + parameters: [ + { + name: "chain", + in: "query", + description: "Filter by chain", + required: false, + schema: {enum: chains}, + }, + { + name: "block_number", + description: "Filter by Block number (ex: 18399498)", + in: "query", + required: false, + schema: { type: "number" }, + }, + { + name: "block_id", + in: "query", + description: "Filter by Block hash ID (ex: 00fef8cf2a2c73266f7c0b71fb5762f9a36419e51a7c05b0e82f9e3bacb859bc)", + required: false, + schema: { type: "string" }, + }, + { + name: 'timestamp', + in: 'query', + description: 'Filter by exact timestamp', + required: false, + schema: timestampSchema, + examples: timestampExamples, + }, + { + name: "final_block", + description: "If true, only returns final blocks", + in: "query", + required: false, + schema: { type: "boolean" }, + }, + { + name: "sort_by", + in: "query", + description: "Sort by `block_number`", + required: false, + schema: {enum: ['ASC', 'DESC'] }, + }, + ...["greater_or_equals_by_timestamp", "greater_by_timestamp", "less_or_equals_by_timestamp", "less_by_timestamp"].map(name => { + return { + name, + in: "query", + description: "Filter " + name.replace(/_/g, " "), + required: false, + schema: timestampSchema, + examples: timestampExamples, + } as ParameterObject + }), + ...["greater_or_equals_by_block_number", "greater_by_block_number", "less_or_equals_by_block_number", "less_by_block_number"].map(name => { + return { + name, + in: "query", + description: "Filter " + name.replace(/_/g, " "), + required: false, + schema: { type: "number" }, + } as ParameterObject + }), + { + name: "limit", + in: "query", + description: "Used to specify the number of records to return.", + required: false, + schema: { type: "number", maximum: config.maxLimit, minimum: 1 }, + }, + ], + responses: { + 200: { description: "Array of blocks", content: { "application/json": { example: block_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("/metrics", { + get: { + tags: [TAGS.MONITORING], + summary: "Prometheus metrics", + responses: {200: { description: "Prometheus metrics", content: { "text/plain": { example: await registry.metrics(), schema: { type: "string" } } }}}, + }, + }) + .addPath("/openapi", { + get: { + tags: [TAGS.DOCS], + summary: "OpenAPI specification", + responses: {200: {description: "OpenAPI JSON Specification", content: { "application/json": { schema: { type: "string" } } } }}, + }, + }) + .getSpecAsJson(); \ No newline at end of file diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..60b635a --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,21 @@ +import { Logger, type ILogObj } from "tslog"; +import { name } from "../package.json" assert { type: "json" }; + +class TsLogger extends Logger { + constructor() { + super(); + this.settings.minLevel = 5; + this.settings.name = name; + } + + public enable(type: "pretty" | "json" = "pretty") { + this.settings.type = type; + this.settings.minLevel = 0; + } + + public disable() { + this.settings.type = "hidden"; + } +} + +export const logger = new TsLogger(); \ No newline at end of file diff --git a/src/prometheus.ts b/src/prometheus.ts new file mode 100644 index 0000000..0235628 --- /dev/null +++ b/src/prometheus.ts @@ -0,0 +1,40 @@ +// From https://github.com/pinax-network/substreams-sink-websockets/blob/main/src/prometheus.ts +import client, { Counter, CounterConfiguration, Gauge, GaugeConfiguration } from 'prom-client'; + +export const registry = new client.Registry(); + +// Metrics +export function registerCounter(name: string, help = "help", labelNames: string[] = [], config?: CounterConfiguration) { + try { + registry.registerMetric(new Counter({ name, help, labelNames, ...config })); + return registry.getSingleMetric(name) as Counter; + } catch (e) { + console.error({name, e}); + throw new Error(`${e}`); + } +} + +export function registerGauge(name: string, help = "help", labelNames: string[] = [], config?: GaugeConfiguration) { + try { + registry.registerMetric(new Gauge({ name, help, labelNames, ...config })); + return registry.getSingleMetric(name) as Gauge; + } catch (e) { + console.error({name, e}); + throw new Error(`${e}`); + } +} + +export async function getSingleMetric(name: string) { + const metric = registry.getSingleMetric(name); + const get = await metric?.get(); + return get?.values[0].value; +} + +// REST API metrics +export const request_error = registerCounter('request_error', 'Total Requests errors', ['pathname', 'status']); +export const request = registerCounter('request', 'Total Requests', ['pathname']); +export const query = registerCounter('query', 'Clickhouse DB queries made'); +export const bytes_read = registerCounter('bytes_read', 'Clickhouse DB Statistics bytes read'); +export const rows_read = registerCounter('rows_read', 'Clickhouse DB Statistics rows read'); +export const elapsed = registerCounter('elapsed', 'Clickhouse DB Statistics query elapsed time'); + diff --git a/src/queries.spec.ts b/src/queries.spec.ts new file mode 100644 index 0000000..55a290e --- /dev/null +++ b/src/queries.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { getBlock, getChain } from "./queries.js"; + +test.skip("getBlock", () => { + expect(getBlock(new URLSearchParams({ chain: "eth", block_number: "123" }))) + .toBe(`SELECT * FROM blocks WHERE (chain == 'eth' AND block_number == '123') ORDER BY block_number DESC LIMIT 1`); + + expect(getBlock(new URLSearchParams({ chain: "eth", greater_or_equals_by_timestamp: '1438270048', less_or_equals_by_timestamp: '1438270083', limit: '3' }))) + .toBe(`SELECT * FROM blocks WHERE (toUnixTimestamp(timestamp) >= 1438270048 AND toUnixTimestamp(timestamp) <= 1438270083 AND chain == 'eth') ORDER BY block_number DESC LIMIT 3`); +}); + +test("getChain", () => { + expect(getChain()).toBe(`SELECT DISTINCT chain FROM module_hashes`); +}); \ No newline at end of file diff --git a/src/queries.ts b/src/queries.ts new file mode 100644 index 0000000..1bcb53a --- /dev/null +++ b/src/queries.ts @@ -0,0 +1,56 @@ +import { DEFAULT_SORT_BY, config } from './config.js'; +import { parseBlockId, parseLimit, parseTimestamp } from './utils.js'; + +export interface Block { + block_number: number; + block_id: string; + timestamp: string; + chain: string; +} + +export function getBlock(searchParams: URLSearchParams) { + // TO-DO: Modulo block number (ex: search by every 1M blocks) + + // SQL Query + let query = `SELECT * FROM ${config.table}`; + const where = []; + + // Clickhouse Operators + // https://clickhouse.com/docs/en/sql-reference/operators + const operators = [ + ["greater_or_equals", ">="], + ["greater", ">"], + ["less_or_equals", "<="], + ["less", "<"], + ] + for ( const [key, operator] of operators ) { + const block_number = searchParams.get(`${key}_by_block_number`); + const timestamp = parseTimestamp(searchParams.get(`${key}_by_timestamp`)); + if (block_number) where.push(`block_number ${operator} ${block_number}`); + if (timestamp) where.push(`toUnixTimestamp(timestamp) ${operator} ${timestamp}`); + } + + // equals + const chain = searchParams.get("chain"); + const block_id = parseBlockId(searchParams.get("block_id")); + const block_number = searchParams.get('block_number'); + const timestamp = parseTimestamp(searchParams.get('timestamp')); + if (chain) where.push(`chain == '${chain}'`); + if (block_id) where.push(`block_id == '${block_id}'`); + if (block_number) where.push(`block_number == '${block_number}'`); + if (timestamp) where.push(`toUnixTimestamp(timestamp) == ${timestamp}`); + + // Join WHERE statements with AND + if ( where.length ) query += ` WHERE (${where.join(' AND ')})`; + + // Sort and Limit + const limit = parseLimit(searchParams.get("limit")); + const sort_by = searchParams.get("sort_by"); + query += ` ORDER BY block_number ${sort_by ?? DEFAULT_SORT_BY}` + query += ` LIMIT ${limit}` + return query; +} + +export function getChain() { + return `SELECT DISTINCT chain FROM module_hashes`; +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..18c71fb --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,14 @@ +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/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 0000000..c74ddc3 --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { parseBlockId, parseLimit, parseTimestamp } from "./utils.js"; +import { DEFAULT_MAX_LIMIT } from "./config.js"; + +test("parseBlockId", () => { + expect(parseBlockId()).toBeUndefined(); + expect(parseBlockId(null)).toBeUndefined(); + expect(parseBlockId("00fef8cf2a2c73266f7c0b71fb5762f9a36419e51a7c05b0e82f9e3bacb859bc")).toBe("00fef8cf2a2c73266f7c0b71fb5762f9a36419e51a7c05b0e82f9e3bacb859bc"); + expect(parseBlockId("0x00fef8cf2a2c73266f7c0b71fb5762f9a36419e51a7c05b0e82f9e3bacb859bc")).toBe("00fef8cf2a2c73266f7c0b71fb5762f9a36419e51a7c05b0e82f9e3bacb859bc"); +}); + +test("parseLimit", () => { + expect(parseLimit()).toBe(1); + expect(parseLimit(null)).toBe(1); + expect(parseLimit("10")).toBe(10); + expect(parseLimit(10)).toBe(10); + expect(parseLimit(999999)).toBe(DEFAULT_MAX_LIMIT); +}); + +test("parseTimestamp", () => { + const seconds = 1672531200; + expect(parseTimestamp()).toBeUndefined(); + expect(parseTimestamp(null)).toBeUndefined(); + expect(parseTimestamp(1672531200000)).toBe(seconds); // Milliseconds (13 digits) => Seconds (10 digits) + expect(parseTimestamp("1672531200")).toBe(seconds); + expect(parseTimestamp(1672531200000)).toBe(seconds); + expect(parseTimestamp("2023-01-01T00:00:00.000Z")).toBe(seconds); + expect(parseTimestamp("2023-01-01T00:00:00.000")).toBe(seconds); + expect(parseTimestamp("2023-01-01 00:00:00")).toBe(seconds); // Datetime + expect(parseTimestamp("2023-01-01T00:00:00Z")).toBe(seconds); // ISO + expect(parseTimestamp("2023-01-01T00:00:00")).toBe(seconds); + expect(parseTimestamp("2023-01-01")).toBe(seconds); + expect(parseTimestamp("2023-01")).toBe(seconds); + expect(parseTimestamp(Number(new Date("2023")))).toBe(seconds); + + // errors + expect(() => parseTimestamp(10)).toThrow("Invalid timestamp"); + expect(() => parseTimestamp("10")).toThrow("Invalid timestamp"); +}); + + diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0d1d106 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,36 @@ +import { config } from "./config.js"; + +export function parseLimit(limit?: string|null|number) { + let value = 1; // default 1 + if (limit) { + if (typeof limit === "string") value = parseInt(limit); + if (typeof limit === "number") value = limit; + } + // limit must be between 1 and maxLimit + if ( value > config.maxLimit ) value = config.maxLimit; + return value; +} + +export function parseBlockId(block_id?: string|null) { + return block_id ? block_id.replace("0x", "") : undefined; +} + +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; +} diff --git a/swagger/favicon.png b/swagger/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ee33cc031a5124249874670431a2356b86d62228 GIT binary patch literal 14354 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 literal 0 HcmV?d00001 diff --git a/swagger/index.html b/swagger/index.html new file mode 100644 index 0000000..77a6915 --- /dev/null +++ b/swagger/index.html @@ -0,0 +1,32 @@ + + + + + + + Substreams Antelope Token API - SwaggerUI + + + + +
+ + + + + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7d8b6dd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist/", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "alwaysStrict": true, + "skipLibCheck": true, + "types": ["bun-types"] + } +}