diff --git a/.changeset/pink-zebras-judge.md b/.changeset/pink-zebras-judge.md new file mode 100644 index 000000000..9d33e48c8 --- /dev/null +++ b/.changeset/pink-zebras-judge.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/fe-mockserver-core': patch +--- + +feat: dynamically register services defined in `Common.ValueListReferences` annotation diff --git a/packages/fe-mockserver-core/src/api.ts b/packages/fe-mockserver-core/src/api.ts index 0e434448d..ca04c0ecf 100644 --- a/packages/fe-mockserver-core/src/api.ts +++ b/packages/fe-mockserver-core/src/api.ts @@ -13,6 +13,7 @@ export interface Service { cdsServiceName?: string; debug?: boolean; contextBasedIsolation?: boolean; + resolveValueListReferences?: boolean; strictKeyMode?: boolean; watch?: boolean; noETag?: boolean; @@ -27,6 +28,7 @@ export interface ConfigService { mockdataRootPath?: string; mockdataPath?: string; generateMockData?: boolean; + resolveValueListReferences?: boolean; metadataCdsPath?: string; metadataPath?: string; cdsServiceName?: string; @@ -69,6 +71,7 @@ export interface BaseServerConfig { logger?: ILogger; validateETag?: boolean; contextBasedIsolation?: boolean; + resolveValueListReferences?: boolean; generateMockData?: boolean; forceNullableValuesToNull?: boolean; fileLoader?: string; @@ -99,6 +102,7 @@ export type ServiceConfig = { i18nPath?: string[]; generateMockData?: boolean; forceNullableValuesToNull?: boolean; + resolveValueListReferences?: boolean; debug?: boolean; strictKeyMode?: boolean; watch?: boolean; // should be forced to false in browser diff --git a/packages/fe-mockserver-core/src/data/metadata.ts b/packages/fe-mockserver-core/src/data/metadata.ts index 416ee3339..54a2ded4d 100644 --- a/packages/fe-mockserver-core/src/data/metadata.ts +++ b/packages/fe-mockserver-core/src/data/metadata.ts @@ -10,6 +10,8 @@ import type { RawMetadata, Singleton } from '@sap-ux/vocabularies-types'; +import { join } from 'path'; +import { join as joinPosix } from 'path/posix'; type NameAndNav = { name: string; @@ -241,4 +243,48 @@ export class ODataMetadata { return keyValues; } + + public getValueListReferences(metadataPath: string) { + const references = []; + for (const entityType of this.metadata.entityTypes) { + for (const property of entityType.entityProperties) { + const rootPath = this.metadataUrl.replace('/$metadata', ''); + const target = `${entityType.name}/${property.name}`; + for (const reference of property.annotations.Common?.ValueListReferences ?? []) { + const externalServiceMetadataPath = joinPosix(rootPath, reference as string).replace( + '/$metadata', + '' + ); + const [valueListServicePath] = externalServiceMetadataPath.split(';'); + const segments = valueListServicePath.split('/'); + let prefix = '/'; + let currentSegment = segments.shift(); + while (currentSegment !== undefined) { + const next = joinPosix(prefix, currentSegment); + if (!rootPath.startsWith(next)) { + break; + } + prefix = next; + currentSegment = segments.shift(); + } + const relativeServicePath = valueListServicePath.replace(prefix, ''); + + const serviceRoot = join(metadataPath, '..', relativeServicePath, target); + const localPath = join(serviceRoot, `metadata.xml`); + + references.push({ + rootPath, + externalServiceMetadataPath: encode(externalServiceMetadataPath), + localPath: localPath, + dataPath: serviceRoot + }); + } + } + } + return references; + } +} + +function encode(str: string): string { + return str.replaceAll("'", '%27').replaceAll('*', '%2A'); } diff --git a/packages/fe-mockserver-core/src/data/serviceRegistry.ts b/packages/fe-mockserver-core/src/data/serviceRegistry.ts index 365ac3a96..afa54fb13 100644 --- a/packages/fe-mockserver-core/src/data/serviceRegistry.ts +++ b/packages/fe-mockserver-core/src/data/serviceRegistry.ts @@ -1,4 +1,5 @@ import type { ILogger } from '@ui5/logger'; +import type { FSWatcher } from 'chokidar'; import etag from 'etag'; import type { IncomingMessage, ServerResponse } from 'http'; import type { IRouter } from 'router'; @@ -55,6 +56,7 @@ export class ServiceRegistry { private readonly services: Map = new Map(); private readonly aliases: Map = new Map(); private readonly registrations: Map = new Map(); + private readonly watchers: FSWatcher[] = []; private config: MockserverConfiguration; private isOpened: boolean = false; @@ -145,7 +147,32 @@ export class ServiceRegistry { } else { metadata = await loadMetadata(mockService, processor); } + const dataAccess = new DataAccess(mockService, metadata, this.fileLoader, this.config.logger, this); + if (mockServiceIn.resolveValueListReferences === true && metadata) { + const references = metadata.getValueListReferences(mockService.metadataPath); + await Promise.allSettled( + references.map(async (reference) => { + const exists = await this.fileLoader.exists(reference.localPath); + if (!exists) { + log.info( + `ValueList reference metadata file not found at "${reference.localPath}". Service "${reference.externalServiceMetadataPath}" will not be provided.` + ); + return undefined; + } + return this.createServiceRegistration( + { + metadataPath: reference.localPath, + urlPath: reference.externalServiceMetadataPath, + generateMockData: false, + mockdataPath: reference.dataPath, + watch: false + }, + log + ); + }) + ); + } // Register this service for cross-service access this.registerService(mockService.urlPath, dataAccess, mockService.alias); @@ -156,7 +183,7 @@ export class ServiceRegistry { watchPath.push(mockService.metadataPath); } const chokidar = await import('chokidar'); - chokidar + const watcher = chokidar .watch(watchPath, { ignoreInitial: true }) @@ -169,6 +196,7 @@ export class ServiceRegistry { dataAccess.reloadData(metadata); log.info(`Service ${mockService.urlPath} restarted`); }); + this.watchers.push(watcher); } const oDataHandlerInstance = await serviceRouter(mockService, dataAccess); @@ -327,4 +355,9 @@ export class ServiceRegistry { }) .join(', '); } + public async dispose(): Promise { + for (const watcher of this.watchers) { + await watcher.close(); + } + } } diff --git a/packages/fe-mockserver-core/src/index.ts b/packages/fe-mockserver-core/src/index.ts index 049d3b25d..be1439bb3 100644 --- a/packages/fe-mockserver-core/src/index.ts +++ b/packages/fe-mockserver-core/src/index.ts @@ -64,4 +64,8 @@ export default class FEMockserver { getRouter() { return this.mainRouter; } + + async dispose(): Promise { + await this.serviceRegistry.dispose(); + } } diff --git a/packages/fe-mockserver-core/test/unit/middleware.test.ts b/packages/fe-mockserver-core/test/unit/middleware.test.ts index 273f2d29c..7fff0df44 100644 --- a/packages/fe-mockserver-core/test/unit/middleware.test.ts +++ b/packages/fe-mockserver-core/test/unit/middleware.test.ts @@ -4,6 +4,7 @@ import type { Server } from 'http'; import * as http from 'http'; import * as path from 'path'; import FEMockserver, { type MockserverConfiguration } from '../../src'; +import FileSystemLoader from '../../src/plugins/fileSystemLoader'; import { getJsonFromMultipartContent, getStatusAndHeadersFromMultipartContent } from '../../test/unit/__testData/utils'; import { ODataV4Requestor } from './__testData/Requestor'; @@ -62,6 +63,9 @@ describe('V4 Requestor', function () { server = http.createServer(function onRequest(req, res) { mockServer.getRouter()(req, res, finalHandler(req, res)); }); + server.on('close', async () => { + await mockServer.dispose(); + }); server.listen(33331); }); afterAll((done) => { @@ -479,19 +483,13 @@ Content-Type:application/json;charset=UTF-8;IEEE754Compatible=true ); myJSON[0].Prop1 = 'SomethingElse'; fs.writeFileSync(path.join(__dirname, '__testData', 'RootElement.json'), JSON.stringify(myJSON, null, 4)); - let resolveFn: Function; - const myPromise = new Promise((resolve) => { - resolveFn = resolve; - }); - setTimeout(async function () { - dataRequestor = new ODataV4Requestor('http://localhost:33331/sap/fe/core/mock/action'); - dataRes = await dataRequestor.getList('/RootElement').execute(); - expect(dataRes.body.length).toBe(4); - expect(dataRes.body[0].Prop1).toBe('SomethingElse'); - resolveFn(); - }, 1000); - return myPromise; - }); + const sleep = new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep; + dataRequestor = new ODataV4Requestor('http://localhost:33331/sap/fe/core/mock/action'); + dataRes = await dataRequestor.getList('/RootElement').execute(); + expect(dataRes.body.length).toBe(4); + expect(dataRes.body[0].Prop1).toBe('SomethingElse'); + }, 10000); it('ChangeSet failure with single error', async () => { const response = await fetch('http://localhost:33331/sap/fe/core/mock/action/$batch', { @@ -753,6 +751,126 @@ Group ID: $auto` }); }); +describe('services from ValueListReferences', () => { + async function createServer(resolveValueListReferences: boolean, port: number): Promise { + const mockServer = new FEMockserver({ + services: [ + { + metadataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample', 'metadata.xml'), + mockdataPath: path.join(__dirname, 'v4', 'services', 'parametrizedSample'), + urlPath: '/sap/fe/core/mock/sticky', + watch: false, + generateMockData: true, + resolveValueListReferences + } + ], + annotations: [], + plugins: [], + contextBasedIsolation: true + }); + await mockServer.isReady; + const server = http.createServer(function onRequest(req, res) { + mockServer.getRouter()(req, res, finalHandler(req, res)); + }); + server.listen(port); + server.on('close', async () => { + await mockServer.dispose(); + }); + return server; + } + describe('resolveValueListReferences = true', () => { + let server: Server; + let loadFileSpy: jest.SpyInstance; + + afterAll((done) => { + if (server) { + server.close(done); + } else { + done(); + } + }); + + it('call service from ValueListReferences', async () => { + const loadFile = FileSystemLoader.prototype.loadFile; + const exists = FileSystemLoader.prototype.exists; + jest.spyOn(FileSystemLoader.prototype, 'exists').mockImplementation((path): Promise => { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { + return Promise.resolve(true); + } else { + return exists(path); + } + }); + loadFileSpy = jest + .spyOn(FileSystemLoader.prototype, 'loadFile') + .mockImplementation((path): Promise => { + if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) { + return Promise.resolve(` + + + + + `); + } else { + return loadFile(path); + } + }); + server = await createServer(true, 33332); + const response = await fetch( + `http://localhost:33332/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); + + expect(response.status).toEqual(200); + expect(loadFileSpy).toHaveBeenNthCalledWith( + 2, + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_companycodestdvh', + '0001', + 'CustomerParameters', + 'P_CompanyCode', + 'metadata.xml' + ) + ); + expect(loadFileSpy).not.toHaveBeenCalledWith( + path.join( + __dirname, + 'v4', + 'services', + 'parametrizedSample', + 'srvd_f4', + 'sap', + 'i_customer_vh', + '0001', + 'CustomerType', + 'Customer', + 'metadata.xml' + ) + ); + }); + }); + describe('resolveValueListReferences = false', () => { + let server: Server; + beforeAll(async function () { + server = await createServer(false, 33333); + }); + afterAll((done) => { + server.close(done); + }); + it('call service from ValueListReferences', async () => { + const response = await fetch( + `http://localhost:33333/sap/srvd_f4/sap/i_companycodestdvh/0001;ps=%27srvd-zrc_arcustomer_definition-0001%27;va=%27com.sap.gateway.srvd.zrc_arcustomer_definition.v0001.et-parameterz_arcustomer2.p_companycode%27/$metadata` + ); + + expect(response.status).toEqual(404); + }); + }); +}); + describe('V2', function () { let server: Server; beforeAll(async function () { @@ -778,6 +896,9 @@ describe('V2', function () { mockServer.getRouter()(req, res, finalHandler(req, res)); }); server.listen(33331); + server.on('close', async () => { + await mockServer.dispose(); + }); }); afterAll((done) => { diff --git a/packages/ui5-middleware-fe-mockserver/README.md b/packages/ui5-middleware-fe-mockserver/README.md index 0cc580e5a..709c9253a 100644 --- a/packages/ui5-middleware-fe-mockserver/README.md +++ b/packages/ui5-middleware-fe-mockserver/README.md @@ -74,7 +74,8 @@ On top of that you can specify one of the following option - mockdataPath : the path to the folder containing the mockdata files - generateMockData : whether or not you want to use automatically generated mockdata -- forceNullableValuesToNull: determine if nullable properties should be generated as null or with a default value (defaults to false) +- resolveValueListReferences : whether or not to try to resolve all services referenced in `Common.ValueListReferences` annotations and serve their metadata from `localServices` directory. +- forceNullableValuesToNull : determine if nullable properties should be generated as null or with a default value (defaults to false) Additional option are available either per service of for all services if defined globally diff --git a/packages/ui5-middleware-fe-mockserver/src/configResolver.ts b/packages/ui5-middleware-fe-mockserver/src/configResolver.ts index bca72e75a..4b83bf700 100644 --- a/packages/ui5-middleware-fe-mockserver/src/configResolver.ts +++ b/packages/ui5-middleware-fe-mockserver/src/configResolver.ts @@ -104,6 +104,7 @@ function processServicesConfig( generateMockData: inService.generateMockData, contextBasedIsolation: inService.contextBasedIsolation, forceNullableValuesToNull: inService.forceNullableValuesToNull, + resolveValueListReferences: inService.resolveValueListReferences, metadataProcessor: inService.metadataProcessor, i18nPath: inService.i18nPath, __captureAndSimulate: inService.__captureAndSimulate diff --git a/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts b/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts index 63053a96e..3e6e92b0f 100644 --- a/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts +++ b/packages/ui5-middleware-fe-mockserver/test/configResolver.test.ts @@ -128,6 +128,27 @@ describe('The config resolver', () => { expect(myBaseResolvedConfig2.services.length).toBe(0); }); + it('can also resolve resolveValueListReferences', () => { + const myBaseResolvedConfig = resolveConfig( + { + annotations: { + localPath: 'myAnnotation.xml', + urlPath: '/my/Annotation.xml' + }, + service: { + urlBasePath: '/my/service', + name: 'URL', + metadataCdsPath: 'metadata.cds', + mockdataRootPath: 'mockData', + resolveValueListReferences: true + } + }, + '/' + ); + + expect(myBaseResolvedConfig.services[0].resolveValueListReferences).toBe(true); + }); + it('can also apply overrides per service', () => { const myBaseResolvedConfig = resolveConfig( {