From 7524f22f37f2599c5316385b75f74003c9b64383 Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Wed, 20 May 2026 14:14:12 +0200 Subject: [PATCH 1/3] Enhances AAS and Submodel services with improved error handling and repository configuration preservation --- src/services/AasService.ts | 139 +++++++++++++++--- src/services/SubmodelService.ts | 121 +++++++++++++-- src/unit-tests/services/AasService.test.ts | 108 ++++++++++++++ .../services/SubmodelService.test.ts | 109 ++++++++++++++ 4 files changed, 438 insertions(+), 39 deletions(-) diff --git a/src/services/AasService.ts b/src/services/AasService.ts index 3bc82d82..0e5dad94 100644 --- a/src/services/AasService.ts +++ b/src/services/AasService.ts @@ -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 @@ -70,6 +77,7 @@ 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 * - includeSubmodels?: Whether to fetch submodels for each shell (default: false) @@ -79,6 +87,7 @@ export class AasService { */ async getAasList(options?: { preferRegistry?: boolean; + strictRegistryResolution?: boolean; limit?: number; cursor?: string; includeSubmodels?: boolean; @@ -89,12 +98,15 @@ export class AasService { shells: AssetAdministrationShell[]; source: 'registry' | 'repository'; submodels?: Record; + 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) { @@ -107,36 +119,89 @@ 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 | 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 | 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 = []; } } @@ -163,6 +228,7 @@ export class AasService { shells, source: 'repository', ...(submodelsMap && { submodels: submodelsMap }), + ...(registryResolutionWarnings.length > 0 && { warnings: registryResolutionWarnings }), }, }; } @@ -226,7 +292,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, @@ -397,7 +463,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({ @@ -862,6 +928,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. diff --git a/src/services/SubmodelService.ts b/src/services/SubmodelService.ts index a311b4e6..7ffea56c 100644 --- a/src/services/SubmodelService.ts +++ b/src/services/SubmodelService.ts @@ -14,6 +14,13 @@ export interface SubmodelServiceConfig { conceptDescriptionRepositoryConfig?: Configuration; } +export interface SubmodelListResolutionWarning { + submodelIdentifier?: string; + endpoint?: string; + errorType: string; + message: string; +} + /** * SubmodelService combines Submodel Registry, Submodel Repository, and Concept Description Repository clients * to provide higher-level functionality for working with Submodels. @@ -49,6 +56,7 @@ export class SubmodelService { * * @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 * - includeConceptDescriptions?: Whether to fetch concept descriptions (default: false) @@ -57,6 +65,7 @@ export class SubmodelService { */ async getSubmodelList(options?: { preferRegistry?: boolean; + strictRegistryResolution?: boolean; limit?: number; cursor?: string; includeConceptDescriptions?: boolean; @@ -66,12 +75,15 @@ export class SubmodelService { submodels: Submodel[]; source: 'registry' | 'repository'; conceptDescriptions?: ConceptDescription[]; + warnings?: SubmodelListResolutionWarning[]; }, any > > { const preferRegistry = options?.preferRegistry ?? true; + const strictRegistryResolution = options?.strictRegistryResolution ?? false; const includeConceptDescriptions = options?.includeConceptDescriptions ?? false; + let registryResolutionWarnings: SubmodelListResolutionWarning[] = []; // Try registry first if configured and preferred if (preferRegistry && this.submodelRegistryConfig) { @@ -83,34 +95,87 @@ export class SubmodelService { if (registryResult.success) { const descriptors = registryResult.data.result; + const warnings: SubmodelListResolutionWarning[] = []; // Fetch submodels from their endpoints - const submodelPromises = descriptors.map(async (descriptor) => { + const submodelPromises = descriptors.map(async (descriptor): Promise => { + if (!descriptor.id) { + warnings.push({ + errorType: 'MissingDescriptorId', + message: 'Submodel descriptor does not contain an id and cannot be resolved', + }); + return null; + } + const endpoint = descriptor.endpoints?.[0]?.protocolInformation?.href; if (!endpoint) { + warnings.push({ + submodelIdentifier: descriptor.id, + errorType: 'MissingEndpoint', + message: 'Submodel descriptor has no endpoint and cannot be resolved', + }); return null; } const submodelResult = await this.getSubmodelByEndpoint({ endpoint }); - return submodelResult.success ? submodelResult.data.submodel : null; + if (submodelResult.success) { + return submodelResult.data.submodel; + } + + const resolutionError = submodelResult.error as + | { + errorType?: string; + message?: string; + messages?: Array<{ text?: string }>; + } + | undefined; + warnings.push({ + submodelIdentifier: descriptor.id, + endpoint, + errorType: resolutionError?.errorType || 'EndpointResolutionError', + message: + resolutionError?.message || + resolutionError?.messages?.[0]?.text || + 'Failed to resolve Submodel from descriptor endpoint', + }); + return null; }); const submodels = (await Promise.all(submodelPromises)).filter((sm): sm is Submodel => sm !== null); - // Fetch concept descriptions if requested - let conceptDescriptions: ConceptDescription[] | undefined; - if (includeConceptDescriptions) { - conceptDescriptions = await this.fetchConceptDescriptionsForSubmodels(submodels); + if (strictRegistryResolution && warnings.length > 0) { + return { + success: false, + error: { + errorType: 'RegistryResolutionError', + message: 'Failed to resolve one or more Submodel descriptors via registry endpoints', + warnings, + }, + }; } - return { - success: true, - data: { - submodels, - source: 'registry', - ...(conceptDescriptions && { conceptDescriptions }), - }, - }; + // If registry descriptors exist but none were resolvable, fall back to repository when possible. + if (warnings.length > 0 && submodels.length === 0 && this.submodelRepositoryConfig) { + registryResolutionWarnings = warnings; + } else { + // Fetch concept descriptions if requested + let conceptDescriptions: ConceptDescription[] | undefined; + if (includeConceptDescriptions) { + conceptDescriptions = await this.fetchConceptDescriptionsForSubmodels(submodels); + } + + return { + success: true, + data: { + submodels, + source: 'registry', + ...(conceptDescriptions && { conceptDescriptions }), + ...(warnings.length > 0 && { warnings }), + }, + }; + } + } else { + registryResolutionWarnings = []; } } @@ -137,6 +202,7 @@ export class SubmodelService { submodels, source: 'repository', ...(conceptDescriptions && { conceptDescriptions }), + ...(registryResolutionWarnings.length > 0 && { warnings: registryResolutionWarnings }), }, }; } @@ -350,7 +416,7 @@ export class SubmodelService { const submodelIdentifier = base64Decode(encodedId); // Create configuration for the endpoint - const config = new Configuration({ basePath: baseUrl }); + const config = this.createRepositoryEndpointConfiguration(baseUrl); // Fetch the submodel const submodelResult = await this.submodelRepositoryClient.getSubmodelById({ @@ -379,6 +445,31 @@ export class SubmodelService { }; } + /** + * 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.submodelRepositoryConfig) { + return new Configuration({ basePath }); + } + + return new Configuration({ + basePath, + fetchApi: this.submodelRepositoryConfig.fetchApi, + middleware: this.submodelRepositoryConfig.middleware, + queryParamsStringify: this.submodelRepositoryConfig.queryParamsStringify, + username: this.submodelRepositoryConfig.username, + password: this.submodelRepositoryConfig.password, + apiKey: this.submodelRepositoryConfig.apiKey, + accessToken: this.submodelRepositoryConfig.accessToken, + headers: this.submodelRepositoryConfig.headers, + credentials: this.submodelRepositoryConfig.credentials, + }); + } + /** * Creates a Submodel and optionally registers it in the registry. * diff --git a/src/unit-tests/services/AasService.test.ts b/src/unit-tests/services/AasService.test.ts index 34452a1c..6d97fee8 100644 --- a/src/unit-tests/services/AasService.test.ts +++ b/src/unit-tests/services/AasService.test.ts @@ -107,6 +107,114 @@ describe('AasService Unit Tests', () => { expect(mockRepositoryClient.getAssetAdministrationShellById).toHaveBeenCalledTimes(1); }); + it('should preserve repository auth configuration when using descriptor endpoints', async () => { + const accessToken = vi.fn().mockResolvedValue('oidc-token'); + const middleware = [{ pre: vi.fn(async () => undefined) }]; + const headers = { Authorization: 'Bearer test-token' }; + const repositoryConfigWithAuth = new Configuration({ + basePath: 'http://localhost:8081', + accessToken, + middleware, + headers, + }); + + const serviceWithAuthConfig = new AasService({ + aasRegistryConfig, + aasRepositoryConfig: repositoryConfigWithAuth, + }); + const registryClientForAuthConfig = (AasRegistryClient as MockedClass).mock + .instances.at(-1) as Mocked; + const repositoryClientForAuthConfig = (AasRepositoryClient as MockedClass).mock + .instances.at(-1) as Mocked; + + registryClientForAuthConfig.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + repositoryClientForAuthConfig.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: true, + data: testShell, + }); + + const result = await serviceWithAuthConfig.getAasList(); + + expect(result.success).toBe(true); + expect(repositoryClientForAuthConfig.getAssetAdministrationShellById).toHaveBeenCalledTimes(1); + + const endpointConfig = repositoryClientForAuthConfig.getAssetAdministrationShellById.mock.calls[0][0] + .configuration as Configuration; + expect(endpointConfig.basePath).toBe('http://localhost:8081'); + expect(endpointConfig.accessToken).toBe(accessToken); + expect(endpointConfig.middleware).toEqual(middleware); + expect(endpointConfig.headers).toEqual(headers); + }); + + it('should fall back to repository when all registry endpoint resolutions fail', async () => { + const mockShells = [testShell]; + mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + mockRepositoryClient.getAllAssetAdministrationShells = vi.fn().mockResolvedValue({ + success: true, + data: { + result: mockShells, + pagedResult: undefined, + }, + }); + + const result = await aasService.getAasList(); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('repository'); + expect(result.data.shells).toEqual(mockShells); + expect(result.data.warnings).toBeDefined(); + expect(result.data.warnings?.[0]).toMatchObject({ + descriptorId: testAasId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllAssetAdministrationShells).toHaveBeenCalledTimes(1); + }); + + it('should fail when strictRegistryResolution is true and registry endpoint resolution fails', async () => { + mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getAssetAdministrationShellById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + + const result = await aasService.getAasList({ strictRegistryResolution: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorType).toBe('RegistryResolutionError'); + expect(result.error.warnings).toBeDefined(); + expect(result.error.warnings[0]).toMatchObject({ + descriptorId: testAasId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllAssetAdministrationShells).not.toHaveBeenCalled(); + }); + it('should fall back to repository when registry fails', async () => { const mockShells = [testShell]; mockRegistryClient.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ diff --git a/src/unit-tests/services/SubmodelService.test.ts b/src/unit-tests/services/SubmodelService.test.ts index 6cfb5ca0..55864606 100644 --- a/src/unit-tests/services/SubmodelService.test.ts +++ b/src/unit-tests/services/SubmodelService.test.ts @@ -91,6 +91,115 @@ describe('SubmodelService Unit Tests', () => { expect(mockRepositoryClient.getSubmodelById).toHaveBeenCalledTimes(1); }); + it('should preserve repository auth configuration when using descriptor endpoints', async () => { + const accessToken = vi.fn().mockResolvedValue('oidc-token'); + const middleware = [{ pre: vi.fn(async () => undefined) }]; + const headers = { Authorization: 'Bearer test-token' }; + const repositoryConfigWithAuth = new Configuration({ + basePath: 'http://localhost:8081', + accessToken, + middleware, + headers, + }); + + const serviceWithAuthConfig = new SubmodelService({ + submodelRegistryConfig, + submodelRepositoryConfig: repositoryConfigWithAuth, + }); + const registryClientForAuthConfig = (SubmodelRegistryClient as MockedClass) + .mock.instances.at(-1) as Mocked; + const repositoryClientForAuthConfig = ( + SubmodelRepositoryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + + registryClientForAuthConfig.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + repositoryClientForAuthConfig.getSubmodelById = vi.fn().mockResolvedValue({ + success: true, + data: testSubmodel, + }); + + const result = await serviceWithAuthConfig.getSubmodelList(); + + expect(result.success).toBe(true); + expect(repositoryClientForAuthConfig.getSubmodelById).toHaveBeenCalledTimes(1); + + const endpointConfig = repositoryClientForAuthConfig.getSubmodelById.mock.calls[0][0] + .configuration as Configuration; + expect(endpointConfig.basePath).toBe('http://localhost:8081'); + expect(endpointConfig.accessToken).toBe(accessToken); + expect(endpointConfig.middleware).toEqual(middleware); + expect(endpointConfig.headers).toEqual(headers); + }); + + it('should fall back to repository when all registry endpoint resolutions fail', async () => { + const mockSubmodels = [testSubmodel]; + mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getSubmodelById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + mockRepositoryClient.getAllSubmodels = vi.fn().mockResolvedValue({ + success: true, + data: { + result: mockSubmodels, + pagedResult: undefined, + }, + }); + + const result = await submodelService.getSubmodelList(); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('repository'); + expect(result.data.submodels).toEqual(mockSubmodels); + expect(result.data.warnings).toBeDefined(); + expect(result.data.warnings?.[0]).toMatchObject({ + submodelIdentifier: testSubmodelId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllSubmodels).toHaveBeenCalledTimes(1); + }); + + it('should fail when strictRegistryResolution is true and registry endpoint resolution fails', async () => { + mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ + success: true, + data: { + result: [testDescriptor], + pagedResult: undefined, + }, + }); + mockRepositoryClient.getSubmodelById = vi.fn().mockResolvedValue({ + success: false, + error: { errorType: 'UnauthorizedError', message: 'Token missing' }, + }); + + const result = await submodelService.getSubmodelList({ strictRegistryResolution: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorType).toBe('RegistryResolutionError'); + expect(result.error.warnings).toBeDefined(); + expect(result.error.warnings[0]).toMatchObject({ + submodelIdentifier: testSubmodelId, + errorType: 'UnauthorizedError', + }); + } + expect(mockRepositoryClient.getAllSubmodels).not.toHaveBeenCalled(); + }); + it('should fall back to repository when registry fails', async () => { const mockSubmodels = [testSubmodel]; mockRegistryClient.getAllSubmodelDescriptors = vi.fn().mockResolvedValue({ From 743798d356f9642122de85bf5c0f783d6cbfd490 Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Wed, 20 May 2026 14:26:35 +0200 Subject: [PATCH 2/3] Adresses review remarks --- README.md | 21 ++++ src/services/AasService.ts | 7 +- src/services/SubmodelService.ts | 117 +++++++++++------- src/unit-tests/services/AasService.test.ts | 10 +- .../services/SubmodelService.test.ts | 5 +- 5 files changed, 105 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2bf51a5b..57dc03b1 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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', @@ -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', diff --git a/src/services/AasService.ts b/src/services/AasService.ts index 0e5dad94..3b4b4e40 100644 --- a/src/services/AasService.ts +++ b/src/services/AasService.ts @@ -83,7 +83,11 @@ export class AasService { * - 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; @@ -199,7 +203,6 @@ export class AasService { }, }; } - } else { registryResolutionWarnings = []; } diff --git a/src/services/SubmodelService.ts b/src/services/SubmodelService.ts index 7ffea56c..d93b1686 100644 --- a/src/services/SubmodelService.ts +++ b/src/services/SubmodelService.ts @@ -61,7 +61,11 @@ export class SubmodelService { * - cursor?: Pagination cursor * - includeConceptDescriptions?: Whether to fetch concept descriptions (default: false) * - * @returns Either `{ success: true; data: { submodels, source, conceptDescriptions? } }` or `{ success: false; error: ... }`. + * @returns Either + * `{ success: true; data: { submodels, source, conceptDescriptions?, warnings? } }` + * or `{ success: false; error: ... }`. + * When `strictRegistryResolution` is `true`, unresolved registry descriptors return + * `RegistryResolutionError` with `warnings`. */ async getSubmodelList(options?: { preferRegistry?: boolean; @@ -95,53 +99,72 @@ export class SubmodelService { if (registryResult.success) { const descriptors = registryResult.data.result; - const warnings: SubmodelListResolutionWarning[] = []; - - // Fetch submodels from their endpoints - const submodelPromises = descriptors.map(async (descriptor): Promise => { - if (!descriptor.id) { - warnings.push({ - errorType: 'MissingDescriptorId', - message: 'Submodel descriptor does not contain an id and cannot be resolved', - }); - return null; - } - - const endpoint = descriptor.endpoints?.[0]?.protocolInformation?.href; - if (!endpoint) { - warnings.push({ - submodelIdentifier: descriptor.id, - errorType: 'MissingEndpoint', - message: 'Submodel descriptor has no endpoint and cannot be resolved', - }); - return null; - } - - const submodelResult = await this.getSubmodelByEndpoint({ endpoint }); - if (submodelResult.success) { - return submodelResult.data.submodel; - } - - const resolutionError = submodelResult.error as - | { - errorType?: string; - message?: string; - messages?: Array<{ text?: string }>; - } - | undefined; - warnings.push({ - submodelIdentifier: descriptor.id, - endpoint, - errorType: resolutionError?.errorType || 'EndpointResolutionError', - message: - resolutionError?.message || - resolutionError?.messages?.[0]?.text || - 'Failed to resolve Submodel from descriptor endpoint', - }); - return null; - }); - const submodels = (await Promise.all(submodelPromises)).filter((sm): sm is Submodel => sm !== null); + // Resolve submodels from descriptor endpoints and aggregate warnings deterministically. + const descriptorResults = await Promise.all( + descriptors.map( + async ( + descriptor + ): Promise<{ + submodel: Submodel | null; + warning?: SubmodelListResolutionWarning; + }> => { + if (!descriptor.id) { + return { + submodel: null, + warning: { + errorType: 'MissingDescriptorId', + message: 'Submodel descriptor does not contain an id and cannot be resolved', + }, + }; + } + + const endpoint = descriptor.endpoints?.[0]?.protocolInformation?.href; + if (!endpoint) { + return { + submodel: null, + warning: { + submodelIdentifier: descriptor.id, + errorType: 'MissingEndpoint', + message: 'Submodel descriptor has no endpoint and cannot be resolved', + }, + }; + } + + const submodelResult = await this.getSubmodelByEndpoint({ endpoint }); + if (submodelResult.success) { + return { submodel: submodelResult.data.submodel }; + } + + const resolutionError = submodelResult.error as + | { + errorType?: string; + message?: string; + messages?: Array<{ text?: string }>; + } + | undefined; + return { + submodel: null, + warning: { + submodelIdentifier: descriptor.id, + endpoint, + errorType: resolutionError?.errorType || 'EndpointResolutionError', + message: + resolutionError?.message || + resolutionError?.messages?.[0]?.text || + 'Failed to resolve Submodel from descriptor endpoint', + }, + }; + } + ) + ); + + const submodels = descriptorResults + .map((result) => result.submodel) + .filter((sm): sm is Submodel => sm !== null); + const warnings = descriptorResults + .map((result) => result.warning) + .filter((warning): warning is SubmodelListResolutionWarning => warning !== undefined); if (strictRegistryResolution && warnings.length > 0) { return { diff --git a/src/unit-tests/services/AasService.test.ts b/src/unit-tests/services/AasService.test.ts index 6d97fee8..290d173c 100644 --- a/src/unit-tests/services/AasService.test.ts +++ b/src/unit-tests/services/AasService.test.ts @@ -122,10 +122,12 @@ describe('AasService Unit Tests', () => { aasRegistryConfig, aasRepositoryConfig: repositoryConfigWithAuth, }); - const registryClientForAuthConfig = (AasRegistryClient as MockedClass).mock - .instances.at(-1) as Mocked; - const repositoryClientForAuthConfig = (AasRepositoryClient as MockedClass).mock - .instances.at(-1) as Mocked; + const registryClientForAuthConfig = ( + AasRegistryClient as MockedClass + ).mock.instances.at(-1) as Mocked; + const repositoryClientForAuthConfig = ( + AasRepositoryClient as MockedClass + ).mock.instances.at(-1) as Mocked; registryClientForAuthConfig.getAllAssetAdministrationShellDescriptors = vi.fn().mockResolvedValue({ success: true, diff --git a/src/unit-tests/services/SubmodelService.test.ts b/src/unit-tests/services/SubmodelService.test.ts index 55864606..e8288c55 100644 --- a/src/unit-tests/services/SubmodelService.test.ts +++ b/src/unit-tests/services/SubmodelService.test.ts @@ -106,8 +106,9 @@ describe('SubmodelService Unit Tests', () => { submodelRegistryConfig, submodelRepositoryConfig: repositoryConfigWithAuth, }); - const registryClientForAuthConfig = (SubmodelRegistryClient as MockedClass) - .mock.instances.at(-1) as Mocked; + const registryClientForAuthConfig = ( + SubmodelRegistryClient as MockedClass + ).mock.instances.at(-1) as Mocked; const repositoryClientForAuthConfig = ( SubmodelRepositoryClient as MockedClass ).mock.instances.at(-1) as Mocked; From 588dbd3decf3ffeccae8d7f14dba1a08605ab810 Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Wed, 20 May 2026 14:32:07 +0200 Subject: [PATCH 3/3] Also adds int tests to confirm fix --- .../aasService.integration.test.ts | 39 ++++++++++++++++++- .../submodelService.integration.test.ts | 39 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/integration-tests/aasService.integration.test.ts b/src/integration-tests/aasService.integration.test.ts index dbc14309..f9a1df5d 100644 --- a/src/integration-tests/aasService.integration.test.ts +++ b/src/integration-tests/aasService.integration.test.ts @@ -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'; @@ -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', () => { diff --git a/src/integration-tests/submodelService.integration.test.ts b/src/integration-tests/submodelService.integration.test.ts index 50b1ceb4..f117a225 100644 --- a/src/integration-tests/submodelService.integration.test.ts +++ b/src/integration-tests/submodelService.integration.test.ts @@ -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'; @@ -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', () => {