-
Notifications
You must be signed in to change notification settings - Fork 3
Feat: [WIP] - API #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
0xpeluche
wants to merge
20
commits into
DefiLlama:master
Choose a base branch
from
0xpeluche:api
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
7161154
[WIP]-API-coins
0xpeluche d7d64ed
.
0xpeluche ae1240d
ensure lowercase
0xpeluche 5168296
wip - api
0xpeluche db61f68
WIIP
0xpeluche 5dd8f77
add pm2 config
0xpeluche c739fcd
rm metadata cache
0xpeluche 3ac0580
add readme, fix async writeCache, pageSize 100k
0xpeluche 368f357
Fix some requested changes
0xpeluche 8622356
ms -> s
0xpeluche 11ee37c
fix
0xpeluche 121c322
add first and percentage routes
0xpeluche 50f360e
Use redirects, add percentageChange, earliest, small fixes
0xpeluche 6864d09
split logic by service
0xpeluche dfc8b07
update readme
0xpeluche 542dd2f
endDate cant be higher than date.now()
0xpeluche c2e2166
update handling error less restrictive
0xpeluche 05920fd
populate cache for the very first instance (if cache is empty)
0xpeluche 6a6530d
Use redirect Key instead of normalized for coinsCurrent
0xpeluche 855f37e
start script
0xpeluche File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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,3 +1,5 @@ | ||
| *.log | ||
| node_modules/ | ||
| .cache/ | ||
| .env | ||
| api/data/ |
This file contains hidden or 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 |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # API Prototype PR | ||
|
|
||
| ## Overview | ||
|
|
||
| This is a prototype PR of an API that manages cryptocurrency metadata, current prices, and timeseries data. The API uses: | ||
|
|
||
| - **PM2 Cluster** for load balancing and auto-restart. | ||
| - **Local Caching** for metadata (managed in a dedicated module). | ||
| - **Elasticsearch** for historical timeseries data. | ||
| - **Redis** for current price data. | ||
|
|
||
| ## Features | ||
|
|
||
| - **PM2 Cluster Setup** | ||
| The API is designed to run in a PM2 cluster. Instance 0 is responsible for fetching the initial metadata refresh (if enabled) while all instances serve the endpoints. | ||
|
|
||
| - **Local Cache Management** | ||
| Metadata is cached locally in a file (e.g., `api/data/metadata.json`). The cache is maintained via a dedicated module (`cache/metadataCache.js`). | ||
| **Note:** The metadata refresh cron is now managed externally (e.g., via Coolify) using a separate script. | ||
|
|
||
| - **Normalized PID Mapping** | ||
| Coin IDs (pids) are normalized using the helper `normalizeCoinId` (from `utils/index.js`) and a mapping is maintained between the input pid and its normalized value. A maximum of 100 pids per request is allowed. | ||
|
|
||
| ## Endpoints | ||
|
|
||
| ### 1. Metadata Route | ||
|
|
||
| **GET** `/api/coins/metadata?pid=bitcoin,ethereum` | ||
|
|
||
| - **Description:** | ||
| Retrieves metadata for the specified coin IDs. | ||
|
|
||
| - **Process:** | ||
| - The API checks the local cache for each pid (using normalized values). | ||
| - If metadata for a pid is missing, it returns `null` (no fallback to Elasticsearch is performed). | ||
|
|
||
| ### 2. Current Price Route | ||
|
|
||
| **GET** `/api/coins/current?pid=bitcoin,ethereum&withMetadata=true&withTTL=true` | ||
|
|
||
| - **Description:** | ||
| Retrieves the current price of the specified coins from Redis. | ||
|
|
||
| - **Options:** | ||
| - `withTTL=true`: Includes TTL information. | ||
|
|
||
| - **Process:** | ||
| - The API queries Redis using keys formatted as `price_<normalized_pid>`. | ||
|
|
||
| ### 3. Timeseries Route | ||
|
|
||
| **GET** `/api/coins/timeseries?pid=bitcoin,ethereum&startDate=2023-01-01&endDate=2023-01-31&scale=5m` | ||
| or | ||
| **GET** `/api/coins/timeseries?pid=bitcoin,ethereum×tamp=1673000000` | ||
|
|
||
| - **Description:** | ||
| Retrieves historical timeseries data for the specified coin IDs. | ||
|
|
||
| - **Modes:** | ||
| - **Timestamp mode:** | ||
| If a `timestamp` (in seconds) is provided, the API returns, for each coin, the document whose `ts` is closest to that timestamp. | ||
|
|
||
| - **Range mode:** | ||
| If `startDate` and `endDate` are provided, the API returns all matching records (grouped by coin). | ||
|
|
||
| - **Scale mode:** | ||
| If a `scale` (e.g., "1m", "5m", "1h", "1d") is provided along with `startDate` and `endDate`, the API performs an aggregation using a `date_histogram`. | ||
| The response includes, for each coin, an array of objects per interval containing: | ||
| - `timestamp` (in seconds) | ||
| - `avg_price` | ||
| - `min_price` | ||
| - `max_price` | ||
| - `count` | ||
|
|
||
| - **Note:** | ||
| A check is performed to avoid exceeding the maximum number of buckets (e.g., 5,000). If too many buckets would be created, an error is returned. | ||
|
|
||
| ### 4. Earliest Timestamp Route | ||
|
|
||
| **GET** `/api/coins/earliest?pid=bitcoin,ethereum` | ||
|
|
||
| - **Description:** | ||
| Retrieves, for each coin, the earliest available timeseries record (i.e., the record with the lowest timestamp). | ||
|
|
||
| - **Process:** | ||
| - For each provided pid, the API queries Elasticsearch for the document with the smallest `ts`. | ||
|
|
||
| ### 5. Percentage Change Route | ||
|
|
||
| **GET** `/api/coins/percentage-change?pid=bitcoin,ethereum×tamp=1656944730&period=3600&lookForward=true` | ||
|
|
||
| - **Description:** | ||
| Calculates the percentage change in price for each coin between two points in time. | ||
|
|
||
| - **Parameters:** | ||
| - `timestamp`: A reference timestamp in seconds (t₀). | ||
| - `period`: The period (in seconds) over which to calculate the change. | ||
| - `lookForward`: | ||
| - If `true`, the change is measured from t₀ to t₀ + period (i.e., forward in time). | ||
| - If `false` or not provided, the change is measured from t₀ to t₀ - period (i.e., backward in time). | ||
|
|
||
| - **Process:** | ||
| - For each coin, the API retrieves the document closest to t₀ and another at t₀ + period (or t₀ - period), then calculates the percentage change as: | ||
| ``` | ||
| ((price_t1 - price_t0) / price_t0) * 100 | ||
| ``` | ||
|
|
||
| ## Setup Instructions | ||
|
|
||
| 1. Install dependencies: | ||
| ```sh | ||
| npm install | ||
| ``` | ||
|
|
||
| 2. Start the PM2 cluster: | ||
| ```sh | ||
| pm2 start ecosystem.config.js | ||
| ``` | ||
|
|
||
| ## Env Dependencies | ||
| ``` | ||
| REDIS_CLIENT_CONFIG= | ||
| COINS_ELASTICSEARCH_CONFIG= | ||
| ``` |
This file contains hidden or 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 |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { getClient } = require('../../db/elastic'); | ||
|
|
||
| let localMetadataCache = {}; | ||
|
|
||
| const DATA_DIR = path.join(__dirname, '../data'); | ||
| const METADATA_JSON_PATH = path.join(DATA_DIR, 'metadata.json'); | ||
|
|
||
| function ensureCacheFileExists() { | ||
| if (!fs.existsSync(DATA_DIR)) { | ||
| fs.mkdirSync(DATA_DIR, { recursive: true }); | ||
| console.log("Data directory created:", DATA_DIR); | ||
| } | ||
| if (!fs.existsSync(METADATA_JSON_PATH)) { | ||
| fs.writeFileSync(METADATA_JSON_PATH, '{}', 'utf8'); | ||
| console.log("Created empty metadata file:", METADATA_JSON_PATH); | ||
| } | ||
| } | ||
|
|
||
| function loadLocalMetadataCache() { | ||
| try { | ||
| ensureCacheFileExists(); | ||
| const data = fs.readFileSync(METADATA_JSON_PATH, 'utf8'); | ||
| localMetadataCache = JSON.parse(data); | ||
| console.log("Local metadata cache reloaded."); | ||
| } catch (err) { | ||
| console.error("Error loading metadata cache file:", err); | ||
| localMetadataCache = {}; | ||
| } | ||
| } | ||
|
|
||
| function saveLocalMetadataCache() { | ||
| ensureCacheFileExists(); | ||
| fs.writeFile( | ||
| METADATA_JSON_PATH, | ||
| JSON.stringify(localMetadataCache, null, 2), | ||
| 'utf8', | ||
| (err) => { | ||
| if (err) { | ||
| console.error("Failed to write local metadata cache file:", err); | ||
| } else { | ||
| console.log(`Cache saved to ${METADATA_JSON_PATH}`); | ||
| } | ||
| } | ||
| ); | ||
| } | ||
|
|
||
|
|
||
| function initCacheWatcher() { | ||
| loadLocalMetadataCache(); | ||
|
|
||
| fs.watchFile(METADATA_JSON_PATH, { interval: 1000 }, (curr, prev) => { | ||
| if (curr.mtimeMs !== prev.mtimeMs) { | ||
| console.log("Detected change in metadata file, reloading cache..."); | ||
| loadLocalMetadataCache(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| function getLocalCache() { | ||
| return localMetadataCache; | ||
| } | ||
|
|
||
| function updateLocalCache(newCache) { | ||
| localMetadataCache = newCache; | ||
| saveLocalMetadataCache(); | ||
| } | ||
|
|
||
| async function getAllMetadata() { | ||
| const client = getClient(); | ||
| if (!client) { | ||
| throw new Error('No Elasticsearch client available.'); | ||
| } | ||
|
|
||
| const pageSize = 100000; | ||
| const allDocs = []; | ||
|
|
||
| let response = await client.search({ | ||
| index: 'coins-metadata', | ||
| scroll: '1m', | ||
| size: pageSize, | ||
| body: { query: { match_all: {} } } | ||
| }); | ||
|
|
||
| while (response.hits && response.hits.hits.length > 0) { | ||
| allDocs.push(...response.hits.hits.map(hit => hit._source)); | ||
| response = await client.scroll({ | ||
| scroll_id: response._scroll_id, | ||
| scroll: '1m' | ||
| }); | ||
| } | ||
| return allDocs; | ||
| } | ||
|
|
||
| async function refreshLocalMetadataFromES() { | ||
| const allData = await getAllMetadata(); | ||
| console.log(`Fetched ${allData.length} metadata documents from ES.`); | ||
|
|
||
| const newCache = { ...localMetadataCache }; | ||
| for (const item of allData) { | ||
| if (item.pid) { | ||
| newCache[item.pid.toLowerCase()] = item; | ||
| } | ||
| } | ||
| updateLocalCache(newCache); | ||
| } | ||
|
|
||
| module.exports = { | ||
| initCacheWatcher, | ||
| getLocalCache, | ||
| updateLocalCache, | ||
| refreshLocalMetadataFromES | ||
| }; | ||
This file contains hidden or 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 |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| const { getCurrentCoin } = require('../services/getCurrentCoin') | ||
| const { getCoinsEarliest } = require('../services/getEarliestCoin') | ||
| const { getCoinMetadata } = require('../services/getMetadataCoin') | ||
| const { getTimeseries } = require('../services/getTimeseriesCoin') | ||
| const { getPercentageChange } = require('../services/getPercentageChangeCoin') | ||
|
|
||
| function sendResponse(res, status, data) { | ||
| res.statusCode = status; | ||
| res.header('Content-Type', 'application/json'); | ||
| res.send(JSON.stringify(data)); | ||
| } | ||
|
|
||
| /** | ||
| * GET /metadata | ||
| * Example: /api/coins/metadata?pid=bitcoin,ethereum | ||
| * This endpoint retrieves metadata from the local cache or Elasticsearch for the given pid(s). | ||
| */ | ||
| async function getCoinsMetadata(req, res) { | ||
| try { | ||
| const { pid } = req.query; | ||
| if (!pid) { | ||
| return sendResponse(res, 400, { error: "Missing 'pid' query parameter." }); | ||
| } | ||
| const data = getCoinMetadata({ pid }); | ||
| return sendResponse(res, 200, data); | ||
| } catch (error) { | ||
| return sendResponse(res, 400, { error: error.message || 'Internal Server Error' }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /current | ||
| * Example: /api/coins/current?pid=bitcoin,ethereum&withMetadata=true&withTTL=true | ||
| * This endpoint retrieves current coin data from Redis for the given pid(s). | ||
| * It can also include metadata or TTL if specified in the query. | ||
| */ | ||
| async function getCoinsCurrent(req, res) { | ||
| try { | ||
| const { pid, withTTL = 'false' } = req.query; | ||
| if (!pid) { | ||
| return sendResponse(res, 400, { error: "Missing 'pid' query parameter." }); | ||
| } | ||
| const includeTTL = (withTTL === 'true'); | ||
| const data = await getCurrentCoin(pid, { withTTL: includeTTL }); | ||
| return sendResponse(res, 200, data); | ||
| } catch (error) { | ||
| return sendResponse(res, 400, { error: error.message || 'Internal Server Error' }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /timeseries | ||
| * Example: /api/coins/timeseries?pid=bitcoin&startDate=2023-01-01&endDate=2023-01-31 | ||
| * Or: /api/coins/timeseries?pid=bitcoin,ethereum×tamp=1673000000 | ||
| * | ||
| * pid is required. | ||
| * If 'timestamp' is provided, for each pid we return the single document whose 'ts' is closest to that timestamp. | ||
| * If 'timestamp' is not provided, we fetch all documents (optionally constrained by startDate/endDate), grouped by pid and sorted by ts ascending. | ||
| */ | ||
| async function getCoinsTimeseries(req, res) { | ||
| try { | ||
| const { pid, startDate, endDate, timestamp, scale } = req.query; | ||
| if (!pid) { | ||
| return sendResponse(res, 400, { error: "Missing 'pid' query parameter." }); | ||
| } | ||
| const data = await getTimeseries({ pid, startDate, endDate, timestamp, scale }); | ||
| return sendResponse(res, 200, data); | ||
| } catch (error) { | ||
| return sendResponse(res, 400, { error: error.message || 'Internal Server Error' }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /earliest | ||
| * Example: /api/coins/earliest?pid=bitcoin,ethereum | ||
| * Returns, for each coin, the earliest record (i.e. with the lowest timestamp). | ||
| */ | ||
| async function getCoinsFirstTimestamp(req, res) { | ||
| try { | ||
| const { pid } = req.query; | ||
| if (!pid) { | ||
| return sendResponse(res, 400, { error: "Missing 'pid' query parameter." }); | ||
| } | ||
| const data = await getCoinsEarliest({ pid }); | ||
| return sendResponse(res, 200, data); | ||
| } catch (error) { | ||
| return sendResponse(res, 400, { error: error.message || 'Internal Server Error' }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /percentage-change | ||
| * Example: /api/coins/percentage-change?pid=bitcoin,ethereum×tamp=1656944730&period=3600&lookForward=true | ||
| * Parameters: | ||
| * - pid: list of coins. | ||
| * - timestamp: a reference timestamp in seconds. | ||
| * - period: period (in seconds) to calculate the change. | ||
| * - lookForward: if true, change is from t0 to t0 + period; otherwise from t0 to t0 - period. | ||
| */ | ||
| async function getCoinsPercentageChange(req, res) { | ||
| try { | ||
| const { pid, timestamp, period, lookForward } = req.query; | ||
| if (!pid || !timestamp || !period) { | ||
| return sendResponse(res, 400, { error: "Missing required query parameters: pid, timestamp, period." }); | ||
| } | ||
| const data = await getPercentageChange({ pid, timestamp, period, lookForward }); | ||
| return sendResponse(res, 200, data); | ||
| } catch (error) { | ||
| return sendResponse(res, 400, { error: error.message || 'Internal Server Error' }); | ||
| } | ||
| } | ||
|
|
||
| module.exports = { | ||
| getCoinsMetadata, | ||
| getCoinsCurrent, | ||
| getCoinsTimeseries, | ||
| getCoinsFirstTimestamp, | ||
| getCoinsPercentageChange | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.