Skip to content

Commit f12398d

Browse files
jamsdenclaude
andcommitted
refactor: LDMClient uses composition instead of inheritance
LDMClient now receives an OSLCClient instance in its constructor instead of extending OSLCClient. This eliminates duplicate axios instances and auth management, preparing for Task 2 where OSLCClient will create LDMClient internally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 28cae09 commit f12398d

2 files changed

Lines changed: 175 additions & 15 deletions

File tree

LDMClient.js

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as $rdf from 'rdflib';
2-
import OSLCClient from './OSLCClient.js';
32

43
const DEFAULT_ACCEPT = 'text/turtle, application/rdf+xml;q=0.9, application/ld+json;q=0.8, application/json;q=0.7';
54

@@ -69,23 +68,19 @@ function parseRdfTriples({ data, contentType, baseIRI }) {
6968
}
7069

7170
/**
72-
* LDMClient extends OSLCClient to provide Link Discovery Management (LDM) functionality.
73-
* It inherits authentication handling from OSLCClient and adds methods for discovering
71+
* LDMClient is a composition helper that uses an OSLCClient instance for HTTP requests.
72+
* It provides Link Discovery Management (LDM) functionality for discovering
7473
* incoming links to OSLC resources.
7574
*/
76-
export default class LDMClient extends OSLCClient {
75+
export default class LDMClient {
7776
/**
7877
* Create an LDMClient instance.
79-
* @param {string} user - Username for authentication
80-
* @param {string} password - Password for authentication
81-
* @param {string|null} configurationContext - GCM configuration context URL (optional)
78+
* @param {OSLCClient} oslcClient - An initialized OSLCClient instance (provides client, userid, password, configuration_context)
8279
* @param {string} ldmServerBaseUrl - Base URL of the LDM server (e.g., https://server/ldm)
83-
* @param {object} options - Optional configuration object passed to OSLCClient
8480
*/
85-
constructor(user, password, configurationContext, ldmServerBaseUrl, options = {}) {
86-
// Call OSLCClient constructor to set up authentication and axios client
87-
super(user, password, configurationContext, options);
88-
81+
constructor(oslcClient, ldmServerBaseUrl) {
82+
this.oslcClient = oslcClient;
83+
this.client = oslcClient.client;
8984
this.LDMServerBaseURL = normalizeBaseUrl(ldmServerBaseUrl);
9085
this._warnedMissingInverseLinkTypes = new Set();
9186
}
@@ -106,7 +101,7 @@ export default class LDMClient extends OSLCClient {
106101
}
107102

108103
// Use provided configurationContext or fall back to the one set in constructor
109-
const effectiveConfigContext = configurationContext || this.configuration_context;
104+
const effectiveConfigContext = configurationContext || this.oslcClient.configuration_context;
110105

111106
const isLqe = this.LDMServerBaseURL.includes('/lqe');
112107
if (isLqe) {
@@ -287,8 +282,8 @@ export default class LDMClient extends OSLCClient {
287282
return !!(common?.Authorization || common?.authorization);
288283
};
289284

290-
const requestAuth = !hasAuthorizationHeader() && this.userid && this.password
291-
? { username: this.userid, password: this.password }
285+
const requestAuth = !hasAuthorizationHeader() && this.oslcClient.userid && this.oslcClient.password
286+
? { username: this.oslcClient.userid, password: this.oslcClient.password }
292287
: undefined;
293288

294289
if (debugLqe) {

__tests__/LDMClient.unit.test.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Unit tests for LDMClient composition pattern.
3+
* LDMClient receives an OSLCClient instance instead of extending it.
4+
*/
5+
import { jest } from '@jest/globals';
6+
7+
// Mock rdflib to avoid heavy dependency in unit tests
8+
jest.unstable_mockModule('rdflib', () => {
9+
const mockStore = {
10+
statementsMatching: jest.fn(() => []),
11+
};
12+
return {
13+
graph: jest.fn(() => mockStore),
14+
parse: jest.fn(),
15+
default: { graph: jest.fn(() => mockStore), parse: jest.fn() },
16+
};
17+
});
18+
19+
const { default: LDMClient } = await import('../LDMClient.js');
20+
21+
/**
22+
* Build a fake OSLCClient-like object with the properties LDMClient needs.
23+
*/
24+
function makeFakeOslcClient(overrides = {}) {
25+
return {
26+
userid: 'testuser',
27+
password: 'testpass',
28+
configuration_context: 'https://server/gc/configuration/1',
29+
client: {
30+
post: jest.fn().mockResolvedValue({ data: { queryResults: [] }, headers: {} }),
31+
get: jest.fn().mockResolvedValue({ data: '', headers: {} }),
32+
defaults: { headers: { common: {} } },
33+
},
34+
...overrides,
35+
};
36+
}
37+
38+
describe('LDMClient composition pattern', () => {
39+
describe('constructor', () => {
40+
it('accepts an OSLCClient-like object and ldmBaseUrl', () => {
41+
const oslcClient = makeFakeOslcClient();
42+
const ldm = new LDMClient(oslcClient, 'https://server/ldm');
43+
expect(ldm).toBeDefined();
44+
expect(ldm.oslcClient).toBe(oslcClient);
45+
});
46+
47+
it('throws if ldmBaseUrl is missing', () => {
48+
const oslcClient = makeFakeOslcClient();
49+
expect(() => new LDMClient(oslcClient)).toThrow('LDMServerBaseURL is required');
50+
expect(() => new LDMClient(oslcClient, '')).toThrow('LDMServerBaseURL is required');
51+
expect(() => new LDMClient(oslcClient, null)).toThrow('LDMServerBaseURL is required');
52+
});
53+
54+
it('normalizes trailing slash from ldmBaseUrl', () => {
55+
const oslcClient = makeFakeOslcClient();
56+
const ldm = new LDMClient(oslcClient, 'https://server/ldm/');
57+
expect(ldm.LDMServerBaseURL).toBe('https://server/ldm');
58+
});
59+
});
60+
61+
describe('uses OSLCClient axios instance', () => {
62+
it('uses oslcClient.client for LQE requests', async () => {
63+
const oslcClient = makeFakeOslcClient();
64+
oslcClient.client.post.mockResolvedValue({
65+
data: { queryResults: [] },
66+
headers: { 'content-type': 'application/json' },
67+
});
68+
69+
const ldm = new LDMClient(oslcClient, 'https://server/lqe');
70+
await ldm.getIncomingLinks(['https://server/rm/resources/1']);
71+
72+
expect(oslcClient.client.post).toHaveBeenCalled();
73+
const [url] = oslcClient.client.post.mock.calls[0];
74+
expect(url).toBe('https://server/lqe/incoming-links');
75+
});
76+
77+
it('uses oslcClient.client for LDM requests', async () => {
78+
const oslcClient = makeFakeOslcClient();
79+
oslcClient.client.post.mockResolvedValue({
80+
data: '@prefix : <http://example.org/> .',
81+
headers: { 'content-type': 'text/turtle' },
82+
});
83+
84+
const ldm = new LDMClient(oslcClient, 'https://server/ldm');
85+
await ldm.getIncomingLinks(['https://server/rm/resources/1']);
86+
87+
expect(oslcClient.client.post).toHaveBeenCalled();
88+
const [url] = oslcClient.client.post.mock.calls[0];
89+
expect(url).toBe('https://server/ldm/discover-links');
90+
});
91+
});
92+
93+
describe('getIncomingLinks', () => {
94+
it('calls the LQE endpoint when base URL contains /lqe', async () => {
95+
const oslcClient = makeFakeOslcClient();
96+
oslcClient.client.post.mockResolvedValue({
97+
data: { queryResults: [{ sourceUrl: 'https://s', linkType: 'https://lt', targetUrl: 'https://t' }] },
98+
headers: { 'content-type': 'application/json' },
99+
});
100+
101+
const ldm = new LDMClient(oslcClient, 'https://server/lqe');
102+
const results = await ldm.getIncomingLinks(['https://server/rm/resources/1']);
103+
104+
expect(results).toEqual([{ sourceURL: 'https://s', linkType: 'https://lt', targetURL: 'https://t' }]);
105+
});
106+
107+
it('passes configuration_context from oslcClient when not overridden', async () => {
108+
const oslcClient = makeFakeOslcClient({ configuration_context: 'https://server/gc/config/42' });
109+
oslcClient.client.post.mockResolvedValue({
110+
data: { queryResults: [] },
111+
headers: { 'content-type': 'application/json' },
112+
});
113+
114+
const ldm = new LDMClient(oslcClient, 'https://server/lqe');
115+
await ldm.getIncomingLinks(['https://server/rm/resources/1']);
116+
117+
const [, body] = oslcClient.client.post.mock.calls[0];
118+
expect(body).toContain('oslc_config.context=https');
119+
});
120+
121+
it('throws on empty targetResourceURLs', async () => {
122+
const ldm = new LDMClient(makeFakeOslcClient(), 'https://server/lqe');
123+
await expect(ldm.getIncomingLinks([])).rejects.toThrow('targetResourceURLs must be a non-empty array');
124+
});
125+
});
126+
127+
describe('invert', () => {
128+
it('maps link types to their inverses', () => {
129+
const ldm = new LDMClient(makeFakeOslcClient(), 'https://server/lqe');
130+
const result = ldm.invert([{
131+
sourceURL: 'https://s',
132+
linkType: 'http://open-services.net/ns/rm#elaborates',
133+
targetURL: 'https://t',
134+
}]);
135+
136+
expect(result).toEqual([{
137+
targetURL: 'https://t',
138+
inverseLinkType: 'http://open-services.net/ns/rm#elaboratedBy',
139+
sourceURL: 'https://s',
140+
}]);
141+
});
142+
143+
it('returns original link type when no inverse mapping exists', () => {
144+
const ldm = new LDMClient(makeFakeOslcClient(), 'https://server/lqe');
145+
const result = ldm.invert([{
146+
sourceURL: 'https://s',
147+
linkType: 'http://example.org/unknownLink',
148+
targetURL: 'https://t',
149+
}]);
150+
151+
expect(result[0].inverseLinkType).toBe('http://example.org/unknownLink');
152+
});
153+
154+
it('handles symmetric link types', () => {
155+
const ldm = new LDMClient(makeFakeOslcClient(), 'https://server/lqe');
156+
const result = ldm.invert([{
157+
sourceURL: 'https://s',
158+
linkType: 'http://open-services.net/ns/core#related',
159+
targetURL: 'https://t',
160+
}]);
161+
162+
expect(result[0].inverseLinkType).toBe('http://open-services.net/ns/core#related');
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)