Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ High-level orchestration services:
- Create, read, update, delete AAS with automatic registry synchronization
- Fetch AAS with their submodels in a single call
- Automatic endpoint resolution and fallback handling
- Optional registry resolution warnings and strict fail-fast mode for list operations
- **SubmodelService**: Unified API for Submodel operations across registry and repository
- Create, read, update, delete Submodels with automatic registry synchronization
- Flexible endpoint-based retrieval
- Automatic fallback to repository when registry unavailable
- Optional registry resolution warnings and strict fail-fast mode for list operations

Clients for the AAS API components:

Expand Down Expand Up @@ -258,8 +260,17 @@ if (createResult.success) {
const listResult = await service.getAasList();
if (listResult.success) {
console.log('All shells:', listResult.data.shells);
// Optional warnings for descriptors that could not be resolved
if (listResult.data.warnings?.length) {
console.warn('Registry resolution warnings:', listResult.data.warnings);
}
}

// Strict mode: fail immediately if any registry descriptor cannot be resolved
const strictListResult = await service.getAasList({
strictRegistryResolution: true,
});

// Get AAS by ID (uses registry endpoint if available)
const getResult = await service.getAasById({
aasIdentifier: 'https://example.com/ids/aas/my-aas',
Expand Down Expand Up @@ -431,8 +442,18 @@ const listResult = await service.getSubmodelList({ preferRegistry: true });
if (listResult.success) {
console.log('All submodels:', listResult.data.submodels);
console.log('Fetched from:', listResult.data.source); // 'registry' or 'repository'
// Optional warnings for descriptors that could not be resolved
if (listResult.data.warnings?.length) {
console.warn('Registry resolution warnings:', listResult.data.warnings);
}
}

// Strict mode: fail immediately if any registry descriptor cannot be resolved
const strictSubmodelListResult = await service.getSubmodelList({
preferRegistry: true,
strictRegistryResolution: true,
});

// Get Submodel by ID (uses registry endpoint if available)
const getResult = await service.getSubmodelById({
submodelIdentifier: 'https://example.com/ids/sm/my-submodel',
Expand Down
39 changes: 38 additions & 1 deletion src/integration-tests/aasService.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SpecificAssetId,
} from '@aas-core-works/aas-core3.1-typescript/types';
import { AasDiscoveryClient } from '../clients/AasDiscoveryClient';
import { Configuration } from '../generated';
import { Configuration, type FetchAPI } from '../generated';
import { base64Encode } from '../lib/base64Url';
import { AasService } from '../services/AasService';
import { createGlobalAssetIdFromAasId, createTestShell } from './fixtures/aasFixtures';
Expand Down Expand Up @@ -287,6 +287,43 @@ describe('AasService Integration Tests', () => {
// Cleanup
await aasService.deleteAas({ aasIdentifier: testShell.id });
});

test('should apply repository fetchApi when resolving registry descriptor endpoints', async () => {
const { testShell } = createUniqueTestData();

// Register an AAS first
await aasService.createAas({
shell: testShell,
});

let repositoryFetchCalls = 0;
const trackedFetchApi: FetchAPI = async (input, init) => {
repositoryFetchCalls++;
return fetch(input, init);
};

const serviceWithTrackedRepositoryFetch = new AasService({
aasRegistryConfig: new Configuration({ basePath: 'http://localhost:8084' }),
aasRepositoryConfig: new Configuration({
basePath: 'http://localhost:8081',
fetchApi: trackedFetchApi,
}),
});

const result = await serviceWithTrackedRepositoryFetch.getAasList({ preferRegistry: true });

assertApiResult(result);
if (result.success) {
expect(result.data.source).toBe('registry');
const foundShell = result.data.shells.find((s) => s.id === testShell.id);
expect(foundShell).toBeDefined();
}
// Ensures endpoint-resolution repository calls actually used repository Configuration.
expect(repositoryFetchCalls).toBeGreaterThan(0);

// Cleanup
await aasService.deleteAas({ aasIdentifier: testShell.id });
});
});

describe('getAasById', () => {
Expand Down
39 changes: 38 additions & 1 deletion src/integration-tests/submodelService.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Configuration } from '../generated';
import { Configuration, type FetchAPI } from '../generated';
import { base64Encode } from '../lib/base64Url';
import { SubmodelService } from '../services/SubmodelService';
import { createTestSubmodelDescriptor } from './fixtures/aasregistryFixtures';
Expand Down Expand Up @@ -278,6 +278,43 @@ describe('SubmodelService Integration Tests', () => {
// Cleanup
await submodelService.deleteSubmodel({ submodelIdentifier: testSubmodel.id });
});

test('should apply repository fetchApi when resolving registry descriptor endpoints', async () => {
const { testSubmodel } = createUniqueTestData();

// Register a Submodel first
await submodelService.createSubmodel({
submodel: testSubmodel,
});

let repositoryFetchCalls = 0;
const trackedFetchApi: FetchAPI = async (input, init) => {
repositoryFetchCalls++;
return fetch(input, init);
};

const serviceWithTrackedRepositoryFetch = new SubmodelService({
submodelRegistryConfig: new Configuration({ basePath: 'http://localhost:8085' }),
submodelRepositoryConfig: new Configuration({
basePath: 'http://localhost:8082',
fetchApi: trackedFetchApi,
}),
});

const result = await serviceWithTrackedRepositoryFetch.getSubmodelList({ preferRegistry: true });

assertApiResult(result);
if (result.success) {
expect(result.data.source).toBe('registry');
const foundSubmodel = result.data.submodels.find((s) => s.id === testSubmodel.id);
expect(foundSubmodel).toBeDefined();
}
// Ensures endpoint-resolution repository calls actually used repository Configuration.
expect(repositoryFetchCalls).toBeGreaterThan(0);

// Cleanup
await submodelService.deleteSubmodel({ submodelIdentifier: testSubmodel.id });
});
});

describe('getSubmodelById', () => {
Expand Down
144 changes: 119 additions & 25 deletions src/services/AasService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export interface AasServiceConfig {
discoveryConfig?: Configuration;
}

export interface AasListResolutionWarning {
descriptorId?: string;
endpoint?: string;
errorType: string;
message: string;
}

/**
* AasService combines and orchestrates multiple clients and services, including:
* - AasRegistryClient
Expand Down Expand Up @@ -70,15 +77,21 @@ export class AasService {
*
* @param options Object containing:
* - preferRegistry?: Whether to prefer registry over repository (default: true)
* - strictRegistryResolution?: Whether registry endpoint resolution failures should fail immediately (default: false)
* - limit?: Maximum number of elements to retrieve
* - cursor?: Pagination cursor
Comment thread
aaronzi marked this conversation as resolved.
* - includeSubmodels?: Whether to fetch submodels for each shell (default: false)
* - includeConceptDescriptions?: Whether to fetch concept descriptions (default: false)
*
* @returns Either `{ success: true; data: { shells, source, submodels? } }` or `{ success: false; error: ... }`.
* @returns Either
* `{ success: true; data: { shells, source, submodels?, warnings? } }`
* or `{ success: false; error: ... }`.
* When `strictRegistryResolution` is `true`, unresolved registry descriptors return
* `RegistryResolutionError` with `warnings`.
*/
async getAasList(options?: {
preferRegistry?: boolean;
strictRegistryResolution?: boolean;
limit?: number;
cursor?: string;
includeSubmodels?: boolean;
Expand All @@ -89,12 +102,15 @@ export class AasService {
shells: AssetAdministrationShell[];
source: 'registry' | 'repository';
submodels?: Record<string, Submodel[]>;
warnings?: AasListResolutionWarning[];
},
any
>
> {
const preferRegistry = options?.preferRegistry ?? true;
const strictRegistryResolution = options?.strictRegistryResolution ?? false;
const includeSubmodels = options?.includeSubmodels ?? false;
let registryResolutionWarnings: AasListResolutionWarning[] = [];

// Try registry first if configured and preferred
if (preferRegistry && this.aasRegistryConfig) {
Expand All @@ -107,36 +123,88 @@ export class AasService {
if (registryResult.success) {
// Fetch actual shells from descriptor endpoints
const shells: AssetAdministrationShell[] = [];
const warnings: AasListResolutionWarning[] = [];
for (const descriptor of registryResult.data.result) {
if (!descriptor.id) {
warnings.push({
errorType: 'MissingDescriptorId',
message: 'AAS descriptor does not contain an id and cannot be resolved',
});
continue;
}

const endpoint = extractEndpointHref(descriptor, 'AAS-3.0');
if (endpoint && descriptor.id) {
// Extract base URL from endpoint (remove /shells/{id} part)
const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint;
const config = new Configuration({ basePath: baseUrl });
const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({
configuration: config,
aasIdentifier: descriptor.id,
if (!endpoint) {
warnings.push({
descriptorId: descriptor.id,
errorType: 'MissingEndpoint',
message: 'AAS descriptor has no AAS-3.0 endpoint and cannot be resolved',
});
continue;
}

// Extract base URL from endpoint (remove /shells/{id} part)
const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint;
const config = this.createRepositoryEndpointConfiguration(baseUrl);
const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({
configuration: config,
aasIdentifier: descriptor.id,
});
if (shellResult.success) {
shells.push(shellResult.data);
} else {
const resolutionError = shellResult.error as
| {
errorType?: string;
message?: string;
messages?: Array<{ text?: string }>;
}
| undefined;
warnings.push({
descriptorId: descriptor.id,
endpoint,
errorType: resolutionError?.errorType || 'EndpointResolutionError',
message:
resolutionError?.message ||
resolutionError?.messages?.[0]?.text ||
'Failed to resolve AAS from descriptor endpoint',
});
if (shellResult.success) {
shells.push(shellResult.data);
}
}
}

// Fetch submodels if requested
let submodelsMap: Record<string, Submodel[]> | undefined;
if (includeSubmodels) {
submodelsMap = await this.fetchSubmodelsForShells(shells, options?.includeConceptDescriptions);
if (strictRegistryResolution && warnings.length > 0) {
return {
success: false,
error: {
errorType: 'RegistryResolutionError',
message: 'Failed to resolve one or more AAS descriptors via registry endpoints',
warnings,
},
};
}

return {
success: true,
data: {
shells,
source: 'registry',
...(submodelsMap && { submodels: submodelsMap }),
},
};
// If registry descriptors exist but none were resolvable, fall back to repository when possible.
if (warnings.length > 0 && shells.length === 0 && this.aasRepositoryConfig) {
registryResolutionWarnings = warnings;
} else {
// Fetch submodels if requested
let submodelsMap: Record<string, Submodel[]> | undefined;
if (includeSubmodels) {
submodelsMap = await this.fetchSubmodelsForShells(shells, options?.includeConceptDescriptions);
}

return {
success: true,
data: {
shells,
source: 'registry',
...(submodelsMap && { submodels: submodelsMap }),
...(warnings.length > 0 && { warnings }),
},
};
}
} else {
registryResolutionWarnings = [];
}
}

Expand All @@ -163,6 +231,7 @@ export class AasService {
shells,
source: 'repository',
...(submodelsMap && { submodels: submodelsMap }),
...(registryResolutionWarnings.length > 0 && { warnings: registryResolutionWarnings }),
},
};
}
Expand Down Expand Up @@ -226,7 +295,7 @@ export class AasService {
// Extract base URL from endpoint (remove /shells/{id} part)
const baseUrl = endpoint.match(/^(https?:\/\/[^/]+(?::\d+)?)/)?.[1] || endpoint;
// Try to fetch from descriptor endpoint
const config = new Configuration({ basePath: baseUrl });
const config = this.createRepositoryEndpointConfiguration(baseUrl);
const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({
configuration: config,
aasIdentifier,
Expand Down Expand Up @@ -397,7 +466,7 @@ export class AasService {
const aasIdentifier = base64Decode(encodedId);

// Create configuration for the endpoint
const config = new Configuration({ basePath: baseUrl });
const config = this.createRepositoryEndpointConfiguration(baseUrl);

// Fetch the shell
const shellResult = await this.aasRepositoryClient.getAssetAdministrationShellById({
Expand Down Expand Up @@ -862,6 +931,31 @@ export class AasService {
};
}

/**
* Creates a repository configuration for an endpoint while preserving auth and middleware.
*
* @param basePath The endpoint base path to target
* @returns Configuration with endpoint basePath plus repository auth/settings
*/
private createRepositoryEndpointConfiguration(basePath: string): Configuration {
if (!this.aasRepositoryConfig) {
return new Configuration({ basePath });
}

return new Configuration({
basePath,
fetchApi: this.aasRepositoryConfig.fetchApi,
middleware: this.aasRepositoryConfig.middleware,
queryParamsStringify: this.aasRepositoryConfig.queryParamsStringify,
username: this.aasRepositoryConfig.username,
password: this.aasRepositoryConfig.password,
apiKey: this.aasRepositoryConfig.apiKey,
accessToken: this.aasRepositoryConfig.accessToken,
headers: this.aasRepositoryConfig.headers,
credentials: this.aasRepositoryConfig.credentials,
});
}

/**
* Helper method to create an AAS descriptor from an AssetAdministrationShell.
* Populates descriptor fields from the shell including metadata and endpoint configuration.
Expand Down
Loading
Loading