From 069000220fab1568280fc021082db6dd6abd0191 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Mon, 2 Sep 2024 12:32:24 +0100 Subject: [PATCH] Add `fromJson` and `toJson` options to HTTP transport --- .changeset/slow-dragons-add.md | 5 +++ packages/rpc-transport-http/README.md | 8 ++++ .../http-transport-from-json-test.ts | 31 ++++++++++++++ .../__tests__/http-transport-to-json-test.ts | 41 +++++++++++++++++++ .../rpc-transport-http/src/http-transport.ts | 11 +++-- 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .changeset/slow-dragons-add.md create mode 100644 packages/rpc-transport-http/src/__tests__/http-transport-from-json-test.ts create mode 100644 packages/rpc-transport-http/src/__tests__/http-transport-to-json-test.ts diff --git a/.changeset/slow-dragons-add.md b/.changeset/slow-dragons-add.md new file mode 100644 index 000000000000..fdf3fe6cef0a --- /dev/null +++ b/.changeset/slow-dragons-add.md @@ -0,0 +1,5 @@ +--- +'@solana/rpc-transport-http': patch +--- + +Add `fromJson` and `toJson` options to the HTTP transport diff --git a/packages/rpc-transport-http/README.md b/packages/rpc-transport-http/README.md index 694fe8026af0..37feb0992918 100644 --- a/packages/rpc-transport-http/README.md +++ b/packages/rpc-transport-http/README.md @@ -78,6 +78,10 @@ const balances = await Promise.allSettled( ); ``` +##### `fromJson` + +An optional function that takes the response as a JSON string and converts it to a JSON value. The RPC request object is also provided as a second argument. When not provided, the JSON value will be accessed via the `response.json()` method of the fetch API. + ##### `headers` An object of headers to set on the request. Avoid [forbidden headers](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name). Additionally, the headers `Accept`, `Content-Length`, and `Content-Type` are disallowed. @@ -94,6 +98,10 @@ const transport = createHttpTransport({ }); ``` +##### `toJson` + +An optional function that takes the request payload and converts it to a JSON string. The RPC request object is also provided as a second argument. When not provided, `JSON.stringify` will be used. + ##### `url` A string representing the target endpoint. In Node, it must be an absolute URL using the `http` or `https` protocol. diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-from-json-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-from-json-test.ts new file mode 100644 index 000000000000..533d2216034c --- /dev/null +++ b/packages/rpc-transport-http/src/__tests__/http-transport-from-json-test.ts @@ -0,0 +1,31 @@ +import { RpcTransport } from '@solana/rpc-spec'; + +describe('createHttpTransport and `fromJson` function', () => { + let fromJson: jest.Mock; + let fetchSpy: jest.SpyInstance; + let makeHttpRequest: RpcTransport; + beforeEach(async () => { + await jest.isolateModulesAsync(async () => { + fromJson = jest.fn(); + fetchSpy = jest.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValue({ ok: true, text: () => '{"ok":true}' }); + const { createHttpTransport } = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await import('../http-transport'); + makeHttpRequest = createHttpTransport({ fromJson, url: 'http://localhost' }); + }); + }); + it('uses the `fromJson` function to parse the response from a JSON string', async () => { + expect.assertions(1); + const request = { methodName: 'foo', params: 123 }; + await makeHttpRequest(request); + expect(fromJson).toHaveBeenCalledWith('{"ok":true}', request); + }); + it('returns the value parsed by `fromJson`', async () => { + expect.assertions(1); + const request = { methodName: 'foo', params: 123 }; + fromJson.mockReturnValueOnce({ result: 456 }); + await expect(makeHttpRequest(request)).resolves.toEqual({ result: 456 }); + }); +}); diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-to-json-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-to-json-test.ts new file mode 100644 index 000000000000..2311f8f7bcc4 --- /dev/null +++ b/packages/rpc-transport-http/src/__tests__/http-transport-to-json-test.ts @@ -0,0 +1,41 @@ +import { RpcTransport } from '@solana/rpc-spec'; +import { createRpcMessage } from '@solana/rpc-spec-types'; + +describe('createHttpTransport and `toJson` function', () => { + let toJson: jest.Mock; + let fetchSpy: jest.SpyInstance; + let makeHttpRequest: RpcTransport; + beforeEach(async () => { + await jest.isolateModulesAsync(async () => { + toJson = jest.fn(value => JSON.stringify(value)); + fetchSpy = jest.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValue({ json: () => ({ ok: true }), ok: true }); + const { createHttpTransport } = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await import('../http-transport'); + makeHttpRequest = createHttpTransport({ toJson, url: 'http://localhost' }); + }); + }); + it('uses the `toJson` function to transform the payload to a JSON string', () => { + const request = { methodName: 'foo', params: 123 }; + makeHttpRequest(request); + expect(toJson).toHaveBeenCalledWith( + expect.objectContaining({ + ...createRpcMessage(request.methodName, request.params), + id: expect.any(Number), + }), + request, + ); + }); + it('uses passes the JSON string to the fetch API', () => { + toJson.mockReturnValueOnce('{"someAugmented":"jsonString"}'); + makeHttpRequest({ methodName: 'foo', params: 123 }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: '{"someAugmented":"jsonString"}', + }), + ); + }); +}); diff --git a/packages/rpc-transport-http/src/http-transport.ts b/packages/rpc-transport-http/src/http-transport.ts index 91096a4fceb1..1f7b39de2ba3 100644 --- a/packages/rpc-transport-http/src/http-transport.ts +++ b/packages/rpc-transport-http/src/http-transport.ts @@ -1,5 +1,5 @@ import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError } from '@solana/errors'; -import { RpcResponse, RpcTransport } from '@solana/rpc-spec'; +import { RpcRequest, RpcResponse, RpcTransport } from '@solana/rpc-spec'; import { createRpcMessage } from '@solana/rpc-spec-types'; import type Dispatcher from 'undici-types/dispatcher'; @@ -11,7 +11,9 @@ import { type Config = Readonly<{ dispatcher_NODE_ONLY?: Dispatcher; + fromJson?: (rawResponse: string, request: RpcRequest) => RpcResponse; headers?: AllowedHttpRequestHeaders; + toJson?: (payload: unknown, request: RpcRequest) => string; url: string; }>; @@ -33,7 +35,7 @@ export function createHttpTransport(config: Config): RpcTransport { if (__DEV__ && !__NODEJS__ && 'dispatcher_NODE_ONLY' in config) { warnDispatcherWasSuppliedInNonNodeEnvironment(); } - const { headers, url } = config; + const { fromJson, headers, toJson, url } = config; if (__DEV__ && headers) { assertIsAllowedHttpRequestHeaders(headers); } @@ -48,7 +50,7 @@ export function createHttpTransport(config: Config): RpcTransport { signal, }: Parameters[0]): Promise> { const payload = createRpcMessage(methodName, params); - const body = JSON.stringify(payload); + const body = toJson ? toJson(payload, { methodName, params }) : JSON.stringify(payload); const requestInfo = { ...dispatcherConfig, body, @@ -69,6 +71,9 @@ export function createHttpTransport(config: Config): RpcTransport { statusCode: response.status, }); } + if (fromJson) { + return fromJson(await response.text(), { methodName, params }) as TResponse; + } return await response.json(); }; }