mirror of
https://github.com/molstar/molstar.git
synced 2026-06-04 13:30:24 +08:00
Merge pull request #1818 from corredD/codex/fix-assembly-symmetry
Fix GraphQL POST request handling for Assembly Symmetry
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
78
src/mol-util/_spec/graphql-client.spec.ts
Normal file
78
src/mol-util/_spec/graphql-client.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user