From 048cbb6d421b39253354d0f39f78702a209467fc Mon Sep 17 00:00:00 2001 From: Jacob Shufro Date: Sun, 1 Oct 2023 17:29:15 -0400 Subject: [PATCH] Support qvalue weighting in Accept headers --- packages/api/src/beacon/client/debug.ts | 8 +-- .../api/src/beacon/routes/beacon/block.ts | 6 +- packages/api/src/beacon/routes/debug.ts | 14 ++-- .../api/src/utils/server/parseAcceptHeader.ts | 71 +++++++++++++++++++ .../test/unit/utils/parseAcceptHeader.test.ts | 26 +++++++ .../beacon-node/src/api/impl/debug/index.ts | 6 +- 6 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 packages/api/src/utils/server/parseAcceptHeader.ts create mode 100644 packages/api/test/unit/utils/parseAcceptHeader.test.ts 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..3dd01e3029e0 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 {MediaTypes, parseAcceptHeader} from "../../../utils/server/parseAcceptHeader.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: format === undefined ? MediaTypes["json"] : MediaTypes[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..dbcc582eee0c 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 {MediaTypes, parseAcceptHeader} from "../../utils/server/parseAcceptHeader.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: format === undefined ? MediaTypes["json"] : MediaTypes[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/server/parseAcceptHeader.ts b/packages/api/src/utils/server/parseAcceptHeader.ts new file mode 100644 index 000000000000..8a12b7f730f6 --- /dev/null +++ b/packages/api/src/utils/server/parseAcceptHeader.ts @@ -0,0 +1,71 @@ +import {ResponseFormat} from "../../interfaces.js" + +enum MediaType { + json = "application/json", + ssz = "application/octet-stream", +}; + +export const MediaTypes: { + [K in ResponseFormat]: MediaType +} = { + "json": MediaType.json, + "ssz": MediaType.ssz, +}; + +function responseFormatFromMediaType(mediaType: MediaType): ResponseFormat { + for (const k of Object.keys(MediaTypes) as ResponseFormat[]) { + if (MediaTypes[k] == mediaType) { + return k + } + } + + return "json" +} + +export function parseAcceptHeader(accept?: string): ResponseFormat { + // Use json by default. + if (!accept) { + return "json" + } + + // 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 `;` delimeter 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 (!Object.values(MediaType).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/parseAcceptHeader.test.ts b/packages/api/test/unit/utils/parseAcceptHeader.test.ts new file mode 100644 index 000000000000..8336a5d49095 --- /dev/null +++ b/packages/api/test/unit/utils/parseAcceptHeader.test.ts @@ -0,0 +1,26 @@ +import {expect} from "chai"; +import {parseAcceptHeader} from "../../../src/utils/server/parseAcceptHeader.js"; +import {ResponseFormat} from "../../../src/interfaces.js"; + +describe("utils / parseAcceptHeader", () => { + describe("parseAcceptHeader", () => { + const testCases: { header: string, expected: ResponseFormat }[] = [ + { header: undefined, expected: "json" }, + { header: "application/json", expected: "json" }, + { header: "application/octet-stream", expected: "ssz" }, + { header: "application/invalid", 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,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" }, + ]; + + 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