Skip to content

Commit 6883d21

Browse files
jamsdenclaude
andcommitted
feat: add getIncomingLinks() to OSLCClient
OSLCClient now accepts ldmBaseUrl in options and exposes getIncomingLinks() which delegates to LDMClient (composition). Returns already-inverted link triples for direct consumption. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f12398d commit 6883d21

2 files changed

Lines changed: 110 additions & 29 deletions

File tree

OSLCClient.js

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,25 @@ import RootServices from './RootServices.js';
88
import ServiceProviderCatalog from './ServiceProviderCatalog.js';
99
import ServiceProvider from './ServiceProvider.js';
1010

11-
// Conditional imports for Node.js only
11+
// Conditional imports for Node.js only — loaded lazily to avoid top-level await
12+
// which prevents browser bundlers (esbuild/webpack) from processing this module.
1213
let wrapper, CookieJar, DOMParser;
1314
const isNodeEnvironment = typeof window === 'undefined';
14-
15-
if (isNodeEnvironment) {
16-
// Node.js imports
17-
const cookiejarSupport = await import('axios-cookiejar-support');
18-
wrapper = cookiejarSupport.wrapper;
19-
const toughCookie = await import('tough-cookie');
20-
CookieJar = toughCookie.CookieJar;
21-
const xmldom = await import('@xmldom/xmldom');
22-
DOMParser = xmldom.DOMParser;
23-
} else {
24-
// Browser: use native DOMParser
25-
DOMParser = window.DOMParser;
15+
let _nodeModulesLoaded = false;
16+
17+
async function ensureNodeModules() {
18+
if (_nodeModulesLoaded) return;
19+
_nodeModulesLoaded = true;
20+
if (isNodeEnvironment) {
21+
const cookiejarSupport = await import('axios-cookiejar-support');
22+
wrapper = cookiejarSupport.wrapper;
23+
const toughCookie = await import('tough-cookie');
24+
CookieJar = toughCookie.CookieJar;
25+
const xmldom = await import('@xmldom/xmldom');
26+
DOMParser = xmldom.DOMParser;
27+
} else {
28+
DOMParser = window.DOMParser;
29+
}
2630
}
2731

2832
// Service providers properties
@@ -175,40 +179,33 @@ export default class OSLCClient {
175179
this.password = password;
176180
this.configuration_context = configuration_context;
177181
this.ssoCallback = options.ssoCallback ?? null;
182+
this._ldmBaseUrl = options.ldmBaseUrl || null;
178183
this.rootservices = null;
179184
this.spc = null;
180185
this.sp = null;
181186
this.ownerMap = new Map();
182187
this.isNodeEnvironment = isNodeEnvironment;
188+
this._options = options;
189+
this._initialized = false;
183190

184-
if (isNodeEnvironment) {
185-
this.jar = options.cookieJar ?? new CookieJar();
186-
}
187-
188-
// Create a base configuration
189-
// maxRedirects: 0 so the response interceptor can inspect 3xx redirects
190-
// for SSO (IdP) detection. Non-IdP redirects are followed manually in the interceptor.
191+
// Create a base axios client — Node.js cookie jar support is applied
192+
// lazily in _ensureInitialized() to avoid top-level await.
191193
const baseConfig = {
192194
timeout: 30000,
193195
maxRedirects: 0,
194196
headers: {
195197
'Accept': 'application/rdf+xml, text/turtle;q=0.9, application/ld+json;q=0.8, application/json;q=0.7, application/xml;q=0.6, text/xml;q=0.5',
196198
'OSLC-Core-Version': '2.0'
197199
},
198-
validateStatus: status => status === 401 || status < 400 // Accept 2xx, 3xx, and 401
200+
validateStatus: status => status === 401 || status < 400
199201
};
200202

201-
// Configure for Node.js or Browser
202203
if (isNodeEnvironment) {
203-
// Node.js: use a cookie jar and keep-alive
204204
baseConfig.keepAlive = true;
205-
baseConfig.jar = this.jar;
206-
this.client = wrapper(axios.create(baseConfig));
207205
} else {
208-
// Browser: use withCredentials for automatic cookie handling
209206
baseConfig.withCredentials = true;
210-
this.client = axios.create(baseConfig);
211207
}
208+
this.client = axios.create(baseConfig);
212209

213210
// Add the Configuration-Context header if one is given
214211
if (configuration_context) {
@@ -231,6 +228,21 @@ export default class OSLCClient {
231228
);
232229
};
233230

231+
/**
232+
* Lazily load Node.js modules and upgrade the axios client with cookie jar
233+
* support. Called automatically before the first network request.
234+
*/
235+
async _ensureInitialized() {
236+
if (this._initialized) return;
237+
this._initialized = true;
238+
await ensureNodeModules();
239+
if (isNodeEnvironment) {
240+
this.jar = this._options.cookieJar ?? new CookieJar();
241+
this.client.defaults.jar = this.jar;
242+
this.client = wrapper(this.client);
243+
}
244+
}
245+
234246
/**
235247
* Main auth dispatch — inspects response headers/status and routes to the
236248
* appropriate authentication handler. Re-dispatches after successful auth
@@ -671,11 +683,12 @@ export default class OSLCClient {
671683
* @returns an OSLCResource object containing the resource data
672684
*/
673685
async getResource(url, oslc_version = '2.0', accept = 'application/rdf+xml') {
686+
await this._ensureInitialized();
674687
const headers = {
675688
'Accept': accept,
676689
'OSLC-Core-Version': oslc_version
677690
};
678-
691+
679692
let response
680693
try {
681694
response = await this.client.get(url, { headers });
@@ -724,6 +737,7 @@ export default class OSLCClient {
724737
* @returns an OSLCResource object containing the resource data
725738
*/
726739
async getCompactResource(url, oslc_version = '2.0', accept = 'application/x-oslc-compact+xml') {
740+
await this._ensureInitialized();
727741
const headers = {
728742
'Accept': accept,
729743
'OSLC-Core-Version': oslc_version
@@ -782,6 +796,7 @@ export default class OSLCClient {
782796

783797

784798
async putResource(resource, eTag = null, oslc_version = '2.0') {
799+
await this._ensureInitialized();
785800
const graph = resource.store;
786801
if (!graph) {
787802
throw new Error('Resource has no data to update');
@@ -808,6 +823,7 @@ export default class OSLCClient {
808823
}
809824

810825
async createResource(resourceType, resource, oslc_version = '2.0') {
826+
await this._ensureInitialized();
811827
const graph = resource.store;
812828
if (!graph) {
813829
throw new Error('Resource has no data to create');
@@ -840,6 +856,7 @@ export default class OSLCClient {
840856
}
841857

842858
async deleteResource(resource, oslc_version = '2.0') {
859+
await this._ensureInitialized();
843860
const graph = resource.store;
844861
if (!graph) {
845862
throw new Error('Resource has no data to delete');
@@ -902,6 +919,7 @@ export default class OSLCClient {
902919
}
903920

904921
async queryWithBase(queryBase, query) {
922+
await this._ensureInitialized();
905923
const headers = {
906924
'OSLC-Core-Version': '2.0',
907925
'Accept': 'application/rdf+xml',
@@ -943,6 +961,7 @@ export default class OSLCClient {
943961
}
944962

945963
async getOwner(url) {
964+
await this._ensureInitialized();
946965
if (this.ownerMap.has(url)) {
947966
return this.ownerMap.get(url);
948967
}
@@ -975,6 +994,25 @@ export default class OSLCClient {
975994
return 'Unknown';
976995
}
977996

997+
/**
998+
* Get incoming links to the given target resource URLs, returning
999+
* already-inverted triples (with inverseLinkType).
1000+
* Delegates to LDMClient (composition helper).
1001+
*
1002+
* @param {string[]} targetResourceURLs - URLs of resources to find incoming links for
1003+
* @param {string[]} linkTypes - optional filter of link type URIs
1004+
* @returns {Promise<Array<{targetURL: string, inverseLinkType: string, sourceURL: string}>>}
1005+
*/
1006+
async getIncomingLinks(targetResourceURLs, linkTypes = []) {
1007+
if (!this._ldmBaseUrl) return [];
1008+
await this._ensureInitialized();
1009+
1010+
const { default: LDMClient } = await import('./LDMClient.js');
1011+
const ldm = new LDMClient(this, this._ldmBaseUrl);
1012+
const triples = await ldm.getIncomingLinks(targetResourceURLs, linkTypes);
1013+
return ldm.invert(triples);
1014+
}
1015+
9781016
async getQueryBase(resourceType) {
9791017
const query = `
9801018
PREFIX oslc: ${oslc()}

__tests__/LDMClient.unit.test.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@ import { jest } from '@jest/globals';
88
jest.unstable_mockModule('rdflib', () => {
99
const mockStore = {
1010
statementsMatching: jest.fn(() => []),
11+
any: jest.fn(() => null),
12+
querySync: jest.fn(() => []),
1113
};
14+
const Namespace = jest.fn((ns) => (localName) => `${ns}${localName || ''}`);
15+
const sym = jest.fn((uri) => ({ value: uri, termType: 'NamedNode' }));
1216
return {
1317
graph: jest.fn(() => mockStore),
1418
parse: jest.fn(),
15-
default: { graph: jest.fn(() => mockStore), parse: jest.fn() },
19+
Namespace,
20+
sym,
21+
default: { graph: jest.fn(() => mockStore), parse: jest.fn(), Namespace, sym },
1622
};
1723
});
1824

1925
const { default: LDMClient } = await import('../LDMClient.js');
26+
const { default: OSLCClient } = await import('../OSLCClient.js');
2027

2128
/**
2229
* Build a fake OSLCClient-like object with the properties LDMClient needs.
@@ -163,3 +170,39 @@ describe('LDMClient composition pattern', () => {
163170
});
164171
});
165172
});
173+
174+
describe('OSLCClient.getIncomingLinks', () => {
175+
test('returns empty array when no ldmBaseUrl configured', async () => {
176+
const client = new OSLCClient('user', 'pass');
177+
const result = await client.getIncomingLinks(['https://server/rm/r1'], []);
178+
expect(result).toEqual([]);
179+
});
180+
181+
test('delegates to LDMClient when ldmBaseUrl is configured', async () => {
182+
const client = new OSLCClient('user', 'pass', null, {
183+
ldmBaseUrl: 'https://server/lqe'
184+
});
185+
await client._ensureInitialized();
186+
187+
// Mock the axios post to return LQE results
188+
client.client.post = jest.fn().mockResolvedValue({
189+
data: {
190+
queryResults: [{
191+
sourceUrl: 'https://server/ccm/wi/1',
192+
linkType: 'http://open-services.net/ns/cm#implementsRequirement',
193+
targetUrl: 'https://server/rm/req/1'
194+
}]
195+
},
196+
headers: { 'content-type': 'application/json' },
197+
});
198+
199+
const result = await client.getIncomingLinks(['https://server/rm/req/1'], []);
200+
201+
// Should return already-inverted results
202+
expect(result).toEqual([{
203+
targetURL: 'https://server/rm/req/1',
204+
inverseLinkType: 'http://open-services.net/ns/rm#implementedBy',
205+
sourceURL: 'https://server/ccm/wi/1'
206+
}]);
207+
});
208+
});

0 commit comments

Comments
 (0)