Skip to content
5 changes: 5 additions & 0 deletions .changeset/pink-zebras-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-ux/fe-mockserver-core': patch
---

feat: dynamically register services defined in `Common.ValueListReferences` annotation
4 changes: 4 additions & 0 deletions packages/fe-mockserver-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Service {
cdsServiceName?: string;
debug?: boolean;
contextBasedIsolation?: boolean;
resolveValueListReferences?: boolean;
strictKeyMode?: boolean;
watch?: boolean;
noETag?: boolean;
Expand All @@ -27,6 +28,7 @@ export interface ConfigService {
mockdataRootPath?: string;
mockdataPath?: string;
generateMockData?: boolean;
resolveValueListReferences?: boolean;
metadataCdsPath?: string;
metadataPath?: string;
cdsServiceName?: string;
Expand Down Expand Up @@ -69,6 +71,7 @@ export interface BaseServerConfig {
logger?: ILogger;
validateETag?: boolean;
contextBasedIsolation?: boolean;
resolveValueListReferences?: boolean;
generateMockData?: boolean;
forceNullableValuesToNull?: boolean;
fileLoader?: string;
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions packages/fe-mockserver-core/src/data/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
35 changes: 34 additions & 1 deletion packages/fe-mockserver-core/src/data/serviceRegistry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@ export class ServiceRegistry {
private readonly services: Map<string, DataAccessInterface> = new Map();
private readonly aliases: Map<string, string> = new Map();
private readonly registrations: Map<string, ServiceRegistration> = new Map();
private readonly watchers: FSWatcher[] = [];
private config: MockserverConfiguration;
private isOpened: boolean = false;

Expand Down Expand Up @@ -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);
Expand All @@ -156,7 +183,7 @@ export class ServiceRegistry {
watchPath.push(mockService.metadataPath);
}
const chokidar = await import('chokidar');
chokidar
const watcher = chokidar
.watch(watchPath, {
ignoreInitial: true
})
Expand All @@ -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);
Expand Down Expand Up @@ -327,4 +355,9 @@ export class ServiceRegistry {
})
.join(', ');
}
public async dispose(): Promise<void> {
for (const watcher of this.watchers) {
await watcher.close();
}
}
}
4 changes: 4 additions & 0 deletions packages/fe-mockserver-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,8 @@ export default class FEMockserver {
getRouter() {
return this.mainRouter;
}

async dispose(): Promise<void> {
await this.serviceRegistry.dispose();
}
}
147 changes: 134 additions & 13 deletions packages/fe-mockserver-core/test/unit/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<any>('/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<any>('/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', {
Expand Down Expand Up @@ -753,6 +751,126 @@ Group ID: $auto`
});
});

describe('services from ValueListReferences', () => {
async function createServer(resolveValueListReferences: boolean, port: number): Promise<Server> {
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<boolean> => {
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<string> => {
if (path.includes('i_companycodestdvh') && path.includes('metadata.xml')) {
return Promise.resolve(`<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
<edmx:DataServices>
<Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="local">
</Schema>
</edmx:DataServices>
</edmx:Edmx>`);
} 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 () {
Expand All @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui5-middleware-fe-mockserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading