diff --git a/packages/api/src/beacon/client/debug.ts b/packages/api/src/beacon/client/debug.ts index 726dc4718b91..b322f2b21403 100644 --- a/packages/api/src/beacon/client/debug.ts +++ b/packages/api/src/beacon/client/debug.ts @@ -1,9 +1,9 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ApiClientResponse} from "../../interfaces.js"; +import {ApiClientResponse, ResponseFormat} from "../../interfaces.js"; import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; import {generateGenericJsonClient, getFetchOptsSerializers, IHttpClient} from "../../utils/client/index.js"; import {StateId} from "../routes/beacon/state.js"; -import {Api, getReqSerializers, getReturnTypes, ReqTypes, routesData, StateFormat} from "../routes/debug.js"; +import {Api, getReqSerializers, getReturnTypes, ReqTypes, routesData} from "../routes/debug.js"; // As Jul 2022, it takes up to 3 mins to download states so make this 5 mins for reservation const GET_STATE_TIMEOUT_MS = 5 * 60 * 1000; @@ -25,7 +25,7 @@ export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Ap // TODO: Debug the type issue // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - async getState(stateId: string, format?: StateFormat) { + async getState(stateId: string, format?: ResponseFormat) { if (format === "ssz") { const res = await httpClient.arrayBuffer({ ...fetchOptsSerializers.getState(stateId, format), @@ -43,7 +43,7 @@ export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Ap // TODO: Debug the type issue // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - async getStateV2(stateId: StateId, format?: StateFormat) { + async getStateV2(stateId: StateId, format?: ResponseFormat) { if (format === "ssz") { const res = await httpClient.arrayBuffer({ ...fetchOptsSerializers.getStateV2(stateId, format), diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index c281c69047c7..417d04044649 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -18,6 +18,7 @@ import { ContainerData, } from "../../../utils/index.js"; import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; +import {parseAcceptHeader, writeAcceptHeader} from "../../../utils/acceptHeader.js"; import {ApiClientResponse, ResponseFormat} from "../../../interfaces.js"; import { SignedBlockContents, @@ -31,7 +32,6 @@ import { // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes export type BlockId = RootHex | Slot | "head" | "genesis" | "finalized"; -export const mimeTypeSSZ = "application/octet-stream"; /** * True if the response references an unverified execution payload. Optimistic information may be invalidated at @@ -283,9 +283,9 @@ export function getReqSerializers(config: ChainForkConfig): ReqSerializers = { writeReq: (block_id, format) => ({ params: {block_id: String(block_id)}, - headers: {accept: format === "ssz" ? mimeTypeSSZ : "application/json"}, + headers: {accept: writeAcceptHeader(format)}, }), - parseReq: ({params, headers}) => [params.block_id, headers.accept === mimeTypeSSZ ? "ssz" : "json"], + parseReq: ({params, headers}) => [params.block_id, parseAcceptHeader(headers.accept)], schema: {params: {block_id: Schema.StringRequired}}, }; diff --git a/packages/api/src/beacon/routes/debug.ts b/packages/api/src/beacon/routes/debug.ts index 419a785e84de..84eed0af04c9 100644 --- a/packages/api/src/beacon/routes/debug.ts +++ b/packages/api/src/beacon/routes/debug.ts @@ -17,14 +17,12 @@ import { ContainerData, } from "../../utils/index.js"; import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; +import {parseAcceptHeader, writeAcceptHeader} from "../../utils/acceptHeader.js"; +import {ApiClientResponse, ResponseFormat} from "../../interfaces.js"; import {ExecutionOptimistic, StateId} from "./beacon/state.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type StateFormat = "json" | "ssz"; -export const mimeTypeSSZ = "application/octet-stream"; - const stringType = new StringType(); const protoNodeSszType = new ContainerType( { @@ -91,7 +89,7 @@ export type Api = { getState(stateId: StateId, format: "ssz"): Promise>; getState( stateId: StateId, - format?: StateFormat + format?: ResponseFormat ): Promise< ApiClientResponse<{ [HttpStatusCode.OK]: Uint8Array | {data: allForks.BeaconState; executionOptimistic: ExecutionOptimistic}; @@ -117,7 +115,7 @@ export type Api = { getStateV2(stateId: StateId, format: "ssz"): Promise>; getStateV2( stateId: StateId, - format?: StateFormat + format?: ResponseFormat ): Promise< ApiClientResponse<{ [HttpStatusCode.OK]: @@ -149,9 +147,9 @@ export function getReqSerializers(): ReqSerializers { const getState: ReqSerializer = { writeReq: (state_id, format) => ({ params: {state_id: String(state_id)}, - headers: {accept: format === "ssz" ? mimeTypeSSZ : "application/json"}, + headers: {accept: writeAcceptHeader(format)}, }), - parseReq: ({params, headers}) => [params.state_id, headers.accept === mimeTypeSSZ ? "ssz" : "json"], + parseReq: ({params, headers}) => [params.state_id, parseAcceptHeader(headers.accept)], schema: {params: {state_id: Schema.StringRequired}}, }; diff --git a/packages/api/src/utils/acceptHeader.ts b/packages/api/src/utils/acceptHeader.ts new file mode 100644 index 000000000000..7d9854af7e5b --- /dev/null +++ b/packages/api/src/utils/acceptHeader.ts @@ -0,0 +1,81 @@ +import {ResponseFormat} from "../interfaces.js"; + +enum MediaType { + json = "application/json", + ssz = "application/octet-stream", +} + +const MEDIA_TYPES: { + [K in ResponseFormat]: MediaType; +} = { + json: MediaType.json, + ssz: MediaType.ssz, +}; + +function responseFormatFromMediaType(mediaType: MediaType): ResponseFormat { + switch (mediaType) { + default: + case MediaType.json: + return "json"; + case MediaType.ssz: + return "ssz"; + } +} + +export function writeAcceptHeader(format?: ResponseFormat): MediaType { + return format == undefined ? MEDIA_TYPES["json"] : MEDIA_TYPES[format]; +} + +export function parseAcceptHeader(accept?: string): ResponseFormat { + // Use json by default. + if (!accept) { + return "json"; + } + + const mediaTypes = Object.values(MediaType); + + // Respect Quality Values per RFC-9110 + // Acceptable mime-types are comma separated with optional whitespace + return responseFormatFromMediaType( + accept + .toLowerCase() + .split(",") + .map((x) => x.trim()) + .reduce( + (best: [number, MediaType], current: string): [number, MediaType] => { + // An optional `;` delimiter is used to separate the mime-type from the weight + // Normalize here, using 1 as the default qvalue + const quality = current.includes(";") ? current.split(";") : [current, "q=1"]; + + const mediaType = quality[0].trim() as MediaType; + + // If the mime type isn't acceptable, move on to the next entry + if (!mediaTypes.includes(mediaType)) { + return best; + } + + // Otherwise, the portion after the semicolon has optional whitespace and the constant prefix "q=" + const weight = quality[1].trim(); + if (!weight.startsWith("q=")) { + // If the format is invalid simply move on to the next entry + return best; + } + + const qvalue = +weight.replace("q=", ""); + if (isNaN(qvalue) || qvalue > 1 || qvalue <= 0) { + // If we can't convert the qvalue to a valid number, move on + return best; + } + + if (qvalue < best[0]) { + // This mime type is not preferred + return best; + } + + // This mime type is preferred + return [qvalue, mediaType]; + }, + [0, MediaType.json] + )[1] + ); +} diff --git a/packages/api/test/unit/utils/acceptHeader.test.ts b/packages/api/test/unit/utils/acceptHeader.test.ts new file mode 100644 index 000000000000..b1ce3cf48d81 --- /dev/null +++ b/packages/api/test/unit/utils/acceptHeader.test.ts @@ -0,0 +1,37 @@ +import {expect} from "chai"; +import {parseAcceptHeader} from "../../../src/utils/acceptHeader.js"; +import {ResponseFormat} from "../../../src/interfaces.js"; + +describe("utils / acceptHeader", () => { + describe("parseAcceptHeader", () => { + const testCases: {header: string | undefined; expected: ResponseFormat}[] = [ + {header: undefined, expected: "json"}, + {header: "application/json", expected: "json"}, + {header: "application/octet-stream", expected: "ssz"}, + {header: "application/invalid", expected: "json"}, + {header: "application/invalid;q=1,application/octet-stream;q=0.1", expected: "ssz"}, + {header: "application/octet-stream;q=0.5,application/json;q=1", expected: "json"}, + {header: "application/octet-stream;q=1,application/json;q=0.1", expected: "ssz"}, + {header: "application/octet-stream,application/json;q=0.1", expected: "ssz"}, + {header: "application/octet-stream;,application/json;q=0.1", expected: "json"}, + {header: "application/octet-stream;q=2,application/json;q=0.1", expected: "json"}, + {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: "json"}, + {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: "json"}, + {header: "application/octet-stream ; q=0.5 , application/json ; q=1", expected: "json"}, + {header: "application/octet-stream ; q=1 , application/json ; q=0.1", expected: "ssz"}, + {header: "application/octet-stream;q=1,application/json;q=0.1", expected: "ssz"}, + + // The implementation is order dependent, however, RFC-9110 doesn't specify a preference. + // The following tests serve to document the behavior at the time of implementation- not a + // specific requirement from the spec. In this case, last wins. + {header: "application/octet-stream;q=1,application/json;q=1", expected: "json"}, + {header: "application/json;q=1,application/octet-stream;q=1", expected: "ssz"}, + ]; + + for (const testCase of testCases) { + it(`should correctly parse the header ${testCase.header}`, () => { + expect(parseAcceptHeader(testCase.header)).to.equal(testCase.expected); + }); + } + }); +}); diff --git a/packages/beacon-node/src/api/impl/debug/index.ts b/packages/beacon-node/src/api/impl/debug/index.ts index 2f988fe32f01..22ba4e607c6b 100644 --- a/packages/beacon-node/src/api/impl/debug/index.ts +++ b/packages/beacon-node/src/api/impl/debug/index.ts @@ -1,4 +1,4 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes, ServerApi, ResponseFormat} from "@lodestar/api"; import {resolveStateId} from "../beacon/state/utils.js"; import {ApiModules} from "../types.js"; import {isOptimisticBlock} from "../../../util/forkChoice.js"; @@ -36,7 +36,7 @@ export function getDebugApi({chain, config}: Pick