From 9a7266fcc257181d6b7a5a32586d8900c84b3e58 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Wed, 14 Oct 2026 02:40:26 +0300 Subject: [PATCH] Separate `SparqlDataProvider` HTTP query source into separate `HttpSparqlQuerySource`: * Deprecate `endpointUrl`, `queryMethod`, `queryFunction` options for `SparqlDataProvider`, move them into explicitly provided `HttpSparqlQuerySource` via `source` option; --- src/data/sparql/responseHandler.ts | 86 ++++---- src/data/sparql/sparqlDataProvider.ts | 292 +++++++------------------ src/data/sparql/sparqlModels.ts | 74 ------- src/data/sparql/sparqlQuerySource.ts | 301 ++++++++++++++++++++++++++ src/workspace.ts | 5 +- test/mock/sparqlMocks.ts | 58 +++-- 6 files changed, 453 insertions(+), 363 deletions(-) create mode 100644 src/data/sparql/sparqlQuerySource.ts diff --git a/src/data/sparql/responseHandler.ts b/src/data/sparql/responseHandler.ts index 477ba3cc..d50fe151 100644 --- a/src/data/sparql/responseHandler.ts +++ b/src/data/sparql/responseHandler.ts @@ -4,14 +4,14 @@ import { multimapAdd } from '../../coreUtils/collections'; import * as Rdf from '../rdf/rdfModel'; import { - LinkTypeModel, ElementTypeGraph, ElementModel, LinkModel, + LinkTypeModel, ElementModel, LinkModel, SubtypeEdge, ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri, - hashSubtypeEdge, equalSubtypeEdges, equalLinks, hashLink + hashSubtypeEdge, equalSubtypeEdges, equalLinks, hashLink, } from '../model'; import type { DataProviderLinkCount, DataProviderLookupItem } from '../dataProvider'; import { LinkConfiguration, PropertyConfiguration } from './sparqlDataProviderSettings'; import { - SparqlResponse, ClassBinding, ElementBinding, LinkBinding, ElementImageBinding, LinkCountBinding, + ClassBinding, ElementBinding, LinkBinding, ElementImageBinding, LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, PropertyBinding, ElementTypeBinding, FilterBinding, isRdfIri, isRdfBlank, isRdfLiteral, } from './sparqlModels'; @@ -28,11 +28,16 @@ export interface MutableClassModel { count?: number; } -export function getClassTree(response: SparqlResponse): ElementTypeGraph { +interface MutableElementTypeGraph { + elementTypes: MutableClassModel[]; + subtypeOf: SubtypeEdge[]; +} + +export function getClassTree(bindings: readonly ClassBinding[]): MutableElementTypeGraph { const nodes = new Map(); const edges = new HashSet(hashSubtypeEdge, equalSubtypeEdges); - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!isRdfIri(binding.class)) { continue; } const classIri: ElementTypeIri = binding.class.value; @@ -53,7 +58,7 @@ export function getClassTree(response: SparqlResponse): ElementTyp } // ensuring parent will always be there - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (binding.parent) { const parentIri: ElementTypeIri = binding.parent.value; if (!nodes.has(parentIri)) { @@ -77,10 +82,10 @@ export function getClassTree(response: SparqlResponse): ElementTyp } export function collectClassInfo( - response: SparqlResponse, + bindings: readonly ClassBinding[], result: Map ): void { - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!binding.class) { continue; } const id: ElementTypeIri = binding.class.value; const model = result.get(id); @@ -104,16 +109,16 @@ export function collectClassInfo( } } -export interface MutablePropertyModel { +export interface MutablePropertyType { readonly id: PropertyTypeIri; label: Rdf.Literal[]; } -export function collectPropertyInfo( - response: SparqlResponse, - result: Map +export function collectPropertyTypes( + bindings: readonly PropertyBinding[], + result: Map ): void { - for (const binding of response.results.bindings) { + for (const binding of bindings) { const propertyTypeId: PropertyTypeIri = binding.property.value; const existing = result.get(propertyTypeId); if (existing) { @@ -127,6 +132,14 @@ export function collectPropertyInfo( } } +export function getPropertyTypes( + bindings: readonly PropertyBinding[] +): Map { + const result = new Map(); + collectPropertyTypes(bindings, result); + return result; +} + export interface MutableLinkType { readonly id: LinkTypeIri; label: Rdf.Literal[]; @@ -134,10 +147,10 @@ export interface MutableLinkType { } export function collectLinkTypes( - response: SparqlResponse, + bindings: readonly LinkTypeBinding[], result: Map ): void { - for (const binding of response.results.bindings) { + for (const binding of bindings) { const linkTypeId: LinkTypeIri = binding.link.value; const existing = result.get(linkTypeId); if (existing) { @@ -149,25 +162,18 @@ export function collectLinkTypes( } export function getLinkTypes( - response: SparqlResponse + bindings: readonly LinkTypeBinding[] ): Map { const result = new Map(); - collectLinkTypes(response, result); + collectLinkTypes(bindings, result); return result; } export function triplesToElementBinding( triples: ReadonlyArray, -): SparqlResponse { +): ElementBinding[] { const elements = new Map(); - const convertedResponse: SparqlResponse = { - head: { - vars: ['inst', 'class', 'label', 'blankType', 'propType', 'propValue'], - }, - results: { - bindings: [], - }, - }; + const bindings: ElementBinding[] = []; for (const t of triples) { if (!isRdfIri(t.subject)) { continue; @@ -206,11 +212,11 @@ export function triplesToElementBinding( const binding: ElementBinding = { inst: (quad.subject as Rdf.NamedNode), }; - convertedResponse.results.bindings.push(binding); + bindings.push(binding); return binding; } - return convertedResponse; + return bindings; } interface MutableElementModel { @@ -220,7 +226,7 @@ interface MutableElementModel { } export function getElementsInfo( - response: SparqlResponse, + bindings: readonly ElementBinding[], // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment types: ReadonlyMap> = EMPTY_MAP, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -230,7 +236,7 @@ export function getElementsInfo( ): Map { const instances = new Map(); - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!isRdfIri(binding.inst)) { continue; } const iri: ElementIri = binding.inst.value; let model = instances.get(iri); @@ -281,11 +287,11 @@ function mapPropertiesByConfig( } export function enrichElementsWithImages( - response: SparqlResponse, + bindings: readonly ElementImageBinding[], elements: Map, imagePropertyIri: PropertyTypeIri ): void { - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!isRdfIri(binding.inst)) { continue; } @@ -301,10 +307,10 @@ export function enrichElementsWithImages( } export function collectElementTypes( - response: SparqlResponse, + bindings: readonly ElementTypeBinding[], result: Map> ): void { - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (isRdfIri(binding.inst) && isRdfIri(binding.class)) { const element: ElementIri = binding.inst.value; const type: ElementTypeIri = binding.class.value; @@ -372,7 +378,7 @@ export interface ConnectedLinkType { } export function getConnectedLinkTypes( - response: SparqlResponse, + bindings: readonly ConnectedLinkTypeBinding[], // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment linkByPredicateType: ReadonlyMap = EMPTY_MAP, openWorldLinks: boolean = true @@ -392,7 +398,7 @@ export function getConnectedLinkTypes( } } }; - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!isRdfIri(binding.link)) { continue; } @@ -411,9 +417,9 @@ export function getConnectedLinkTypes( } export function getLinkStatistics( - response: SparqlResponse + bindings: readonly LinkCountBinding[] ): DataProviderLinkCount | undefined { - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (isRdfIri(binding.link)) { return getLinkCount(binding); } @@ -422,7 +428,7 @@ export function getLinkStatistics( } export function getFilteredData( - response: SparqlResponse, + bindings: readonly (ElementBinding & FilterBinding)[], sourceTypes: ReadonlySet | undefined, linkByPredicateType: ReadonlyMap | undefined, labelPredicate: PropertyTypeIri, @@ -435,7 +441,7 @@ export function getFilteredData( const outPredicates = new Map>(); const inPredicates = new Map>(); - for (const binding of response.results.bindings) { + for (const binding of bindings) { if (!isRdfIri(binding.inst) && !isRdfBlank(binding.inst)) { continue; } diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index 51e4fb93..7ee9b73b 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -1,5 +1,3 @@ -import * as N3 from 'n3'; - import { multimapArrayAdd } from '../../coreUtils/collections'; import * as Rdf from '../rdf/rdfModel'; import { rdfs, schema } from '../rdf/vocabulary'; @@ -15,32 +13,35 @@ import { chunkArray, chunkUndirectedCrossProduct } from './requestChunking'; import { MutableClassModel, MutableLinkType, - MutablePropertyModel, + MutablePropertyType, appendProperty, collectClassInfo, collectElementTypes, collectLinkTypes, - collectPropertyInfo, + collectPropertyTypes, enrichElementsWithImages, getClassTree, - getElementsInfo, - getLinkTypes, - getLinksInfo, getConnectedLinkTypes, + getElementsInfo, getFilteredData, + getLinksInfo, + getLinkTypes, getLinkStatistics, + getPropertyTypes, triplesToElementBinding, isDirectLink, isDirectProperty, } from './responseHandler'; import { ClassBinding, ElementBinding, ElementTypeBinding, LinkBinding, PropertyBinding, FilterBinding, - LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, ElementImageBinding, SparqlResponse, - mapSparqlResponseIntoRdfJs, + LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, ElementImageBinding, } from './sparqlModels'; import { SparqlDataProviderSettings, OwlStatsSettings, LinkConfiguration, PropertyConfiguration, } from './sparqlDataProviderSettings'; +import { + SparqlQuerySource, HttpSparqlQuerySource, HttpSparqlQuerySourceOptions, +} from './sparqlQuerySource'; /** * Options for {@link SparqlDataProvider}. @@ -54,10 +55,17 @@ export interface SparqlDataProviderOptions { */ factory?: Rdf.DataFactory; + /** + * Source to get data from by a SPARQL query. + */ + source?: SparqlQuerySource; + /** * SPARQL endpoint URL to send queries to. + * + * @deprecated Pass {@link HttpSparqlQuerySource} to {@link source} instead. */ - endpointUrl: string; + endpointUrl?: HttpSparqlQuerySourceOptions['endpointUrl']; /** * Query method for SPARQL queries to use: @@ -65,8 +73,9 @@ export interface SparqlDataProviderOptions { * - `POST` - less compatible, better on large data sets. * * @default "GET" + * @deprecated Pass {@link HttpSparqlQuerySource} to {@link source} instead. */ - queryMethod?: 'GET' | 'POST'; + queryMethod?: HttpSparqlQuerySourceOptions['queryMethod']; /** * Custom function to send SPARQL requests. @@ -81,8 +90,10 @@ export interface SparqlDataProviderOptions { * cache: 'default', * } * ``` + * + * @deprecated Pass {@link HttpSparqlQuerySource} to {@link source} instead. */ - queryFunction?: SparqlQueryFunction; + queryFunction?: HttpSparqlQuerySourceOptions['queryFunction']; /** * Chunk size to split each SPARQL request with multiple input IRIs into. @@ -135,17 +146,6 @@ export interface SparqlDataProviderOptions { prepareLabelPredicate?: PropertyTypeIri; } -/** - * Custom function to send SPARQL HTTP requests. - */ -export type SparqlQueryFunction = (params: { - url: string; - body?: string; - headers: { [header: string]: string }; - method: string; - signal?: AbortSignal; -}) => Promise; - /** * Options for request chunking in {@link SparqlDataProvider}. * @@ -170,15 +170,16 @@ export interface SparqlProviderChunkOptions { } /** - * Provides graph data by requesting it from a SPARQL endpoint. + * Provides graph data by requesting it using SPARQL queries from a source + * (e.g. a SPARQL HTTP endpoint). * * @category Data */ export class SparqlDataProvider implements DataProvider { readonly factory: Rdf.DataFactory; + private readonly source: SparqlQuerySource; private readonly options: SparqlDataProviderOptions; private readonly settings: SparqlDataProviderSettings; - private readonly queryFunction: SparqlQueryFunction; private readonly acceptBlankNodes = false; private readonly chunkMeasure: ChunkMeasure; @@ -198,19 +199,27 @@ export class SparqlDataProvider implements DataProvider { ) { const { factory = Rdf.DefaultDataFactory, - queryFunction = queryInternal, + source, + endpointUrl, + queryMethod, + queryFunction, + chunk, + prepareLabelPredicate, + prepareImagePredicate, } = options; + + if (!(source || endpointUrl)) { + throw new Error('Either "source" or "endpointUrl" is required'); + } + this.factory = factory; + this.source = source ?? new HttpSparqlQuerySource({ + endpointUrl: endpointUrl ?? '', + queryMethod, + queryFunction, + }); this.options = options; this.settings = settings; - this.queryFunction = queryFunction; - - const { - chunk, - queryMethod = 'GET', - prepareLabelPredicate, - prepareImagePredicate, - } = this.options; if (chunk) { if (!Number.isSafeInteger(chunk.maxSize)) { @@ -281,8 +290,8 @@ export class SparqlDataProvider implements DataProvider { schemaLabelProperty, labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), }); - const result = await this.executeSparqlSelect(query, {signal}); - const classTree = getClassTree(result); + const response = await this.executeSparqlSelect(query, {signal}); + const classTree = getClassTree(response.results.bindings); if (this.options.prepareLabels) { await attachLabels(classTree.elementTypes, this.options.prepareLabels, signal); @@ -298,7 +307,7 @@ export class SparqlDataProvider implements DataProvider { const {propertyIds, signal} = params; const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, propertyInfoQuery} = this.settings; - const properties = new Map(); + const properties = new Map(); if (propertyInfoQuery) { await this.queryChunked(propertyIds, async batch => { const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' '); @@ -308,7 +317,7 @@ export class SparqlDataProvider implements DataProvider { labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), }); const response = await this.executeSparqlSelect(query, {signal}); - collectPropertyInfo(response, properties); + collectPropertyTypes(response.results.bindings, properties); }); } else { for (const id of propertyIds) { @@ -340,7 +349,7 @@ export class SparqlDataProvider implements DataProvider { labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), }); const response = await this.executeSparqlSelect(query, {signal}); - collectClassInfo(response, classes); + collectClassInfo(response.results.bindings, classes); }); } else { for (const classId of classIds) { @@ -372,7 +381,7 @@ export class SparqlDataProvider implements DataProvider { labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), }); const response = await this.executeSparqlSelect(query, {signal}); - collectLinkTypes(response, linkTypes); + collectLinkTypes(response.results.bindings, linkTypes); }); } else { for (const typeId of linkTypeIds) { @@ -403,8 +412,8 @@ export class SparqlDataProvider implements DataProvider { schemaLabelProperty, labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), }); - const result = await this.executeSparqlSelect(query, {signal}); - const linkTypes = getLinkTypes(result); + const response = await this.executeSparqlSelect(query, {signal}); + const linkTypes = getLinkTypes(response.results.bindings); if (this.options.prepareLabels) { await attachLabels(Array.from(linkTypes.values()), this.options.prepareLabels, signal); @@ -494,8 +503,8 @@ export class SparqlDataProvider implements DataProvider { } `; try { - const bindings = await this.executeSparqlSelect(query, {signal}); - enrichElementsWithImages(bindings, elements, this.imagePredicate); + const response = await this.executeSparqlSelect(query, {signal}); + enrichElementsWithImages(response.results.bindings, elements, this.imagePredicate); } catch (err) { console.warn('Failed to load entity image URLs', err); } @@ -654,10 +663,9 @@ export class SparqlDataProvider implements DataProvider { linkConfigurations: forAll.unionParts.join('\nUNION\n'), }); - const linkTypeBindings = await this.executeSparqlSelect(query, {signal}); - const hasConnectedDirection = linkTypeBindings.head.vars.includes('direction'); + const linkTypeResponse = await this.executeSparqlSelect(query, {signal}); const connectedLinkTypes = getConnectedLinkTypes( - linkTypeBindings, + linkTypeResponse.results.bindings, this.linkByPredicate, this.openWorldLinks ); @@ -696,7 +704,7 @@ export class SparqlDataProvider implements DataProvider { linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`; } - if (!linkTypesStatisticsQuery || (inexactCount && hasConnectedDirection)) { + if (!linkTypesStatisticsQuery || (inexactCount && bindDirection)) { foundLinkStats.push({ id: linkType, inCount: hasInLink ? 1 : 0, @@ -712,8 +720,8 @@ export class SparqlDataProvider implements DataProvider { navigateElementFilterOut, navigateElementFilterIn, }); - const bindings = await this.executeSparqlSelect(statsQuery, {signal}); - const linkStats = getLinkStatistics(bindings); + const statsResponse = await this.executeSparqlSelect(statsQuery, {signal}); + const linkStats = getLinkStatistics(statsResponse.results.bindings); if (linkStats) { foundLinkStats.push(linkStats); } @@ -737,10 +745,10 @@ export class SparqlDataProvider implements DataProvider { ); const filterQuery = this.createFilterQuery(params); - const bindings = await this.executeSparqlSelect(filterQuery, {signal}); + const response = await this.executeSparqlSelect(filterQuery, {signal}); const linkedElements = getFilteredData( - bindings, + response.results.bindings, await types, this.linkByPredicate, this.labelPredicate, @@ -838,20 +846,20 @@ export class SparqlDataProvider implements DataProvider { /** * Executes arbitrary SPARQL SELECT query and returns the result tuples. */ - executeSparqlSelect( + async executeSparqlSelect( query: string, options?: { signal?: AbortSignal } - ): Promise> { - const method = this.options.queryMethod ?? 'GET'; - const {signal} = options ?? {}; - return executeSparqlQuery( - this.options.endpointUrl, + ): Promise<{ results: { bindings: Binding[] } }> { + const bindings = await this.source.sparqlSelect({ query, - method, - this.queryFunction, - this.factory, - signal - ); + factory: this.factory, + signal: options?.signal, + }); + return { + results: { + bindings: bindings as Binding[], + }, + }; } /** @@ -861,15 +869,11 @@ export class SparqlDataProvider implements DataProvider { query: string, options?: { signal?: AbortSignal } ): Promise { - const method = this.options.queryMethod ?? 'GET'; - const {signal} = options ?? {}; - return executeSparqlConstruct( - this.options.endpointUrl, + return this.source.sparqlConstruct({ query, - method, - this.queryFunction, - signal, - ); + factory: this.factory, + signal: options?.signal, + }); } protected createRefQueryPart(params: { elementId: ElementIri; linkId?: LinkTypeIri; direction?: 'in' | 'out' }) { @@ -1039,7 +1043,7 @@ export class SparqlDataProvider implements DataProvider { const queryTemplate = 'SELECT ?inst ?class { VALUES(?inst) { ${ids} } ${filterTypePattern} }'; const query = resolveTemplate(queryTemplate, {ids, filterTypePattern}); const response = await this.executeSparqlSelect(query, {signal}); - collectElementTypes(response, elementTypes); + collectElementTypes(response.results.bindings, elementTypes); }); return elementTypes; } @@ -1132,150 +1136,6 @@ function resolveTemplate(template: string, values: { [key: string]: string | und return result; } -async function executeSparqlQuery( - endpoint: string, - query: string, - method: 'GET' | 'POST', - queryFunction: SparqlQueryFunction, - factory: Rdf.DataFactory, - signal: AbortSignal | undefined -): Promise> { - let internalQuery: Promise; - if (method === 'GET') { - internalQuery = queryFunction({ - url: appendQueryParams(endpoint, {query}), - headers: { - 'Accept': 'application/sparql-results+json', - }, - method: 'GET', - signal, - }); - } else { - internalQuery = queryFunction({ - url: endpoint, - body: query, - headers: { - 'Accept': 'application/sparql-results+json', - 'Content-Type': 'application/sparql-query; charset=UTF-8', - }, - method: 'POST', - signal, - }); - } - const response = await internalQuery; - if (response.ok) { - const sparqlResponse = await response.json() as SparqlResponse; - return mapSparqlResponseIntoRdfJs(sparqlResponse, factory); - } else { - throw await SparqlResponseError.fromResponse(response); - } -} - -async function executeSparqlConstruct( - endpoint: string, - query: string, - method: 'GET' | 'POST', - queryFunction: SparqlQueryFunction, - signal: AbortSignal | undefined -): Promise { - let internalQuery: Promise; - if (method === 'GET') { - internalQuery = queryFunction({ - url: appendQueryParams(endpoint, {query}), - headers: { - 'Accept': 'text/turtle', - }, - method: 'GET', - signal, - }); - } else { - internalQuery = queryFunction({ - url: endpoint, - body: query, - headers: { - 'Accept': 'text/turtle', - 'Content-Type': 'application/sparql-query; charset=UTF-8', - }, - method: 'POST', - signal, - }); - } - const response = await internalQuery; - if (response.ok) { - const turtleText = await response.text(); - const parser = new N3.Parser(); - return parser.parse(turtleText); - } else { - throw await SparqlResponseError.fromResponse(response); - } -} - -/** - * SPARQL response error originated from an endpoint. - */ -export class SparqlResponseError extends Error { - /** - * SPARQL endpoint response with an error result. - */ - readonly response: Response; - - constructor( - message: string, - response: Response, - options?: ErrorOptions - ) { - super(message, options); - this.response = response; - } - - /** - * Makes an error for a non-OK response with the HTTP status and the beginning - * of the response body in the message, as an endpoint usually explains - * a rejected query there (e.g. a syntax or a query cost estimation error). - */ - static async fromResponse(response: Response): Promise { - let details = ''; - try { - const body = (await response.text()).trim(); - const maxLength = 500; - details = body.length > maxLength ? body.substring(0, maxLength) + '…' : body; - } catch (e) { - /* ignore */ - } - const status = `HTTP ${response.status} ${response.statusText}`.trim(); - return new SparqlResponseError( - details ? `${status}: ${details}` : status, - response - ); - } -} - -function appendQueryParams(endpoint: string, queryParams: { [key: string]: string } = {}) { - const initialSeparator = endpoint.indexOf('?') < 0 ? '?' : '&'; - const additionalParams = initialSeparator + Object.keys(queryParams) - .map(key => `${key}=${encodeURIComponent(queryParams[key])}`) - .join('&'); - return endpoint + additionalParams; -} - -function queryInternal(params: { - url: string; - body?: string; - headers: { [header: string]: string }; - method: string; - signal?: AbortSignal; -}) { - return fetch(params.url, { - method: params.method, - body: params.body, - credentials: 'same-origin', - mode: 'cors', - cache: 'default', - headers: params.headers, - signal: params.signal, - }); -} - function formatLanguageFilter( variable: string, languages: ReadonlyArray | undefined diff --git a/src/data/sparql/sparqlModels.ts b/src/data/sparql/sparqlModels.ts index 249a5ea5..3007f704 100644 --- a/src/data/sparql/sparqlModels.ts +++ b/src/data/sparql/sparqlModels.ts @@ -1,79 +1,5 @@ import * as Rdf from '../rdf/rdfModel'; -type SparqlTerm = SparqlIri | SparqlLiteral | SparqlBlank; - -interface SparqlIri { - type: 'uri'; - value: string; -} - -interface SparqlBlank { - type: 'bnode'; - value: string; -} - -interface SparqlLiteral { - type: 'literal' | 'typed-literal'; - value: string; - datatype?: string; - 'xml:lang': string; -} - -export interface SparqlResponse { - head: { - vars: string[]; - }; - results: { - bindings: Binding[]; - }; -} - -export function mapSparqlResponseIntoRdfJs( - response: SparqlResponse, - factory: Rdf.DataFactory -): SparqlResponse { - function mapSparqlBinding(binding: { [name: string]: SparqlTerm }): { [name: string]: Rdf.Term } { - const mapped: { [name: string]: Rdf.Term } = {}; - for (const key in binding) { - if (Object.prototype.hasOwnProperty.call(binding, key)) { - const term = binding[key]; - switch (term.type) { - case 'uri': { - mapped[key] = factory.namedNode(term.value); - break; - } - case 'bnode': { - mapped[key] = factory.blankNode(term.value); - break; - } - case 'literal': - case 'typed-literal': { - mapped[key] = factory.literal( - term.value, - term['xml:lang'] ? term['xml:lang'] : - term.datatype ? factory.namedNode(term.datatype) : - undefined - ); - break; - } - default: { - throw new Error(`Unexpected SPARQL term type: "${(term as SparqlTerm).type}"`); - } - } - } - } - return mapped; - } - - return { - ...response, - results: { - ...response.results, - bindings: response.results.bindings.map(mapSparqlBinding), - }, - }; -} - export function isRdfIri(term: Rdf.Term | undefined): term is Rdf.NamedNode { return Boolean(term && term.termType === 'NamedNode'); } diff --git a/src/data/sparql/sparqlQuerySource.ts b/src/data/sparql/sparqlQuerySource.ts new file mode 100644 index 00000000..72e077e3 --- /dev/null +++ b/src/data/sparql/sparqlQuerySource.ts @@ -0,0 +1,301 @@ +import * as N3 from 'n3'; + +import * as Rdf from '../rdf/rdfModel'; + +/** + * Provides a source to get data from by a SPARQL query. + * + * @category Data + */ +export interface SparqlQuerySource { + /** + * Gets data by a [SPARQL SELECT](https://www.w3.org/TR/sparql11-query/#select) query. + */ + sparqlSelect(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise[]>; + + /** + * Gets data by a [SPARQL CONSTRUCT](https://www.w3.org/TR/sparql11-query/#construct) query. + */ + sparqlConstruct(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise; +} + +/** + * Options for {@link HttpSparqlQuerySource}. + */ +export interface HttpSparqlQuerySourceOptions { + /** + * SPARQL endpoint URL to send queries to. + */ + endpointUrl: string; + + /** + * Query method for SPARQL queries to use: + * - `GET` - more compatible, may have issues with large request URLs; + * - `POST` - less compatible, better on large data sets. + * + * @default "GET" + */ + queryMethod?: 'GET' | 'POST'; + + /** + * Custom function to send SPARQL requests. + * + * By default, a global [fetch()](https://developer.mozilla.org/en-US/docs/Web/API/fetch) + * is used with the following options: + * ```js + * { + * ...params, + * credentials: 'same-origin', + * mode: 'cors', + * cache: 'default', + * } + * ``` + */ + queryFunction?: SparqlQueryFunction; +} + +/** + * Custom function to send SPARQL HTTP requests. + * + * @see {@link HttpSparqlQuerySourceOptions.queryFunction} + */ +export type SparqlQueryFunction = (params: { + url: string; + body?: string; + headers: { [header: string]: string }; + method: string; + signal?: AbortSignal; +}) => Promise; + +/** + * Provides a query source over a SPARQL HTTP endpoint. + */ +export class HttpSparqlQuerySource implements SparqlQuerySource { + private readonly options: Required; + + constructor(options: HttpSparqlQuerySourceOptions) { + this.options = { + endpointUrl: options.endpointUrl, + queryMethod: options.queryMethod ?? 'GET', + queryFunction: options.queryFunction ?? queryInternal, + }; + } + + async sparqlSelect(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise[]> { + const {query, factory, signal} = params; + const {endpointUrl, queryMethod, queryFunction} = this.options; + let internalQuery: Promise; + if (queryMethod === 'GET') { + internalQuery = queryFunction({ + url: appendQueryParams(endpointUrl, {query}), + headers: { + 'Accept': 'application/sparql-results+json', + }, + method: 'GET', + signal, + }); + } else { + internalQuery = queryFunction({ + url: endpointUrl, + body: query, + headers: { + 'Accept': 'application/sparql-results+json', + 'Content-Type': 'application/sparql-query; charset=UTF-8', + }, + method: 'POST', + signal, + }); + } + const response = await internalQuery; + if (response.ok) { + const sparqlResponse = await response.json() as SparqlHttpResponse; + return sparqlResponse.results.bindings + .map(binding => mapSparqlBinding(binding, factory)); + } else { + throw await SparqlResponseError.fromResponse(response); + } + } + + async sparqlConstruct(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise { + const {query, factory, signal} = params; + const {endpointUrl, queryMethod, queryFunction} = this.options; + let internalQuery: Promise; + if (queryMethod === 'GET') { + internalQuery = queryFunction({ + url: appendQueryParams(endpointUrl, {query}), + headers: { + 'Accept': 'text/turtle', + }, + method: 'GET', + signal, + }); + } else { + internalQuery = queryFunction({ + url: endpointUrl, + body: query, + headers: { + 'Accept': 'text/turtle', + 'Content-Type': 'application/sparql-query; charset=UTF-8', + }, + method: 'POST', + signal, + }); + } + const response = await internalQuery; + if (response.ok) { + const turtleText = await response.text(); + const parser = new N3.Parser(); + return parser.parse(turtleText); + } else { + throw await SparqlResponseError.fromResponse(response); + } + } +} + +/** + * SPARQL error originated from an HTTP response. + */ +export class SparqlResponseError extends Error { + /** + * SPARQL endpoint response with an error result. + */ + readonly response: Response; + + constructor( + message: string, + response: Response, + options?: ErrorOptions + ) { + super(message, options); + this.response = response; + } + + /** + * Makes an error for a non-OK response with the HTTP status and the beginning + * of the response body in the message, as an endpoint usually explains + * a rejected query there (e.g. a syntax or a query cost estimation error). + */ + static async fromResponse(response: Response): Promise { + let details = ''; + try { + const body = (await response.text()).trim(); + const maxLength = 500; + details = body.length > maxLength ? body.substring(0, maxLength) + '…' : body; + } catch (e) { + /* ignore */ + } + const status = `HTTP ${response.status} ${response.statusText}`.trim(); + return new SparqlResponseError( + details ? `${status}: ${details}` : status, + response + ); + } +} + +function appendQueryParams(endpoint: string, queryParams: { [key: string]: string } = {}) { + const initialSeparator = endpoint.indexOf('?') < 0 ? '?' : '&'; + const additionalParams = initialSeparator + Object.keys(queryParams) + .map(key => `${key}=${encodeURIComponent(queryParams[key])}`) + .join('&'); + return endpoint + additionalParams; +} + +function queryInternal(params: { + url: string; + body?: string; + headers: { [header: string]: string }; + method: string; + signal?: AbortSignal; +}) { + return fetch(params.url, { + method: params.method, + body: params.body, + credentials: 'same-origin', + mode: 'cors', + cache: 'default', + headers: params.headers, + signal: params.signal, + }); +} + +type SparqlHttpTerm = SparqlIri | SparqlLiteral | SparqlBlank; + +interface SparqlIri { + type: 'uri'; + value: string; +} + +interface SparqlBlank { + type: 'bnode'; + value: string; +} + +interface SparqlLiteral { + type: 'literal' | 'typed-literal'; + value: string; + datatype?: string; + 'xml:lang': string; +} + +interface SparqlHttpResponse { + head: { + vars: string[]; + }; + results: { + bindings: Record[]; + }; +} + +function mapSparqlBinding( + binding: Record, + factory: Rdf.DataFactory, +): Record { + const mapped: { [name: string]: Rdf.Term } = {}; + for (const key in binding) { + if (Object.prototype.hasOwnProperty.call(binding, key)) { + const term = binding[key]; + switch (term.type) { + case 'uri': { + mapped[key] = factory.namedNode(term.value); + break; + } + case 'bnode': { + mapped[key] = factory.blankNode(term.value); + break; + } + case 'literal': + case 'typed-literal': { + mapped[key] = factory.literal( + term.value, + term['xml:lang'] ? term['xml:lang'] : + term.datatype ? factory.namedNode(term.datatype) : + undefined + ); + break; + } + default: { + throw new Error( + `Unexpected SPARQL term type: "${(term as SparqlHttpTerm).type}"` + ); + } + } + } + } + return mapped; +} diff --git a/src/workspace.ts b/src/workspace.ts index 9bb029b2..0c9fe6cb 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -66,9 +66,12 @@ export * as Rdf from './data/rdf/rdfModel'; export * from './data/rdf/vocabulary'; export { SparqlDataProvider, type SparqlDataProviderOptions, type SparqlProviderChunkOptions, - type SparqlQueryFunction, SparqlResponseError, } from './data/sparql/sparqlDataProvider'; export * from './data/sparql/sparqlDataProviderSettings'; +export { + type SparqlQuerySource, HttpSparqlQuerySource, type HttpSparqlQuerySourceOptions, + type SparqlQueryFunction, SparqlResponseError, +} from './data/sparql/sparqlQuerySource'; export { type CanvasApi, type CanvasMetrics, type CanvasPaneMetrics, type CanvasEvents, diff --git a/test/mock/sparqlMocks.ts b/test/mock/sparqlMocks.ts index 2619c6f1..e81a5c34 100644 --- a/test/mock/sparqlMocks.ts +++ b/test/mock/sparqlMocks.ts @@ -10,7 +10,9 @@ import { import { type SparqlDataProviderSettings, } from '../../src/data/sparql/sparqlDataProviderSettings'; -import { SparqlResponse } from '../../src/data/sparql/sparqlModels'; +import { + SparqlQuerySource, +} from '../../src/data/sparql/sparqlQuerySource'; import TURTLE_DATA from '../../examples/resources/orgOntology.ttl?raw'; @@ -42,44 +44,41 @@ export const test = { image: `${NAMESPACE_TEST}image`, } as const; -class InMemorySparqlProvider extends SparqlDataProvider { +class CommunicaQuerySource implements SparqlQuerySource { constructor( - options: SparqlDataProviderOptions, - settings: SparqlDataProviderSettings | undefined, private readonly store: N3.Store, private readonly engine: QueryEngine, - ) { - super(options, settings); - } + ) {} - async executeSparqlSelect( - query: string, - options?: { signal?: AbortSignal; } - ): Promise> { + async sparqlSelect(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise[]> { + const {query, signal} = params; const stream = await this.engine.queryBindings(query, { sources: [this.store] }); - const bindings: Binding[] = []; + const bindings: Record[] = []; for await (const item of stream) { - options?.signal?.throwIfAborted(); + signal?.throwIfAborted(); const binding = Object.create(null) as Record; for (const [variable, term] of item) { binding[variable.value] = term; } - bindings.push(binding as Binding); + bindings.push(binding); } - return { - head: { vars: [], }, - results: { bindings }, - }; + return bindings; } - async executeSparqlConstruct( - query: string, - options?: { signal?: AbortSignal; } - ): Promise { + async sparqlConstruct(params: { + query: string; + factory: Rdf.DataFactory; + signal?: AbortSignal; + }): Promise { + const {query, signal} = params; const stream = await this.engine.queryQuads(query, { sources: [this.store] }); const quads: Rdf.Quad[] = []; for await (const item of stream) { - options?.signal?.throwIfAborted(); + signal?.throwIfAborted(); quads.push(item); } return quads; @@ -87,24 +86,19 @@ class InMemorySparqlProvider extends SparqlDataProvider { } export async function makeSparqlDataProvider( - options: Omit, + options: Omit, settings?: SparqlDataProviderSettings ): Promise { const store = new N3.Store( new N3.Parser().parse(TURTLE_DATA) ); const engine = new QueryEngine(); - return new InMemorySparqlProvider( + return new SparqlDataProvider( { ...options, - endpointUrl: '', - queryFunction: () => { - throw new Error('Cannot query external SPARQL endpoint'); - }, + source: new CommunicaQuerySource(store, engine), }, - settings, - store, - engine + settings ); }