From 0937c84f4782c88b0fe7bab774febad2915db6d5 Mon Sep 17 00:00:00 2001 From: Ludovic Autin Date: Sat, 18 Apr 2026 22:20:57 -0700 Subject: [PATCH 1/4] Fix GraphQL POST request handling --- src/mol-util/_spec/graphql-client.spec.ts | 77 +++++++++++++++++++++++ src/mol-util/assets.ts | 25 +++++--- src/mol-util/data-source.ts | 11 +++- src/mol-util/graphql-client.ts | 11 ++-- 4 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/mol-util/_spec/graphql-client.spec.ts diff --git a/src/mol-util/_spec/graphql-client.spec.ts b/src/mol-util/_spec/graphql-client.spec.ts new file mode 100644 index 000000000..25820b7b7 --- /dev/null +++ b/src/mol-util/_spec/graphql-client.spec.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author OpenAI Codex + */ + +import { RuntimeContext } from '../../mol-task'; +import { Asset, AssetManager } from '../assets'; +import { ajaxGet } from '../data-source'; +import { GraphQLClient } from '../graphql-client'; + +describe('graphql transport', () => { + it('adds JSON headers to GraphQL requests', async () => { + const assetManager = new AssetManager(); + const dispose = jest.fn(); + + const resolveSpy = jest.spyOn(assetManager, 'resolve').mockImplementation((asset, type) => { + expect(type).toBe('json'); + expect(Asset.isUrl(asset)).toBe(true); + if (!Asset.isUrl(asset)) throw new Error('expected URL asset'); + + expect(asset.url).toBe('https://example.org/graphql'); + expect(asset.headers).toEqual([ + ['Content-Type', 'application/json; charset=utf-8'], + ['Accept', 'application/json'] + ]); + expect(asset.body).toContain('"query"'); + expect(asset.body).toContain('"variables"'); + + return { + id: 0, + name: 'mock', + run: async () => ({ data: { data: { ok: true } }, dispose }), + runAsChild: async () => ({ data: { data: { ok: true } }, dispose }), + runInContext: async () => ({ data: { data: { ok: true } }, dispose }), + } as any; + }); + + const client = new GraphQLClient('https://example.org/graphql', assetManager); + const result = await client.request(RuntimeContext.Synchronous, 'query Test { test }', { id: '1' }); + + expect(resolveSpy).toHaveBeenCalledTimes(1); + expect(result.data).toEqual({ ok: true }); + result.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('preserves POST body and headers in Node.js HTTP requests', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: 200, + bytes: async () => new TextEncoder().encode(JSON.stringify({ ok: true })) + } as any); + + const result = await ajaxGet({ + url: 'https://example.org/graphql', + type: 'json', + body: '{"query":"{ test }"}', + headers: [ + ['Content-Type', 'application/json'], + ['Accept', 'application/json'] + ] + }).run(); + + expect(fetchSpy).toHaveBeenCalledWith('https://example.org/graphql', { + signal: expect.any(AbortSignal), + method: 'POST', + body: '{"query":"{ test }"}', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + expect(result).toEqual({ ok: true }); + + fetchSpy.mockRestore(); + }); +}); diff --git a/src/mol-util/assets.ts b/src/mol-util/assets.ts index 8957f5daf..947347494 100644 --- a/src/mol-util/assets.ts +++ b/src/mol-util/assets.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2020-2026 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -54,15 +54,26 @@ namespace Asset { return typeof url === 'string' ? url : url.url; } - export function getUrlAsset(manager: AssetManager, url: string | Url, body?: string) { + export function getUrlAsset(manager: AssetManager, url: string | Url, body?: string, headers?: [string, string][]) { if (typeof url === 'string') { - const asset = manager.tryFindUrl(url, body); - return asset || Url(url, { body }); + const asset = manager.tryFindUrl(url, body, headers); + return asset || Url(url, { body, headers }); } return url; } } +function urlHeadersEqual(a?: [string, string][], b?: [string, string][]) { + const aLength = a?.length ?? 0; + const bLength = b?.length ?? 0; + if (aLength !== bLength) return false; + + for (let i = 0; i < aLength; i++) { + if (a![i][0] !== b![i][0] || a![i][1] !== b![i][1]) return false; + } + return true; +} + class AssetManager { // TODO: add URL based ref-counted cache? // TODO: when serializing, check for duplicates? @@ -73,13 +84,13 @@ class AssetManager { return iterableToArray(this._assets.values()); } - tryFindUrl(url: string, body?: string): Asset.Url | undefined { + tryFindUrl(url: string, body?: string, headers?: [string, string][]): Asset.Url | undefined { const assets = this.assets.values(); while (true) { const v = assets.next(); if (v.done) return; const asset = v.value.asset; - if (Asset.isUrl(asset) && asset.url === url && (asset.body || '') === (body || '')) return asset; + if (Asset.isUrl(asset) && asset.url === url && (asset.body || '') === (body || '') && urlHeadersEqual(asset.headers, headers)) return asset; } } @@ -168,4 +179,4 @@ class AssetManager { dispose() { this.clear(); } -} \ No newline at end of file +} diff --git a/src/mol-util/data-source.ts b/src/mol-util/data-source.ts index 9ccb67cf3..9cbb07bdb 100644 --- a/src/mol-util/data-source.ts +++ b/src/mol-util/data-source.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -333,7 +333,12 @@ function ajaxGetInternal_http_NodeJS(title: string | undefin const aborter = new AbortController(); return Task.create(title ?? 'Download', async ctx => { await ctx.update({ message: 'Downloading...', canAbort: true }); - const response = await fetch(url, { signal: aborter.signal }); + const response = await fetch(url, { + signal: aborter.signal, + method: body ? 'POST' : 'GET', + body, + headers: headers ? Object.fromEntries(headers) : void 0 + }); if (!(response.status >= 200 && response.status < 400)) { throw new Error(`Download failed with status code ${response.status}`); } @@ -402,4 +407,4 @@ async function wrapPromise(index: number, id: string, p: Promise * @@ -57,9 +57,12 @@ export class GraphQLClient { constructor(private url: string, private assetManager: AssetManager) { } async request(ctx: RuntimeContext, query: string, variables?: Variables): Promise> { - + const headers: [string, string][] = [ + ['Content-Type', 'application/json; charset=utf-8'], + ['Accept', 'application/json'] + ]; const body = JSON.stringify({ query, variables }, null, 2); - const url = Asset.getUrlAsset(this.assetManager, this.url, body); + const url = Asset.getUrlAsset(this.assetManager, this.url, body, headers); const result = await this.assetManager.resolve(url, 'json').runInContext(ctx); if (!result.data.errors && result.data.data) { @@ -72,4 +75,4 @@ export class GraphQLClient { throw new ClientError({ ...errorResult }, { query, variables }); } } -} \ No newline at end of file +} From c6874c922d7840e1fc7ccf454a9d07269dedbc83 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sat, 25 Apr 2026 07:50:21 -0700 Subject: [PATCH 2/4] use record for headers Co-authored-by: Copilot --- src/extensions/g3d/data.ts | 2 +- src/mol-util/_spec/graphql-client.spec.ts | 19 ++++++++++--------- src/mol-util/assets.ts | 20 ++++++++++---------- src/mol-util/data-source.ts | 12 ++++++------ src/mol-util/graphql-client.ts | 8 ++++---- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/extensions/g3d/data.ts b/src/extensions/g3d/data.ts index a0e9a6a83..93ebbd2cf 100644 --- a/src/extensions/g3d/data.ts +++ b/src/extensions/g3d/data.ts @@ -59,7 +59,7 @@ export async function getG3dDataBlock(ctx: PluginContext, header: G3dHeader, url async function getRawData(ctx: PluginContext, urlOrData: string | Uint8Array, range: { offset: number, size: number }) { if (typeof urlOrData === 'string') { - return await ctx.runTask(ctx.fetch({ url: urlOrData, headers: [['Range', `bytes=${range.offset}-${range.offset + range.size - 1}`]], type: 'binary' })); + return await ctx.runTask(ctx.fetch({ url: urlOrData, headers: { 'Range': `bytes=${range.offset}-${range.offset + range.size - 1}` }, type: 'binary' })); } else { return urlOrData.slice(range.offset, range.offset + range.size); } diff --git a/src/mol-util/_spec/graphql-client.spec.ts b/src/mol-util/_spec/graphql-client.spec.ts index 25820b7b7..ba6b3c593 100644 --- a/src/mol-util/_spec/graphql-client.spec.ts +++ b/src/mol-util/_spec/graphql-client.spec.ts @@ -1,7 +1,8 @@ /** * Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info. * - * @author OpenAI Codex + * @author Ludovic Autin + * @author Alexander Rose */ import { RuntimeContext } from '../../mol-task'; @@ -20,10 +21,10 @@ describe('graphql transport', () => { if (!Asset.isUrl(asset)) throw new Error('expected URL asset'); expect(asset.url).toBe('https://example.org/graphql'); - expect(asset.headers).toEqual([ - ['Content-Type', 'application/json; charset=utf-8'], - ['Accept', 'application/json'] - ]); + expect(asset.headers).toEqual({ + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json' + }); expect(asset.body).toContain('"query"'); expect(asset.body).toContain('"variables"'); @@ -55,10 +56,10 @@ describe('graphql transport', () => { url: 'https://example.org/graphql', type: 'json', body: '{"query":"{ test }"}', - headers: [ - ['Content-Type', 'application/json'], - ['Accept', 'application/json'] - ] + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } }).run(); expect(fetchSpy).toHaveBeenCalledWith('https://example.org/graphql', { diff --git a/src/mol-util/assets.ts b/src/mol-util/assets.ts index 947347494..2600d49ca 100644 --- a/src/mol-util/assets.ts +++ b/src/mol-util/assets.ts @@ -17,10 +17,10 @@ type _File = File; type Asset = Asset.Url | Asset.File namespace Asset { - export type Url = { kind: 'url', id: UUID, url: string, title?: string, body?: string, headers?: [string, string][] } + export type Url = { kind: 'url', id: UUID, url: string, title?: string, body?: string, headers?: Record } export type File = { kind: 'file', id: UUID, name: string, file?: _File } - export function Url(url: string, options?: { body?: string, title?: string, headers?: [string, string][] }): Url { + export function Url(url: string, options?: { body?: string, title?: string, headers?: Record }): Url { return { kind: 'url', id: UUID.create22(), url, ...options }; } @@ -54,7 +54,7 @@ namespace Asset { return typeof url === 'string' ? url : url.url; } - export function getUrlAsset(manager: AssetManager, url: string | Url, body?: string, headers?: [string, string][]) { + export function getUrlAsset(manager: AssetManager, url: string | Url, body?: string, headers?: Record) { if (typeof url === 'string') { const asset = manager.tryFindUrl(url, body, headers); return asset || Url(url, { body, headers }); @@ -63,13 +63,13 @@ namespace Asset { } } -function urlHeadersEqual(a?: [string, string][], b?: [string, string][]) { - const aLength = a?.length ?? 0; - const bLength = b?.length ?? 0; - if (aLength !== bLength) return false; +function urlHeadersEqual(a?: Record, b?: Record) { + const aKeys = a ? Object.keys(a) : []; + const bKeys = b ? Object.keys(b) : []; + if (aKeys.length !== bKeys.length) return false; - for (let i = 0; i < aLength; i++) { - if (a![i][0] !== b![i][0] || a![i][1] !== b![i][1]) return false; + for (const key of aKeys) { + if (a![key] !== b![key]) return false; } return true; } @@ -84,7 +84,7 @@ class AssetManager { return iterableToArray(this._assets.values()); } - tryFindUrl(url: string, body?: string, headers?: [string, string][]): Asset.Url | undefined { + tryFindUrl(url: string, body?: string, headers?: Record): Asset.Url | undefined { const assets = this.assets.values(); while (true) { const v = assets.next(); diff --git a/src/mol-util/data-source.ts b/src/mol-util/data-source.ts index 9cbb07bdb..a0206cd7c 100644 --- a/src/mol-util/data-source.ts +++ b/src/mol-util/data-source.ts @@ -34,7 +34,7 @@ export interface AjaxGetParams { url: string, type?: T, title?: string, - headers?: [string, string][], + headers?: Record, body?: string } @@ -250,7 +250,7 @@ function getRequestResponseType(type: DataType): XMLHttpRequestResponseType { } } -function ajaxGetInternal(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task> { +function ajaxGetInternal(title: string | undefined, url: string, type: T, body?: string, headers?: Record): Task> { if (RUNNING_IN_NODEJS) { if (url.startsWith('file://')) { return ajaxGetInternal_file_NodeJS(title, url, type, body, headers); @@ -265,7 +265,7 @@ function ajaxGetInternal(title: string | undefined, url: str xhttp.open(body ? 'post' : 'get', url, true); if (headers) { - for (const [name, value] of headers) { + for (const [name, value] of Object.entries(headers)) { xhttp.setRequestHeader(name, value); } } @@ -311,7 +311,7 @@ function readFileAsync(filename: string): Promise { } /** Alternative implementation of ajaxGetInternal for NodeJS for file:// protocol */ -function ajaxGetInternal_file_NodeJS(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task> { +function ajaxGetInternal_file_NodeJS(title: string | undefined, url: string, type: T, body?: string, headers?: Record): Task> { if (!RUNNING_IN_NODEJS) throw new Error('This function should only be used when running in Node.js'); if (!url.startsWith('file://')) throw new Error('This function is only for URLs with protocol file://'); @@ -327,7 +327,7 @@ function ajaxGetInternal_file_NodeJS(title: string | undefin } /** Alternative implementation of ajaxGetInternal for NodeJS for http(s):// protocol */ -function ajaxGetInternal_http_NodeJS(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task> { +function ajaxGetInternal_http_NodeJS(title: string | undefined, url: string, type: T, body?: string, headers?: Record): Task> { if (!RUNNING_IN_NODEJS) throw new Error('This function should only be used when running in Node.js'); const aborter = new AbortController(); @@ -337,7 +337,7 @@ function ajaxGetInternal_http_NodeJS(title: string | undefin signal: aborter.signal, method: body ? 'POST' : 'GET', body, - headers: headers ? Object.fromEntries(headers) : void 0 + headers }); if (!(response.status >= 200 && response.status < 400)) { throw new Error(`Download failed with status code ${response.status}`); diff --git a/src/mol-util/graphql-client.ts b/src/mol-util/graphql-client.ts index cc64a9c0b..c462c5f68 100644 --- a/src/mol-util/graphql-client.ts +++ b/src/mol-util/graphql-client.ts @@ -57,10 +57,10 @@ export class GraphQLClient { constructor(private url: string, private assetManager: AssetManager) { } async request(ctx: RuntimeContext, query: string, variables?: Variables): Promise> { - const headers: [string, string][] = [ - ['Content-Type', 'application/json; charset=utf-8'], - ['Accept', 'application/json'] - ]; + const headers: Record = { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json', + }; const body = JSON.stringify({ query, variables }, null, 2); const url = Asset.getUrlAsset(this.assetManager, this.url, body, headers); const result = await this.assetManager.resolve(url, 'json').runInContext(ctx); From 25836b2de0b7ca902b0fc659f4734ed76ec443e4 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sat, 25 Apr 2026 07:50:28 -0700 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17651501d..489b538a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Note that since we don't clearly distinguish between a public and private interf - Handle CCD bonds with Deuterium atoms - [Breaking] ComponentBond.Entry.map now returns ComponentBond.Pairs - Fix volume slice marking performance regression +- Fix `GraphQLClient` missing required headers ## [v5.8.0] - 2026-04-03 - Dependencies: remove `utils.promisify`, `node-fetch` (#1797) From 93943cc27b6d18f7859a96b5c40e0c1a12d4facf Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sat, 25 Apr 2026 07:55:08 -0700 Subject: [PATCH 4/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 489b538a9..fe172e8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that since we don't clearly distinguish between a public and private interf - [Breaking] ComponentBond.Entry.map now returns ComponentBond.Pairs - Fix volume slice marking performance regression - Fix `GraphQLClient` missing required headers +- [Breaking] Use Record instead of Array for headers (assets & data-source utils) ## [v5.8.0] - 2026-04-03 - Dependencies: remove `utils.promisify`, `node-fetch` (#1797)