• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hirosystems / stacks-blockchain-api / 3885427095

pending completion
3885427095

push

github

Matthew Little
chore!: support for Stacks 2.1

2029 of 3104 branches covered (65.37%)

7123 of 9267 relevant lines covered (76.86%)

1177.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.86
/src/api/init.ts
1
import { Server, createServer } from 'http';
31✔
2
import { Socket } from 'net';
3
import * as express from 'express';
31✔
4
import * as expressWinston from 'express-winston';
31✔
5
import * as winston from 'winston';
6
import { v4 as uuid } from 'uuid';
31✔
7
import * as cors from 'cors';
31✔
8

9
import { createTxRouter } from './routes/tx';
31✔
10
import { createDebugRouter } from './routes/debug';
31✔
11
import { createInfoRouter } from './routes/info';
31✔
12
import { createContractRouter } from './routes/contract';
31✔
13
import { createCoreNodeRpcProxyRouter } from './routes/core-node-rpc-proxy';
31✔
14
import { createBlockRouter } from './routes/block';
31✔
15
import { createFaucetRouter } from './routes/faucets';
31✔
16
import { createAddressRouter } from './routes/address';
31✔
17
import { createSearchRouter } from './routes/search';
31✔
18
import { createStxSupplyRouter } from './routes/stx-supply';
31✔
19
import { createRosettaNetworkRouter } from './routes/rosetta/network';
31✔
20
import { createRosettaMempoolRouter } from './routes/rosetta/mempool';
31✔
21
import { createRosettaBlockRouter } from './routes/rosetta/block';
31✔
22
import { createRosettaAccountRouter } from './routes/rosetta/account';
31✔
23
import { createRosettaConstructionRouter } from './routes/rosetta/construction';
31✔
24
import { apiDocumentationUrl, isProdEnv, logError, logger, LogLevel, waiter } from '../helpers';
31✔
25
import { InvalidRequestError } from '../errors';
31✔
26
import { createBurnchainRouter } from './routes/burnchain';
31✔
27
import { createBnsNamespacesRouter } from './routes/bns/namespaces';
31✔
28
import { createBnsPriceRouter } from './routes/bns/pricing';
31✔
29
import { createBnsNamesRouter } from './routes/bns/names';
31✔
30
import { createBnsAddressesRouter } from './routes/bns/addresses';
31✔
31

32
import { ChainID } from '@stacks/transactions';
31✔
33

34
import * as pathToRegex from 'path-to-regexp';
31✔
35
import * as expressListEndpoints from 'express-list-endpoints';
31✔
36
import { createMiddleware as createPrometheusMiddleware } from '@promster/express';
31✔
37
import { createMicroblockRouter } from './routes/microblock';
31✔
38
import { createStatusRouter } from './routes/status';
31✔
39
import { createTokenRouter } from './routes/tokens/tokens';
31✔
40
import { createFeeRateRouter } from './routes/fee-rate';
31✔
41
import { setResponseNonCacheable } from './controllers/cache-controller';
31✔
42

43
import * as path from 'path';
31✔
44
import * as fs from 'fs';
31✔
45
import { PgStore } from '../datastore/pg-store';
46
import { PgWriteStore } from '../datastore/pg-write-store';
47
import { WebSocketTransmitter } from './routes/ws/web-socket-transmitter';
31✔
48
import { createPox2EventsRouter } from './routes/pox2';
31✔
49
import { isPgConnectionError } from '../datastore/helpers';
31✔
50

51
export interface ApiServer {
52
  expressApp: express.Express;
53
  server: Server;
54
  ws: WebSocketTransmitter;
55
  address: string;
56
  datastore: PgStore;
57
  terminate: () => Promise<void>;
58
  forceKill: () => Promise<void>;
59
}
60

61
/** API version as given by .git-info */
62
export const API_VERSION: { branch?: string; commit?: string; tag?: string } = {};
31✔
63

64
export async function startApiServer(opts: {
31✔
65
  datastore: PgStore;
66
  writeDatastore?: PgWriteStore;
67
  chainId: ChainID;
68
  /** If not specified, this is read from the STACKS_BLOCKCHAIN_API_HOST env var. */
69
  serverHost?: string;
70
  /** If not specified, this is read from the STACKS_BLOCKCHAIN_API_PORT env var. */
71
  serverPort?: number;
72
  httpLogLevel?: LogLevel;
73
}): Promise<ApiServer> {
74
  const { datastore, writeDatastore, chainId, serverHost, serverPort, httpLogLevel } = opts;
205✔
75

76
  try {
205✔
77
    const [branch, commit, tag] = fs.readFileSync('.git-info', 'utf-8').split('\n');
205✔
78
    API_VERSION.branch = branch;
×
79
    API_VERSION.commit = commit;
×
80
    API_VERSION.tag = tag;
×
81
  } catch (error) {
82
    logger.error(`Unable to read API version from .git-info`, error);
205✔
83
  }
84

85
  const app = express();
205✔
86
  const apiHost = serverHost ?? process.env['STACKS_BLOCKCHAIN_API_HOST'];
205✔
87
  const apiPort = serverPort ?? parseInt(process.env['STACKS_BLOCKCHAIN_API_PORT'] ?? '');
205!
88

89
  if (!apiHost) {
205!
90
    throw new Error(
×
91
      `STACKS_BLOCKCHAIN_API_HOST must be specified, e.g. "STACKS_BLOCKCHAIN_API_HOST=127.0.0.1"`
92
    );
93
  }
94
  if (!apiPort) {
205!
95
    throw new Error(
×
96
      `STACKS_BLOCKCHAIN_API_PORT must be specified, e.g. "STACKS_BLOCKCHAIN_API_PORT=3999"`
97
    );
98
  }
99

100
  // app.use(compression());
101
  // app.disable('x-powered-by');
102

103
  let routes: {
104
    path: string;
105
    regexp: RegExp;
106
  }[] = [];
205✔
107

108
  if (isProdEnv) {
205!
109
    // The default from
110
    // https://github.com/tdeekens/promster/blob/696803abf03a9a657d4af46d312fa9fb70a75320/packages/metrics/src/create-metric-types/create-metric-types.ts#L16
111
    const defaultPromHttpRequestDurationInSeconds = [0.05, 0.1, 0.3, 0.5, 0.8, 1, 1.5, 2, 3, 10];
×
112

113
    // Add a few more buckets to account for requests that take longer than 10 seconds
114
    defaultPromHttpRequestDurationInSeconds.push(25, 50, 100, 250, 500);
×
115

116
    const promMiddleware = createPrometheusMiddleware({
×
117
      options: {
118
        buckets: defaultPromHttpRequestDurationInSeconds as [number],
119
        normalizePath: path => {
120
          // Get the url pathname without a query string or fragment
121
          // (note base url doesn't matter, but required by URL constructor)
122
          try {
×
123
            let pathTemplate = new URL(path, 'http://x').pathname;
×
124
            // Match request url to the Express route, e.g.:
125
            // `/extended/v1/address/ST26DR4VGV507V1RZ1JNM7NN4K3DTGX810S62SBBR/stx` to
126
            // `/extended/v1/address/:stx_address/stx`
127
            for (const pathRegex of routes) {
×
128
              if (pathRegex.regexp.test(pathTemplate)) {
×
129
                pathTemplate = pathRegex.path;
×
130
                break;
×
131
              }
132
            }
133
            return pathTemplate;
×
134
          } catch (error) {
135
            logger.warn(`Warning: ${error}`);
×
136
            return path;
×
137
          }
138
        },
139
      },
140
    });
141
    app.use(promMiddleware);
×
142
  }
143
  // Add API version to header
144
  app.use((_, res, next) => {
205✔
145
    res.setHeader(
1,340✔
146
      'X-API-Version',
147
      `${API_VERSION.tag} (${API_VERSION.branch}:${API_VERSION.commit})`
148
    );
149
    res.append('Access-Control-Expose-Headers', 'X-API-Version');
1,340✔
150
    next();
1,340✔
151
  });
152
  // Setup request logging
153
  app.use(
205✔
154
    expressWinston.logger({
155
      format: logger.format,
156
      transports: logger.transports,
157
      metaField: (null as unknown) as string,
158
      statusLevels: {
159
        error: 'error',
160
        warn: httpLogLevel ?? 'http',
241✔
161
        success: httpLogLevel ?? 'http',
241✔
162
      },
163
    })
164
  );
165

166
  app.set('json spaces', 2);
205✔
167

168
  // Turn off Express's etag handling. By default CRC32 hashes are generated over response payloads
169
  // which are useless for our use case and wastes CPU.
170
  // See https://expressjs.com/en/api.html#etag.options.table
171
  app.set('etag', false);
205✔
172

173
  app.get('/', (req, res) => {
205✔
174
    res.redirect(`/extended/v1/status`);
×
175
  });
176

177
  app.use('/doc', (req, res) => {
205✔
178
    // if env variable for API_DOCS_URL is given
179
    if (apiDocumentationUrl) {
×
180
      return res.redirect(apiDocumentationUrl);
×
181
    } else if (!isProdEnv) {
×
182
      // use local documentation if serving locally
183
      const apiDocumentationPath = path.join(__dirname + '../../../docs/.tmp/index.html');
×
184
      if (fs.existsSync(apiDocumentationPath)) {
×
185
        return res.sendFile(apiDocumentationPath);
×
186
      }
187

188
      const docNotFound = {
×
189
        error: 'Local documentation not found',
190
        desc: 'Please run the command: `npm run build:docs` and restart your server',
191
      };
192
      return res.send(docNotFound).status(404);
×
193
    }
194
    // for production and no API_DOCS_URL provided
195
    const errObj = {
×
196
      error: 'Documentation is not available',
197
      desc: `You can still read documentation from https://docs.hiro.so/api`,
198
    };
199
    res.send(errObj).status(404);
×
200
  });
201

202
  // Setup extended API v1 routes
203
  app.use(
205✔
204
    '/extended/v1',
205
    (() => {
206
      const router = express.Router();
205✔
207
      router.use(cors());
205✔
208
      router.use((req, res, next) => {
205✔
209
        // Set caching on all routes to be disabled by default, individual routes can override
210
        res.set('Cache-Control', 'no-store');
330✔
211
        next();
330✔
212
      });
213
      router.use('/tx', createTxRouter(datastore));
205✔
214
      router.use('/block', createBlockRouter(datastore));
205✔
215
      router.use('/microblock', createMicroblockRouter(datastore));
205✔
216
      router.use('/burnchain', createBurnchainRouter(datastore));
205✔
217
      router.use('/contract', createContractRouter(datastore));
205✔
218
      // same here, exclude account nonce route
219
      router.use('/address', createAddressRouter(datastore, chainId));
205✔
220
      router.use('/search', createSearchRouter(datastore));
205✔
221
      router.use('/info', createInfoRouter(datastore));
205✔
222
      router.use('/stx_supply', createStxSupplyRouter(datastore));
205✔
223
      router.use('/debug', createDebugRouter(datastore));
205✔
224
      router.use('/status', createStatusRouter(datastore));
205✔
225
      router.use('/fee_rate', createFeeRateRouter(datastore));
205✔
226
      router.use('/tokens', createTokenRouter(datastore));
205✔
227
      router.use('/pox2_events', createPox2EventsRouter(datastore));
205✔
228
      if (chainId !== ChainID.Mainnet && writeDatastore) {
205✔
229
        router.use('/faucets', createFaucetRouter(writeDatastore));
2✔
230
      }
231
      return router;
205✔
232
    })()
233
  );
234

235
  // Setup direct proxy to core-node RPC endpoints (/v2)
236
  // pricing endpoint
237
  app.use(
205✔
238
    '/v2',
239
    (() => {
240
      const router = express.Router();
205✔
241
      router.use(cors());
205✔
242
      router.use('/prices', createBnsPriceRouter(datastore, chainId));
205✔
243
      router.use('/', createCoreNodeRpcProxyRouter(datastore));
205✔
244

245
      return router;
205✔
246
    })()
247
  );
248

249
  // Rosetta API -- https://www.rosetta-api.org
250
  app.use(
205✔
251
    '/rosetta/v1',
252
    (() => {
253
      const router = express.Router();
205✔
254
      router.use(cors());
205✔
255
      router.use('/network', createRosettaNetworkRouter(datastore, chainId));
205✔
256
      router.use('/mempool', createRosettaMempoolRouter(datastore, chainId));
205✔
257
      router.use('/block', createRosettaBlockRouter(datastore, chainId));
205✔
258
      router.use('/account', createRosettaAccountRouter(datastore, chainId));
205✔
259
      router.use('/construction', createRosettaConstructionRouter(datastore, chainId));
205✔
260
      return router;
205✔
261
    })()
262
  );
263

264
  // Setup legacy API v1 and v2 routes
265
  app.use(
205✔
266
    '/v1',
267
    (() => {
268
      const router = express.Router();
205✔
269
      router.use(cors());
205✔
270
      router.use('/namespaces', createBnsNamespacesRouter(datastore));
205✔
271
      router.use('/names', createBnsNamesRouter(datastore, chainId));
205✔
272
      router.use('/addresses', createBnsAddressesRouter(datastore, chainId));
205✔
273
      return router;
205✔
274
    })()
275
  );
276

277
  //handle invalid request gracefully
278
  app.use((req, res) => {
205✔
279
    res.status(404).json({ message: `${req.method} ${req.path} not found` });
×
280
  });
281

282
  // Setup error handler (must be added at the end of the middleware stack)
283
  app.use(((error, req, res, next) => {
205✔
284
    if (req.method === 'GET' && res.statusCode !== 200 && res.hasHeader('ETag')) {
33!
285
      logger.error(
×
286
        `Non-200 request has ETag: ${res.header('ETag')}, Cache-Control: ${res.header(
287
          'Cache-Control'
288
        )}`
289
      );
290
    }
291
    if (error && res.headersSent && res.statusCode !== 200 && res.hasHeader('ETag')) {
33!
292
      logger.error(
×
293
        `A non-200 response with an error in request processing has ETag: ${res.header(
294
          'ETag'
295
        )}, Cache-Control: ${res.header('Cache-Control')}`
296
      );
297
    }
298
    if (!res.headersSent && (error || res.statusCode !== 200)) {
33!
299
      setResponseNonCacheable(res);
18✔
300
    }
301
    if (error && !res.headersSent) {
33✔
302
      if (error instanceof InvalidRequestError) {
18✔
303
        res.status(error.status).json({ error: error.message }).end();
15✔
304
      } else if (isPgConnectionError(error)) {
3✔
305
        res.status(503).json({ error: `The database service is unavailable` }).end();
1✔
306
      } else {
307
        res.status(500);
2✔
308
        const errorTag = uuid();
2✔
309
        Object.assign(error, { errorTag: errorTag });
2✔
310
        res
2✔
311
          .json({ error: error.toString(), stack: (error as Error).stack, errorTag: errorTag })
312
          .end();
313
      }
314
    }
315
    next(error);
33✔
316
  }) as express.ErrorRequestHandler);
317

318
  app.use(
205✔
319
    expressWinston.errorLogger({
320
      winstonInstance: logger as winston.Logger,
321
      metaField: (null as unknown) as string,
322
      blacklistedMetaFields: ['trace', 'os', 'process'],
323
      skip: (_req, _res, error) => {
324
        // Do not log errors for client 4xx responses
325
        return error instanceof InvalidRequestError;
33✔
326
      },
327
    })
328
  );
329

330
  // Store all the registered express routes for usage with metrics reporting
331
  routes = expressListEndpoints(app).map(endpoint => ({
19,071✔
332
    path: endpoint.path,
333
    regexp: pathToRegex.pathToRegexp(endpoint.path),
334
  }));
335

336
  // Manual route definitions for the /v2/ proxied endpoints
337
  routes.push({
205✔
338
    path: '/v2/pox',
339
    regexp: /^\/v2\/pox(.*)/,
340
  });
341
  routes.push({
205✔
342
    path: '/v2/info',
343
    regexp: /^\/v2\/info(.*)/,
344
  });
345
  routes.push({
205✔
346
    path: '/v2/accounts/*',
347
    regexp: /^\/v2\/accounts(.*)/,
348
  });
349
  routes.push({
205✔
350
    path: '/v2/contracts/call-read/*',
351
    regexp: /^\/v2\/contracts\/call-read(.*)/,
352
  });
353
  routes.push({
205✔
354
    path: '/v2/map_entry/*',
355
    regexp: /^\/v2\/map_entry(.*)/,
356
  });
357
  routes.push({
205✔
358
    path: '/v2/*',
359
    regexp: /^\/v2(.*)/,
360
  });
361

362
  const server = createServer(app);
205✔
363

364
  const serverSockets = new Set<Socket>();
205✔
365
  server.on('connection', socket => {
205✔
366
    serverSockets.add(socket);
1,065✔
367
    socket.once('close', () => {
1,065✔
368
      serverSockets.delete(socket);
1,064✔
369
    });
370
  });
371

372
  const ws = new WebSocketTransmitter(datastore, server);
205✔
373
  ws.connect();
205✔
374

375
  await new Promise<void>((resolve, reject) => {
205✔
376
    try {
205✔
377
      server.once('error', error => {
205✔
378
        reject(error);
×
379
      });
380
      server.listen(apiPort, apiHost, () => {
205✔
381
        resolve();
205✔
382
      });
383
    } catch (error) {
384
      reject(error);
×
385
    }
386
  });
387

388
  const terminate = async () => {
205✔
389
    await new Promise<void>((resolve, reject) => {
205✔
390
      logger.info('Closing WebSocket channels...');
205✔
391
      ws.close(error => {
205✔
392
        if (error) {
205!
393
          logError('Failed to gracefully close WebSocket channels', error);
×
394
          reject(error);
×
395
        } else {
396
          logger.info('API WebSocket channels closed.');
205✔
397
          resolve();
205✔
398
        }
399
      });
400
    });
401
    for (const socket of serverSockets) {
205✔
402
      socket.destroy();
165✔
403
    }
404
    await new Promise<void>(resolve => {
205✔
405
      logger.info('Closing API http server...');
205✔
406
      server.close(() => {
205✔
407
        logger.info('API http server closed.');
205✔
408
        resolve();
205✔
409
      });
410
    });
411
  };
412

413
  const forceKill = async () => {
205✔
414
    logger.info('Force closing API server...');
×
415
    const [wsClosePromise, serverClosePromise] = [waiter(), waiter()];
×
416
    ws.close(() => wsClosePromise.finish());
×
417
    server.close(() => serverClosePromise.finish());
×
418
    for (const socket of serverSockets) {
×
419
      socket.destroy();
×
420
    }
421
    await Promise.allSettled([wsClosePromise, serverClosePromise]);
×
422
  };
423

424
  const addr = server.address();
205✔
425
  if (addr === null) {
205!
426
    throw new Error('server missing address');
×
427
  }
428
  const addrStr = typeof addr === 'string' ? addr : `${addr.address}:${addr.port}`;
205!
429
  return {
205✔
430
    expressApp: app,
431
    server: server,
432
    ws: ws,
433
    address: addrStr,
434
    datastore: datastore,
435
    terminate: terminate,
436
    forceKill: forceKill,
437
  };
438
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc