Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/api/deprecation-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,34 @@ import { FastifyPluginAsync } from 'fastify';
declare module 'fastify' {
interface FastifySchema {
deprecatedMessage?: string;
/**
* Marks a `deprecated` route that keeps serving when `ENABLE_DEPRECATED_ENDPOINTS` is off. It
* still gets the `Warning` header and is still hidden from the OpenAPI spec; it is only exempt
* from the `410 Gone` sunset kill switch.
*/
deprecatedExemptFromSunset?: boolean;
}
}

const pluginCb: FastifyPluginAsync<{
defaultDeprecatedMessage: string;
/**
* When `false`, every route whose schema is marked `deprecated` is disabled and responds with
* `410 Gone`. Defaults to enabled when omitted.
* `410 Gone`, unless it is also marked `deprecatedExemptFromSunset`. Defaults to enabled when
* omitted.
*/
enableDeprecatedEndpoints?: boolean;
}> = async (fastify, options) => {
// When deprecated endpoints are disabled (e.g. past a sunset date), short-circuit any route
// whose schema is marked `deprecated` with a `410 Gone` before its handler (and any DB work)
// runs. `onRequest` is the earliest hook with `routeOptions` populated.
// whose schema is marked `deprecated` (and not `deprecatedExemptFromSunset`) with a `410 Gone`
// before its handler (and any DB work) runs. `onRequest` is the earliest hook with
// `routeOptions` populated.
if (options.enableDeprecatedEndpoints === false) {
fastify.addHook('onRequest', async (request, reply) => {
if (request.routeOptions.schema?.deprecated) {
const schema = request.routeOptions.schema;
if (schema?.deprecated && !schema.deprecatedExemptFromSunset) {
const message =
request.routeOptions.schema.deprecatedMessage ??
options.defaultDeprecatedMessage ??
'Endpoint is deprecated';
schema.deprecatedMessage ?? options.defaultDeprecatedMessage ?? 'Endpoint is deprecated';
return reply.code(410).send({
error: 'Gone',
message: `This endpoint has been disabled. ${message}`,
Expand Down Expand Up @@ -58,6 +65,9 @@ const pluginCb: FastifyPluginAsync<{
*
* If `enableDeprecatedEndpoints` is `false`, all deprecated routes are disabled and respond with
* `410 Gone` instead of executing. This is the master kill-switch flipped at a sunset date.
* Routes that also set `deprecatedExemptFromSunset: true` are exempt and keep serving (still with
* the `Warning` header): use it for endpoints that are deprecated in favor of a newer version but
* are not part of the planned sunset.
*/
const DeprecationPlugin = fp(pluginCb);

Expand Down
1 change: 1 addition & 0 deletions src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export async function startApiServer(opts: {

// Middleware to annotate http responses with deprecation warnings, and to disable deprecated
// endpoints entirely (410 Gone) once `ENABLE_DEPRECATED_ENDPOINTS` is flipped off at sunset.
// Routes marked `deprecatedExemptFromSunset` keep serving (with the warning) when it is off.
await fastify.register(DeprecationPlugin, {
defaultDeprecatedMessage: 'See https://docs.hiro.so/stacks/api for more information',
enableDeprecatedEndpoints: ENV.ENABLE_DEPRECATED_ENDPOINTS,
Expand Down
10 changes: 5 additions & 5 deletions src/api/routes/v1/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const BlockRoutes: FastifyPluginAsync<
operationId: 'get_block_list',
summary: 'Get recent blocks',
description:
'Retrieves a list of recently mined blocks. **This endpoint is deprecated in favor of `get_blocks`.**',
'Retrieves a list of recently mined blocks. **This endpoint is deprecated in favor of `get_stacks_blocks`.**',
tags: ['Blocks'],
querystring: Type.Object({
limit: LimitParam(ResourceType.Block, 'Limit', 'max number of blocks to fetch'),
Expand Down Expand Up @@ -59,7 +59,7 @@ export const BlockRoutes: FastifyPluginAsync<
operationId: 'get_block_by_height',
summary: 'Get block by height',
description:
'Retrieves block details of a specific block at a given block height. **This endpoint is deprecated in favor of `get_block`.**',
'Retrieves block details of a specific block at a given block height. **This endpoint is deprecated in favor of `get_stacks_block`.**',
tags: ['Blocks'],
params: Type.Object({
height: Type.Integer({
Expand Down Expand Up @@ -92,7 +92,7 @@ export const BlockRoutes: FastifyPluginAsync<
operationId: 'get_block_by_burn_block_height',
summary: 'Get block by burnchain height',
description:
'Retrieves block details of a specific block for a given burn chain height. **This endpoint is deprecated in favor of `get_blocks_by_burn_block`.**',
'Retrieves block details of a specific block for a given burn chain height. **This endpoint is deprecated in favor of `get_bitcoin_block_stacks_blocks`.**',
tags: ['Blocks'],
params: Type.Object({
burn_block_height: Type.Integer({
Expand Down Expand Up @@ -128,7 +128,7 @@ export const BlockRoutes: FastifyPluginAsync<
operationId: 'get_block_by_hash',
summary: 'Get block by hash',
description:
'Retrieves block details of a specific block for a given chain height. **This endpoint is deprecated in favor of `get_block`.**',
'Retrieves block details of a specific block for a given chain height. **This endpoint is deprecated in favor of `get_stacks_block`.**',
tags: ['Blocks'],
params: Type.Object({
hash: Type.String({
Expand Down Expand Up @@ -167,7 +167,7 @@ export const BlockRoutes: FastifyPluginAsync<
operationId: 'get_block_by_burn_block_hash',
summary: 'Get block by burnchain block hash',
description:
'Retrieves block details of a specific block for a given burnchain block hash. **This endpoint is deprecated in favor of `get_blocks_by_burn_block`.**',
'Retrieves block details of a specific block for a given burnchain block hash. **This endpoint is deprecated in favor of `get_bitcoin_block_stacks_blocks`.**',
tags: ['Blocks'],
params: Type.Object({
burn_block_hash: Type.String({
Expand Down
14 changes: 12 additions & 2 deletions src/api/routes/v2/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ export const BlockRoutesV2: FastifyPluginAsync<
preHandler: handleChainTipCache,
schema: {
operationId: 'get_blocks',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage:
'Use GET /extended/v3/blocks instead. It is cursor-paginated (no offset).',
summary: 'Get blocks',
description: `Retrieves a list of recently mined blocks`,
description: `Retrieves a list of recently mined blocks. **Deprecated:** use \`GET /extended/v3/blocks\` instead.`,
tags: ['Blocks'],
querystring: Type.Object({
limit: LimitParam(ResourceType.Block),
Expand Down Expand Up @@ -145,8 +149,11 @@ export const BlockRoutesV2: FastifyPluginAsync<
},
schema: {
operationId: 'get_block',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage: 'Use GET /extended/v3/blocks/{height_or_hash} instead.',
summary: 'Get block',
description: `Retrieves a single block`,
description: `Retrieves a single block. **Deprecated:** use \`GET /extended/v3/blocks/{height_or_hash}\` instead.`,
tags: ['Blocks'],
params: BlockParamsSchema,
response: {
Expand Down Expand Up @@ -175,6 +182,9 @@ export const BlockRoutesV2: FastifyPluginAsync<
schema: {
operationId: 'get_transactions_by_block',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage:
'Use GET /extended/v3/blocks/{height_or_hash}/transactions instead. It is cursor-paginated (no offset).',
summary: 'Get transactions by block',
description: `Retrieves transactions confirmed in a single block. **Deprecated:** use \`GET /extended/v3/blocks/{height_or_hash}/transactions\` instead.`,
tags: ['Transactions'],
Expand Down
17 changes: 14 additions & 3 deletions src/api/routes/v2/burn-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ export const BurnBlockRoutesV2: FastifyPluginAsync<
preHandler: handleChainTipCache,
schema: {
operationId: 'get_burn_blocks',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage:
'Use GET /extended/v3/bitcoin-blocks instead. It is cursor-paginated (no offset).',
summary: 'Get burn blocks',
description: `Retrieves a list of recent burn blocks`,
description: `Retrieves a list of recent burn blocks. **Deprecated:** use \`GET /extended/v3/bitcoin-blocks\` instead.`,
tags: ['Burn Blocks'],
querystring: Type.Object({
limit: LimitParam(ResourceType.BurnBlock),
Expand Down Expand Up @@ -58,8 +62,11 @@ export const BurnBlockRoutesV2: FastifyPluginAsync<
},
schema: {
operationId: 'get_burn_block',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage: 'Use GET /extended/v3/bitcoin-blocks/{height_or_hash} instead.',
summary: 'Get burn block',
description: `Retrieves a single burn block`,
description: `Retrieves a single burn block. **Deprecated:** use \`GET /extended/v3/bitcoin-blocks/{height_or_hash}\` instead.`,
tags: ['Burn Blocks'],
params: BurnBlockParamsSchema,
response: {
Expand Down Expand Up @@ -88,8 +95,12 @@ export const BurnBlockRoutesV2: FastifyPluginAsync<
},
schema: {
operationId: 'get_blocks_by_burn_block',
deprecated: true,
deprecatedExemptFromSunset: true,
deprecatedMessage:
'Use GET /extended/v3/bitcoin-blocks/{height_or_hash}/blocks instead. It is cursor-paginated (no offset).',
summary: 'Get blocks by burn block',
description: `Retrieves a list of blocks confirmed by a specific burn block`,
description: `Retrieves a list of blocks confirmed by a specific burn block. **Deprecated:** use \`GET /extended/v3/bitcoin-blocks/{height_or_hash}/blocks\` instead.`,
tags: ['Burn Blocks'],
params: BurnBlockParamsSchema,
querystring: Type.Object({
Expand Down
177 changes: 177 additions & 0 deletions tests/api/blocks/v2-blocks-deprecation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import supertest from 'supertest';
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { STACKS_TESTNET } from '@stacks/network';
import { ApiServer, startApiServer } from '../../../src/api/init.ts';
import DeprecationPlugin from '../../../src/api/deprecation-plugin.ts';
import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts';
import { ENV } from '../../../src/env.ts';
import { migrate } from '../../test-helpers.ts';

/**
* Deprecation coverage for the `/extended/v2/blocks` and `/extended/v2/burn-blocks` routes that
* have a v3 counterpart.
*
* These routes are deprecated in favor of v3 but exempt from the `ENABLE_DEPRECATED_ENDPOINTS`
* sunset kill switch (`deprecatedExemptFromSunset`): they keep the `Warning` header, yet keep
* serving when deprecated endpoints are disabled.
*
* The header is attached by `DeprecationPlugin` on `onSend`, which runs for error responses too —
* so the 404s below still assert the deprecation contract without needing fixture data.
*/

const warning = (message: string) => `299 - "Deprecated: ${message}"`;
const PAGINATED = 'It is cursor-paginated (no offset).';

const HASH = '0x' + '11'.repeat(32);

const EXEMPT_ROUTES = [
{
path: '/extended/v2/blocks',
warning: warning(`Use GET /extended/v3/blocks instead. ${PAGINATED}`),
},
{
path: `/extended/v2/blocks/${HASH}`,
warning: warning('Use GET /extended/v3/blocks/{height_or_hash} instead.'),
},
{
path: `/extended/v2/blocks/${HASH}/transactions`,
warning: warning(
`Use GET /extended/v3/blocks/{height_or_hash}/transactions instead. ${PAGINATED}`
),
},
{
path: '/extended/v2/burn-blocks',
warning: warning(`Use GET /extended/v3/bitcoin-blocks instead. ${PAGINATED}`),
},
{
path: `/extended/v2/burn-blocks/${HASH}`,
warning: warning('Use GET /extended/v3/bitcoin-blocks/{height_or_hash} instead.'),
},
{
path: `/extended/v2/burn-blocks/${HASH}/blocks`,
warning: warning(
`Use GET /extended/v3/bitcoin-blocks/{height_or_hash}/blocks instead. ${PAGINATED}`
),
},
];

/** Deprecated v1 block routes are part of the sunset, so they must still be disabled. */
const GATED_ROUTES = ['/extended/v1/block', `/extended/v1/block/${HASH}`];

describe('v2 block and burn-block endpoint deprecation', () => {
let db: PgWriteStore;
let api: ApiServer | undefined;

async function startApi(): Promise<ApiServer> {
api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId });
return api;
}

beforeEach(async () => {
await migrate('up');
db = await PgWriteStore.connect({
usageName: 'tests',
withNotifier: false,
skipMigrations: true,
});
});

afterEach(async () => {
ENV.ENABLE_DEPRECATED_ENDPOINTS = true;
await api?.terminate();
api = undefined;
await db?.close();
await migrate('down');
});

for (const route of EXEMPT_ROUTES) {
test(`GET ${route.path} carries its v3 deprecation warning`, async () => {
const api = await startApi();
const res = await supertest(api.server).get(route.path);
assert.equal(res.headers['warning'], route.warning);
});
}

test('exempt routes keep serving when deprecated endpoints are disabled', async () => {
ENV.ENABLE_DEPRECATED_ENDPOINTS = false;
const api = await startApi();
for (const route of EXEMPT_ROUTES) {
const res = await supertest(api.server).get(route.path);
assert.notEqual(res.status, 410, route.path);
assert.equal(res.headers['warning'], route.warning, route.path);
}
// The list endpoints are served normally against an empty database.
const blocks = await supertest(api.server).get('/extended/v2/blocks');
assert.equal(blocks.status, 200);
assert.deepEqual(blocks.body.results, []);
const burnBlocks = await supertest(api.server).get('/extended/v2/burn-blocks');
assert.equal(burnBlocks.status, 200);
assert.deepEqual(burnBlocks.body.results, []);
});

test('v1 block routes still return 410 when disabled', async () => {
ENV.ENABLE_DEPRECATED_ENDPOINTS = false;
const api = await startApi();
for (const path of GATED_ROUTES) {
const res = await supertest(api.server).get(path);
assert.equal(res.status, 410, path);
assert.equal(res.body.error, 'Gone', path);
}
});
});

describe('DeprecationPlugin deprecatedExemptFromSunset', () => {
async function buildServer(enableDeprecatedEndpoints: boolean) {
const fastify = Fastify();
await fastify.register(DeprecationPlugin, {
defaultDeprecatedMessage: 'default message',
enableDeprecatedEndpoints,
});
fastify.get('/gated', { schema: { deprecated: true } }, async () => ({ ok: true }));
fastify.get(
'/exempt',
{ schema: { deprecated: true, deprecatedExemptFromSunset: true } },
async () => ({ ok: true })
);
// The flag alone does nothing on a route that is not deprecated.
fastify.get('/current', { schema: { deprecatedExemptFromSunset: true } }, async () => ({
ok: true,
}));
await fastify.ready();
return fastify;
}

test('only non-exempt deprecated routes are disabled', async () => {
const fastify = await buildServer(false);
try {
const gated = await fastify.inject({ method: 'GET', url: '/gated' });
assert.equal(gated.statusCode, 410);
assert.equal(gated.headers['warning'], warning('default message'));

const exempt = await fastify.inject({ method: 'GET', url: '/exempt' });
assert.equal(exempt.statusCode, 200);
assert.deepEqual(exempt.json(), { ok: true });
assert.equal(exempt.headers['warning'], warning('default message'));

const current = await fastify.inject({ method: 'GET', url: '/current' });
assert.equal(current.statusCode, 200);
assert.equal(current.headers['warning'], undefined);
} finally {
await fastify.close();
}
});

test('all routes serve when deprecated endpoints are enabled', async () => {
const fastify = await buildServer(true);
try {
for (const url of ['/gated', '/exempt', '/current']) {
const res = await fastify.inject({ method: 'GET', url });
assert.equal(res.statusCode, 200, url);
}
} finally {
await fastify.close();
}
});
});
Loading