Merge pull request #1818 from corredD/codex/fix-assembly-symmetry

Fix GraphQL POST request handling for Assembly Symmetry
This commit is contained in:
Alexander Rose
2026-04-25 07:57:40 -07:00
committed by GitHub
6 changed files with 121 additions and 22 deletions

View File

@@ -10,6 +10,8 @@ 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
- [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)

View File

@@ -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);
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Ludovic Autin <ludovic.autin@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
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();
});
});

View File

@@ -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 <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -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<string, string> }
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<string, string> }): Url {
return { kind: 'url', id: UUID.create22(), url, ...options };
}
@@ -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?: Record<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?: Record<string, string>, b?: Record<string, string>) {
const aKeys = a ? Object.keys(a) : [];
const bKeys = b ? Object.keys(b) : [];
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (a![key] !== b![key]) 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?: Record<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();
}
}
}

View File

@@ -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 <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -34,7 +34,7 @@ export interface AjaxGetParams<T extends DataType = 'string'> {
url: string,
type?: T,
title?: string,
headers?: [string, string][],
headers?: Record<string, string>,
body?: string
}
@@ -250,7 +250,7 @@ function getRequestResponseType(type: DataType): XMLHttpRequestResponseType {
}
}
function ajaxGetInternal<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task<DataResponse<T>> {
function ajaxGetInternal<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: Record<string, string>): Task<DataResponse<T>> {
if (RUNNING_IN_NODEJS) {
if (url.startsWith('file://')) {
return ajaxGetInternal_file_NodeJS(title, url, type, body, headers);
@@ -265,7 +265,7 @@ function ajaxGetInternal<T extends DataType>(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<NonSharedBuffer> {
}
/** Alternative implementation of ajaxGetInternal for NodeJS for file:// protocol */
function ajaxGetInternal_file_NodeJS<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task<DataResponse<T>> {
function ajaxGetInternal_file_NodeJS<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: Record<string, string>): Task<DataResponse<T>> {
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,13 +327,18 @@ function ajaxGetInternal_file_NodeJS<T extends DataType>(title: string | undefin
}
/** Alternative implementation of ajaxGetInternal for NodeJS for http(s):// protocol */
function ajaxGetInternal_http_NodeJS<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task<DataResponse<T>> {
function ajaxGetInternal_http_NodeJS<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: Record<string, string>): Task<DataResponse<T>> {
if (!RUNNING_IN_NODEJS) throw new Error('This function should only be used when running in Node.js');
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
});
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<Asset.Wrapper<'
} catch (error) {
return { kind: 'error', error, index, id };
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2020 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 Alexander Rose <alexander.rose@weirdbyte.de>
*
@@ -57,9 +57,12 @@ export class GraphQLClient {
constructor(private url: string, private assetManager: AssetManager) { }
async request(ctx: RuntimeContext, query: string, variables?: Variables): Promise<Asset.Wrapper<'json'>> {
const headers: Record<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 });
}
}
}
}