Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/purple-roses-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sap-ux/preview-middleware': patch
'@sap-ux/adp-tooling': patch
---

feat: Serve merged manifest.json via proxy in CF ADP preview mode
56 changes: 48 additions & 8 deletions packages/adp-tooling/src/preview/adp-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import RoutesHandler from './routes-handler';
import OvpRoutesHandler from './ovp-routes-handler';
import type {
AdpPreviewConfig,
AdpPreviewConfigWithTarget,
CommonChangeProperties,
DescriptorVariant,
OperationType,
CommonAdditionalChangeInfoProperties
CommonAdditionalChangeInfoProperties,
AdpPreviewConfigWithBuildPath
} from '../types';
import type { Editor } from 'mem-fs-editor';
import {
Expand All @@ -37,7 +39,8 @@ import {
isV4DescriptorChange
} from './change-handler';
import { addCustomFragment } from './descriptor-change-handler';
import { getExistingAdpProjectType } from '../base/helper';
import { getExistingAdpProjectType, readManifestFromBuildPath } from '../base/helper';
import { runBuild } from '../base/project-builder';
import path from 'node:path';
declare global {
// false positive, const can't be used here https://github.com/eslint/eslint/issues/15896
Expand Down Expand Up @@ -79,6 +82,10 @@ export class AdpPreview {
private lrep: LayeredRepositoryService | undefined;
private descriptorVariantId: string | undefined;
private projectTypeValue?: AdaptationProjectType;
/**
* Flag to indicate if the preview is running in CF ADP build mode, where the manifest is read from the build output instead of being fetched from the backend.
*/
private readonly isCfBuildMode: boolean;

/**
* @returns merged manifest.
Expand Down Expand Up @@ -137,7 +144,9 @@ export class AdpPreview {
private readonly project: ReaderCollection,
private readonly util: MiddlewareUtils,
private readonly logger: ToolsLogger
) {}
) {
this.isCfBuildMode = 'cfBuildPath' in config;
}

/**
* Fetch all required configurations from the backend and initialize all configurations.
Expand All @@ -146,14 +155,15 @@ export class AdpPreview {
* @returns {Promise<UI5FlexLayer>} The UI5 flex layer for which editing is enabled.
*/
async init(descriptorVariant: DescriptorVariant): Promise<UI5FlexLayer> {
if ('cfBuildPath' in this.config) {
if (this.isCfBuildMode) {
return this.initCfBuildMode(descriptorVariant);
}

const config = this.config as AdpPreviewConfigWithTarget;
this.descriptorVariantId = descriptorVariant.id;
this.provider = await createAbapServiceProvider(
this.config.target,
{ ignoreCertErrors: this.config.ignoreCertErrors },
config.target,
{ ignoreCertErrors: config.ignoreCertErrors },
true,
this.logger
);
Expand All @@ -179,6 +189,16 @@ export class AdpPreview {
this.descriptorVariantId = descriptorVariant.id;
this.projectTypeValue = undefined;
this.routesHandler = new RoutesHandler(this.project, this.util, {} as AbapServiceProvider, this.logger);

const config = this.config as AdpPreviewConfigWithBuildPath;
const manifest = readManifestFromBuildPath(config.cfBuildPath) as MergedAppDescriptor['manifest'];
this.mergedDescriptor = {
name: descriptorVariant.id,
url: '/',
manifest,
asyncHints: { libs: [], components: [] }
};

return descriptorVariant.layer;
}

Expand All @@ -187,10 +207,13 @@ export class AdpPreview {
* The descriptor is refreshed only if the global flag is set to true.
*/
async sync(): Promise<void> {
if ('cfBuildPath' in this.config) {
if (!global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ && this.mergedDescriptor) {
return;
}
if (!global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ && this.mergedDescriptor) {
if (this.isCfBuildMode) {
await runBuild(this.util.getProject().getRootPath(), { ADP_BUILDER_MODE: 'preview' });
const buildPath = (this.config as AdpPreviewConfigWithBuildPath).cfBuildPath;
this.mergedDescriptor.manifest = readManifestFromBuildPath(buildPath) as MergedAppDescriptor['manifest'];
return;
}
if (!this.lrep || !this.descriptorVariantId) {
Expand Down Expand Up @@ -233,6 +256,23 @@ export class AdpPreview {
}
}

/**
* CF build mode proxy that intercepts manifest.json requests and delegates everything else to the next middleware.
*
* @param req incoming request
* @param res outgoing response object
* @param next next middleware that is to be called if the request cannot be handled
*/
async cfProxy(req: Request, res: Response, next: NextFunction): Promise<void> {
if (req.path === '/manifest.json') {
await this.sync();
res.status(200);
res.send(JSON.stringify(this.descriptor.manifest, undefined, 2));
} else {
next();
}
}

/**
* Add additional APIs to the router that are required for adaptation projects only.
*
Expand Down
106 changes: 102 additions & 4 deletions packages/adp-tooling/test/unit/preview/adp-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as systemAccess from '@sap-ux/system-access/dist/base/connect';
import * as serviceWriter from '@sap-ux/odata-service-writer/dist/data/annotations';

import * as helper from '../../../src/base/helper';
import * as projectBuilder from '../../../src/base/project-builder';
import * as editors from '../../../src/writer/editors';
import { AdpPreview } from '../../../src';
import * as manifestService from '../../../src/base/abap/manifest-service';
Expand Down Expand Up @@ -242,6 +243,9 @@ describe('AdaptationProject', () => {
});

test('should initialize with cfBuildPath mode', async () => {
const mockCfManifest = { 'sap.app': { id: 'cf.test.app' } };
jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any);

const adp = new AdpPreview(
{
target: {
Expand All @@ -262,6 +266,10 @@ describe('AdaptationProject', () => {
expect(adp['descriptorVariantId']).toBe(parsedVariant.id);
expect(adp['routesHandler']).toBeDefined();
expect(adp['provider']).toBeUndefined();
expect(adp.descriptor).toBeDefined();
expect(adp.descriptor.manifest).toEqual(mockCfManifest);
expect(adp.descriptor.url).toBe('/');
expect(adp.descriptor.name).toBe(parsedVariant.id);
});
});

Expand Down Expand Up @@ -295,8 +303,11 @@ describe('AdaptationProject', () => {
global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = false;
});

test('should return early when cfBuildPath is set', async () => {
// Create a separate nock scope for this test to avoid interfering with other tests
test('should return early when cfBuildPath is set and no sync required', async () => {
const mockCfManifest = { 'sap.app': { id: 'cf.test.app' } };
jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any);
const runBuildSpy = jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue();

const testBackend = 'https://test-backend.example';
const adp = new AdpPreview(
{
Expand All @@ -313,9 +324,46 @@ describe('AdaptationProject', () => {
const parsedVariant = JSON.parse(descriptorVariant);
await adp.init(parsedVariant);

// sync should return immediately without making any backend calls
// Since cfBuildPath is set, sync should return early
// sync should return early because mergedDescriptor is already set and no sync required
await adp.sync();
expect(runBuildSpy).not.toHaveBeenCalled();
});

test('should rebuild and re-read manifest when sync required in cfBuildPath mode', async () => {
const initialManifest = { 'sap.app': { id: 'cf.test.app' } };
const updatedManifest = { 'sap.app': { id: 'cf.test.app.updated' } };
const readManifestSpy = jest
.spyOn(helper, 'readManifestFromBuildPath')
.mockReturnValueOnce(initialManifest as any)
.mockReturnValueOnce(updatedManifest as any);
const runBuildSpy = jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue();

const testBackend = '/test-backend';
const adp = new AdpPreview(
{
target: {
url: testBackend
},
cfBuildPath: 'dist'
},
mockProject as unknown as ReaderCollection,
middlewareUtil,
logger
);

const parsedVariant = JSON.parse(descriptorVariant);
await adp.init(parsedVariant);
expect(adp.descriptor.manifest).toEqual(initialManifest);

// Trigger sync
global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = true;
await adp.sync();

expect(runBuildSpy).toHaveBeenCalledWith('/projects/adp.project', {
ADP_BUILDER_MODE: 'preview'
});
expect(readManifestSpy).toHaveBeenLastCalledWith('dist');
expect(adp.descriptor.manifest).toEqual(updatedManifest);
});

test('updates merged descriptor', async () => {
Expand Down Expand Up @@ -508,6 +556,52 @@ describe('AdaptationProject', () => {
});
});

describe('cfProxy', () => {
let server: supertest.Agent;
const next = jest.fn().mockImplementation((_req, res) => res.status(200).send());

beforeAll(async () => {
const mockCfManifest = { 'sap.app': { id: 'cf.proxy.test' } };
jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue(mockCfManifest as any);
jest.spyOn(projectBuilder, 'runBuild').mockResolvedValue();

const adp = new AdpPreview(
{
target: {
url: backend
},
cfBuildPath: 'dist'
},
mockProject as unknown as ReaderCollection,
middlewareUtil,
logger
);

await adp.init(JSON.parse(descriptorVariant));

const app = express();
app.use(adp.descriptor.url, adp.cfProxy.bind(adp));
app.use(next);

server = supertest(app);
});

afterEach(() => {
global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = false;
});

test('/manifest.json serves merged manifest', async () => {
const response = await server.get('/manifest.json').expect(200);
expect(JSON.parse(response.text)).toEqual({ 'sap.app': { id: 'cf.proxy.test' } });
});

test('other requests call next()', async () => {
next.mockClear();
await server.get('/some-other-file.js').expect(200);
expect(next).toHaveBeenCalled();
});
});

describe('onChangeRequest', () => {
const mockFs = {} as unknown as Editor;
const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger;
Expand Down Expand Up @@ -982,6 +1076,10 @@ describe('AdaptationProject', () => {
describe('addApis - cfBuildPath mode', () => {
let cfBuildPathServer: supertest.Agent;
beforeAll(async () => {
jest.spyOn(helper, 'readManifestFromBuildPath').mockReturnValue({
'sap.app': { id: 'cf.api.test' }
} as any);

const adp = new AdpPreview(
{
target: {
Expand Down
6 changes: 4 additions & 2 deletions packages/preview-middleware/src/base/flp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ export class FlpSandbox {

// CF ADP build path mode: serve built resources directly from build output
if ('cfBuildPath' in config) {
const manifest = this.setupCfBuildMode(config.cfBuildPath);
const manifest = this.setupCfBuildMode(config.cfBuildPath, adp);
configureRta(this.rta, layer, variant.id, false, true);
await this.init(manifest, variant.reference);
await this.setupAdpCommonHandlers(adp);
Expand Down Expand Up @@ -1255,10 +1255,12 @@ export class FlpSandbox {
* Setup the CF build path mode for the ADP project.
*
* @param cfBuildPath path to the build output folder
* @param adp AdpPreview instance for proxying manifest requests
* @returns the manifest
*/
private setupCfBuildMode(cfBuildPath: string): Manifest {
private setupCfBuildMode(cfBuildPath: string, adp: AdpPreview): Manifest {
const manifest = readManifestFromBuildPath(cfBuildPath);
this.router.use(adp.descriptor.url, adp.cfProxy.bind(adp));
this.router.use('/', serveStatic(cfBuildPath));
this.logger.info(`Initialized CF ADP with cfBuildPath, serving from ${cfBuildPath}`);
return manifest;
Expand Down
1 change: 1 addition & 0 deletions packages/preview-middleware/test/unit/base/flp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1959,6 +1959,7 @@ describe('initAdp', () => {
},
resources: [],
proxy: jest.fn(),
cfProxy: jest.fn(),
sync: syncSpy,
onChangeRequest: jest.fn(),
addApis: jest.fn(),
Expand Down
Loading