Skip to content
Draft

Muab #3822

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c5b82a6
feat: Log initial axios requests without data added from interceptors.
avasilev-sap Oct 12, 2025
3c4ad9b
fix: For requests we log initial data without such added from interce…
avasilev-sap Oct 12, 2025
e6f6336
feat: Add functionallity to save the moab config from the generator e…
avasilev-sap Oct 13, 2025
64b1f8f
feat: Merge .har file with final muab config
avasilev-sap Oct 16, 2025
0555870
docs: Add docs and todos.
avasilev-sap Oct 16, 2025
7d66d85
feat: Add mock server package in the monorepo.
avasilev-sap Nov 2, 2025
2853301
chore: Update .lock file and root tsconfig to be compliant with the n…
avasilev-sap Nov 2, 2025
0aad857
chore: Add mockserver-node as dependency.
avasilev-sap Nov 2, 2025
1a1d6de
chore: Update .lock file
avasilev-sap Nov 2, 2025
f642ba1
feat(incomplete): Add functionallity for recor/replay requests.
avasilev-sap Nov 4, 2025
ba2f3c8
chore: Disable axios traffic dump and .har file save
avasilev-sap Nov 4, 2025
d1b6123
refactor: Reorganize files and partition logic into separate files.
avasilev-sap Nov 6, 2025
156ef49
fix: Move types to dev dependency.
avasilev-sap Nov 6, 2025
5633179
feat(unstable): Mock requests with binary payload manually with mock-…
avasilev-sap Nov 6, 2025
9b9b200
fix: * Patch some types exported from mock-server framework.
avasilev-sap Nov 8, 2025
a93e920
refactor: Plish the code to be close to production ready. Some parame…
avasilev-sap Nov 8, 2025
bf74c27
test: Remove jest dependency since iot is proided by the monorepo alr…
avasilev-sap Nov 11, 2025
6c92121
chore: Add test case description related to the ABAP appdescr-variant…
avasilev-sap Nov 11, 2025
55e5782
test: Fix test mocks.
avasilev-sap Nov 11, 2025
05ba208
chore: Disable logging from axios traffic recorder + add logging for …
avasilev-sap Nov 12, 2025
d0092ca
chore: Add todos.
avasilev-sap Nov 13, 2025
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
1 change: 1 addition & 0 deletions packages/adp-mock-server/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
7 changes: 7 additions & 0 deletions packages/adp-mock-server/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
extends: ['../../.eslintrc'],
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: __dirname
}
};
2 changes: 2 additions & 0 deletions packages/adp-mock-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mock-data
CertificateAuthorityCertificate.pem
Empty file.
6 changes: 6 additions & 0 deletions packages/adp-mock-server/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const config = require('../../jest.base');
config.displayName = 'adp-mock-server';
config.rootDir = './';
config.testMatch = ['<rootDir>/test/unit/**/*.test.(ts|js)'];
config.collectCoverageFrom = ['<rootDir>/src/**/*.ts', '!<rootDir>/src/index.ts'];
module.exports = config;
47 changes: 47 additions & 0 deletions packages/adp-mock-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@sap-ux/adp-mock-server",
"version": "0.0.1",
"description": "Adaptation project mock server",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md",
"!dist/*.map",
"!dist/**/*.map"
],
"scripts": {
"build": "tsc --build",
"start": "ts-node ./src/index.ts --start",
"stop": "ts-node ./src/index.ts --stop",
"start:record": "ts-node ./src/index.ts --start --record",
"stop:record": "ts-node ./src/index.ts --stop --record",
"clean": "rimraf dist *.tsbuildinfo",
"test": "jest --ci --forceExit --detectOpenHandles --colors",
"watch": "tsc --build --watch"
},
"repository": {
"type": "git",
"url": "https://github.com/SAP/open-ux-tools.git",
"directory": "packages/adp-mock-server"
},
"license": "Apache-2.0",
"dependencies": {
"@sap-ux/logger": "workspace:*",
"adm-zip": "0.5.10",
"dotenv": "16.3.1",
"lodash": "4.17.21",
"mockserver-client": "5.15.0"
},
"devDependencies": {
"@types/adm-zip": "0.5.5",
"@types/lodash": "4.14.202",
"mockserver-node": "5.15.0",
"rimraf": "5.0.5",
"ts-node": "10.9.2",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.0"
}
}
52 changes: 52 additions & 0 deletions packages/adp-mock-server/src/client/record-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import fs from 'fs/promises';
import { omit, once } from 'lodash';
import { mockServerClient } from 'mockserver-client';
import type { MockServerClient } from 'mockserver-client/mockServerClient';
import { MOCK_SERVER_PORT, RESPONSES_JSON_PATH } from '../server-constants';
import { HttpRequestAndHttpResponse } from '../types';
import { logger } from '../utils/logger';
import { getSapSystemPort } from '../utils/sap-system-utils';

export const getRecordClient = once(getRecordClientInternal);

export async function recordRequestsAndResponses(): Promise<void> {
logger.info('Record responses.');
const client = await getRecordClient();
let requestAndResponseList = await retrieveRecordedRequestsAndResponses(client);
// TODO a.vasilev reponse.body = true does not work, mock server validation schema error is thrown when the server is started,
// need to be "true" what about false, maybe we can set the body to undefined for false values?!
requestAndResponseList = disableRequestsSecureFlag(requestAndResponseList);
await fs.writeFile(RESPONSES_JSON_PATH, JSON.stringify(requestAndResponseList, null, 2));
}

async function retrieveRecordedRequestsAndResponses(client: MockServerClient): Promise<HttpRequestAndHttpResponse[]> {
return client.retrieveRecordedRequestsAndResponses({}) as Promise<HttpRequestAndHttpResponse[]>;
}

function disableRequestsSecureFlag(requestAndResponseList: HttpRequestAndHttpResponse[]): HttpRequestAndHttpResponse[] {
return requestAndResponseList.map(({ httpRequest, httpResponse }) => ({
httpRequest: {
// TODO a.vasilev: Sometimes the settings request does not match
// the recordings due to a changed cookie. How is this possible, sb
// is changing the SAP_SESSIONID cookie in replay mode, how is that even
// possible???
...omit(httpRequest, ['headers', 'cookies']),
secure: false
},
httpResponse
}));
}

async function getRecordClientInternal(): Promise<MockServerClient> {
logger.info('Init mock server client.');
const client = mockServerClient('localhost', MOCK_SERVER_PORT);
await client.mockAnyResponse({
httpForward: {
// Forwards https requests to the actual sap system.
scheme: 'HTTPS',
host: process.env.SAP_SYSTEM_HOST,
port: getSapSystemPort()
}
});
return client;
}
93 changes: 93 additions & 0 deletions packages/adp-mock-server/src/client/replay-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { isEqual, once } from 'lodash';
import { HttpRequest, HttpResponse } from 'mockserver-client';
import { mockServerClient, MockServerClient } from 'mockserver-client/mockServerClient';
import { MOCK_SERVER_PORT } from '../server-constants';
import { Expectation } from '../types';
import { normalizeZipFileContent } from '../utils/file-utils';
import { HashMap, HashMapKeyComparator } from '../utils/hash-map';
import { HashSet } from '../utils/hash-set';
import { logger } from '../utils/logger';
import { isZipBody } from '../utils/type-guards';

const NOT_FOUND_RESPONSE: HttpResponse = {
statusCode: 404,
reasonPhrase: '[ADP] Not found.'
};

type RequestMatcher = Pick<HttpRequest, 'path' | 'method'>;

const zipRequestsComparator: HashMapKeyComparator<HttpRequest> = (requestA, requestB) =>
requestA.path === requestB.path &&
requestA.method === requestB.method &&
isEqual(requestA.queryStringParameters, requestB.queryStringParameters) &&
isZipBodyEqualWith(requestA.body, requestB.body);

const isZipBodyEqualWith = (aBody: unknown, bBody: unknown) => {
if (!isZipBody(aBody) || !isZipBody(bBody)) {
return false;
}
const aBodyBuffer = Buffer.from(aBody.base64Bytes, 'base64');
const bBodyBuffer = Buffer.from(bBody.base64Bytes, 'base64');
return normalizeZipFileContent(aBodyBuffer) === normalizeZipFileContent(bBodyBuffer);
};

const requestMatcherComparator = (matcherA: RequestMatcher, matcherB: RequestMatcher) =>
matcherA.path === matcherB.path && matcherA.method === matcherB.method;

export const getReplayClient = once(getReplayClientInternal);

async function getReplayClientInternal(): Promise<MockServerClient> {
logger.info('Init mock server client.');
const client = mockServerClient('localhost', MOCK_SERVER_PORT);
await patchZipRequestsAndResponses(client);
return client;
}

async function patchZipRequestsAndResponses(client: MockServerClient): Promise<void> {
const expectationsList = await retrieveActiveExpectations(client);
const zipExpectationsList = expectationsList.filter(({ httpRequest }) => isZipBody(httpRequest?.body));
const zipResponsesByRequestMap = new HashMap<HttpRequest, HttpResponse[]>(zipRequestsComparator);
zipExpectationsList.forEach(({ httpRequest, httpResponse }) => {
if (!httpRequest || !httpResponse) {
return;
}
if (zipResponsesByRequestMap.has(httpRequest)) {
zipResponsesByRequestMap.get(httpRequest)?.push(httpResponse);
} else {
zipResponsesByRequestMap.set(httpRequest, [httpResponse]);
}
});
const zipRequestMatcherSet = new HashSet<RequestMatcher>(requestMatcherComparator);
const zipRequestMatcherList = Array.from(zipResponsesByRequestMap.keys()).map(({ path, method }) => ({
path,
method
}));
zipRequestMatcherList.forEach((matcher) => {
zipRequestMatcherSet.add(matcher);
});
const zipResponseIteratorsByRequestMap = zipResponsesByRequestMap.map(
([httpRequest, httpResponses]) => [httpRequest, httpResponses.values()],
zipRequestsComparator
);

await Promise.all(
zipRequestMatcherSet.values().map((matcher) =>
client.mockWithCallback(
matcher,
(httpRequest) => {
if (!zipResponseIteratorsByRequestMap.has(httpRequest)) {
return NOT_FOUND_RESPONSE;
}
const httpResponsesIterator = zipResponseIteratorsByRequestMap.get(httpRequest);
const nextResponse = httpResponsesIterator?.next().value;
return nextResponse ?? NOT_FOUND_RESPONSE;
},
{ unlimited: true }
)
)
);
}

async function retrieveActiveExpectations(client: MockServerClient): Promise<Expectation[]> {
return client.retrieveActiveExpectations({}) as Promise<Expectation[]>;
}
82 changes: 82 additions & 0 deletions packages/adp-mock-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import dotenv from 'dotenv';
import { start_mockserver, stop_mockserver } from 'mockserver-node';
import path from 'path';
import { getRecordClient, recordRequestsAndResponses } from './client/record-client';
import { getReplayClient } from './client/replay-client';
import { EXPECTATIONS_JSON_PATH, MOCK_SERVER_PORT, RESPONSES_JSON_PATH } from './server-constants';
import { getCliParamValueByName } from './utils/cli-utils';
import { createMockDataFolderIfNeeded } from './utils/file-utils';
import { logger } from './utils/logger';
import { getSapSystemPort } from './utils/sap-system-utils';

const CLI_PARAM_START = 'start';
const CLI_PARAM_STOP = 'stop';
const CLI_PARAM_RECORD = 'record';
const NOOP = new Promise(() => {});

dotenv.config({ path: path.join(__dirname, '../.env') });

async function start(isRecordOrReplayMode: boolean): Promise<void> {
await createMockDataFolderIfNeeded();
if (isRecordOrReplayMode) {
await startInRecordMode();
} else {
await startInReplayMode();
}
}

async function startInRecordMode(): Promise<void> {
await start_mockserver({
serverPort: MOCK_SERVER_PORT,
// Does not support https forwarding without client https forwarding.
proxyRemoteHost: process.env.SAP_SYSTEM_HOST,
proxyRemotePort: getSapSystemPort(),
jvmOptions: [
'-Dmockserver.watchInitializationJson=true',
`-Dmockserver.initializationJsonPath=${EXPECTATIONS_JSON_PATH}`,
'-Dmockserver.persistExpectations=true',
`-Dmockserver.persistedExpectationsPath=${EXPECTATIONS_JSON_PATH}`
]
// verbose: true
});
logger.info(`✅ Server running on port ${MOCK_SERVER_PORT} in record mode.`);
await getRecordClient();
}

async function startInReplayMode(): Promise<void> {
await start_mockserver({
serverPort: MOCK_SERVER_PORT,
jvmOptions: [
'-Dmockserver.watchInitializationJson=true',
// We load all request/responses as expectations for the replay mode.
`-Dmockserver.initializationJsonPath=${RESPONSES_JSON_PATH}`,
'-Dmockserver.persistExpectations=false'
]
// verbose: true
});
logger.info(`✅ Server running on port ${MOCK_SERVER_PORT} in replay mode.`);
await getReplayClient();
}

async function stop(): Promise<void> {
if (getCliParamValueByName(CLI_PARAM_RECORD)) {
await recordRequestsAndResponses();
}
await stop_mockserver({ serverPort: MOCK_SERVER_PORT });
logger.info('Stop mock server.');
}

async function main(): Promise<void> {
if (getCliParamValueByName(CLI_PARAM_START)) {
await start(getCliParamValueByName(CLI_PARAM_RECORD));
// Keep alive.
await NOOP;
} else if (getCliParamValueByName(CLI_PARAM_STOP)) {
await stop();
}
}

main().catch((error) => {
logger.error(`Unexpected error: ${error}.`);
process.exit(1);
});
6 changes: 6 additions & 0 deletions packages/adp-mock-server/src/server-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MOCK_SERVER_PORT = 1080;
export const DEFAULT_SAP_SYSTEM_PORT = 44355;

export const MOCK_DATA_FOLDER_PATH = './mock-data';
export const EXPECTATIONS_JSON_PATH = `${MOCK_DATA_FOLDER_PATH}/expectations.json`;
export const RESPONSES_JSON_PATH = `${MOCK_DATA_FOLDER_PATH}/responses.json`;
31 changes: 31 additions & 0 deletions packages/adp-mock-server/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { HttpRequest, HttpResponse, Expectation as MockServerExpectation } from 'mockserver-client';
import { HttpRequestAndHttpResponse as MockServerHttpRequestAndHttpResponse } from 'mockserver-client/mockServer';

export type HttpRequestAndHttpResponse = Omit<MockServerHttpRequestAndHttpResponse, 'httpRequest' | 'httpResponse'> & {
httpRequest?: HttpRequest;
httpResponse?: HttpResponse;
};

export type Expectation = Omit<MockServerExpectation, 'httpRequest'> & { httpRequest?: HttpRequest };

export type BodyType =
| 'BINARY'
| 'JSON'
| 'JSON_SCHEMA'
| 'JSON_PATH'
| 'PARAMETERS'
| 'REGEX'
| 'STRING'
| 'XML'
| 'XML_SCHEMA'
| 'XPATH';

export interface Body {
type: BodyType;
not?: boolean;
contentType?: string;
}

export interface BinaryBody extends Body {
base64Bytes: string;
}
32 changes: 32 additions & 0 deletions packages/adp-mock-server/src/utils/cli-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type CliParamValue = string | number | boolean | undefined;

export function getCliParamValueByName<T extends CliParamValue>(name: string): T {
const arg = process.argv.find((arg) => arg.startsWith(`--${name}`));

if (!arg) {
return undefined as T;
}

const value = arg.split('=')[1];

if (!value) {
// If we have param without a value we return true, e.g. --record.
return true as T;
}

const valueToLowerCase = value.toLowerCase();
if (valueToLowerCase === 'true') {
return true as T;
}

if (valueToLowerCase === 'false') {
return false as T;
}

const numericValue = Number(value);
if (!isNaN(numericValue)) {
return numericValue as T;
}

return value as T;
}
Loading