Skip to content
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

feat: support qvalue weighting in Accept headers #6014

Merged
merged 1 commit into from
Oct 3, 2023
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
8 changes: 4 additions & 4 deletions packages/api/src/beacon/client/debug.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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),
Expand All @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/beacon/routes/beacon/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -283,9 +283,9 @@ export function getReqSerializers(config: ChainForkConfig): ReqSerializers<Api,
const getBlockReq: ReqSerializer<Api["getBlock"], GetBlockReq> = {
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}},
};

Expand Down
14 changes: 6 additions & 8 deletions packages/api/src/beacon/routes/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
matthewkeil marked this conversation as resolved.
Show resolved Hide resolved

const stringType = new StringType();
const protoNodeSszType = new ContainerType(
{
Expand Down Expand Up @@ -91,7 +89,7 @@ export type Api = {
getState(stateId: StateId, format: "ssz"): Promise<ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}>>;
getState(
stateId: StateId,
format?: StateFormat
format?: ResponseFormat
): Promise<
ApiClientResponse<{
[HttpStatusCode.OK]: Uint8Array | {data: allForks.BeaconState; executionOptimistic: ExecutionOptimistic};
Expand All @@ -117,7 +115,7 @@ export type Api = {
getStateV2(stateId: StateId, format: "ssz"): Promise<ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}>>;
getStateV2(
stateId: StateId,
format?: StateFormat
format?: ResponseFormat
): Promise<
ApiClientResponse<{
[HttpStatusCode.OK]:
Expand Down Expand Up @@ -149,9 +147,9 @@ export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
const getState: ReqSerializer<Api["getState"], ReqTypes["getState"]> = {
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}},
};

Expand Down
81 changes: 81 additions & 0 deletions packages/api/src/utils/acceptHeader.ts
Original file line number Diff line number Diff line change
@@ -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]
);
}
37 changes: 37 additions & 0 deletions packages/api/test/unit/utils/acceptHeader.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
});
6 changes: 3 additions & 3 deletions packages/beacon-node/src/api/impl/debug/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,7 +36,7 @@ export function getDebugApi({chain, config}: Pick<ApiModules, "chain" | "config"
return {data: nodes};
},

async getState(stateId: string | number, format?: routes.debug.StateFormat) {
async getState(stateId: string | number, format?: ResponseFormat) {
const {state} = await resolveStateId(chain, stateId, {allowRegen: true});
if (format === "ssz") {
// Casting to any otherwise Typescript doesn't like the multi-type return
Expand All @@ -47,7 +47,7 @@ export function getDebugApi({chain, config}: Pick<ApiModules, "chain" | "config"
}
},

async getStateV2(stateId: string | number, format?: routes.debug.StateFormat) {
async getStateV2(stateId: string | number, format?: ResponseFormat) {
const {state} = await resolveStateId(chain, stateId, {allowRegen: true});
if (format === "ssz") {
// Casting to any otherwise Typescript doesn't like the multi-type return
Expand Down
Loading