From 3c08599c06e0f23f5474d3ff091f809c6689c24d Mon Sep 17 00:00:00 2001 From: Aleksandr Suvorov Date: Tue, 17 Feb 2026 13:24:15 +0100 Subject: [PATCH] feat: Add rule 'No absolute DataSource Uri' for CF platform --- docs/Rules.md | 9 ++ src/linter/manifestJson/ManifestLinter.ts | 19 ++++ .../manifestJson/fix/ReplaceJsonValueFix.ts | 43 ++++++++ src/linter/messages.ts | 13 +++ .../absolute-data-source-uri/manifest.json | 23 ++++ .../manifest.json | 16 +++ .../manifestJson/ManifestDataSourceUri.ts | 51 +++++++++ .../manifestJson/fix/ReplaceJsonValueFix.ts | 103 ++++++++++++++++++ 8 files changed, 277 insertions(+) create mode 100644 src/linter/manifestJson/fix/ReplaceJsonValueFix.ts create mode 100644 test/fixtures/linter/manifestJson/absolute-data-source-uri/manifest.json create mode 100644 test/fixtures/linter/manifestJson/relative-data-source-uri-no-sap-cloud/manifest.json create mode 100644 test/lib/linter/manifestJson/ManifestDataSourceUri.ts create mode 100644 test/lib/linter/manifestJson/fix/ReplaceJsonValueFix.ts diff --git a/docs/Rules.md b/docs/Rules.md index ff3108909..041515ce0 100644 --- a/docs/Rules.md +++ b/docs/Rules.md @@ -20,6 +20,7 @@ - [no-outdated-manifest-version](#no-outdated-manifest-version) - [no-removed-manifest-property](#no-removed-manifest-property) - [no-legacy-ui5-version-in-manifest](#no-legacy-ui5-version-in-manifest) + - [no-absolute-data-source-uri](#no-absolute-data-source-uri) ## async-component-flags @@ -138,3 +139,11 @@ This rule ensures that projects specify a minimum UI5 version that supports mode **Related information** - [Manifest for Components](https://ui5.sap.com/#/topic/be0cf40f61184b358b5faedaec98b2da#loiobe0cf40f61184b358b5faedaec98b2da/section_manifest2) + +## no-absolute-data-source-uri + +Checks `sap.app/dataSources/*/uri` in `manifest.json` for Cloud Foundry platform (`sap.cloud/service` should be set). The URI must be relative to the base URL - must not start with `/`. + +**Related information** +- [Develop a Full-Stack CAP Application Following SAP BTP Developer’s Guide](https://developers.sap.com/tutorials/prep-for-prod..html) +- [Accessing Business Service UI](https://help.sap.com/docs/btp/sap-business-technology-platform/accessing-business-service-ui) diff --git a/src/linter/manifestJson/ManifestLinter.ts b/src/linter/manifestJson/ManifestLinter.ts index e5729b7fb..53a4a412a 100644 --- a/src/linter/manifestJson/ManifestLinter.ts +++ b/src/linter/manifestJson/ManifestLinter.ts @@ -1,6 +1,7 @@ import type { JSONSchemaForSAPUI5Namespace, JSONSchemaForSAPAPPNamespace, + JSONSchemaForSAPCLOUDNamespace, Model as ManifestModel, DataSource as ManifestDataSource, } from "../../manifest.d.ts"; @@ -13,6 +14,7 @@ import {MESSAGE} from "../messages.js"; import semver from "semver"; import {jsonSourceMapType, parseManifest} from "./parser.js"; import RemoveJsonPropertyFix from "./fix/RemoveJsonPropertyFix.js"; +import ReplaceJsonValueFix from "./fix/ReplaceJsonValueFix.js"; const deprecatedViewTypes = ["JSON", "HTML", "JS", "Template"]; @@ -75,6 +77,7 @@ export default class ManifestLinter { const {resources, models, dependencies, rootView, routing} = (manifest["sap.ui5"] ?? {} as JSONSchemaForSAPUI5Namespace); const {dataSources} = (manifest["sap.app"] ?? {} as JSONSchemaForSAPAPPNamespace); + const {service} = (manifest["sap.cloud"] ?? {} as JSONSchemaForSAPCLOUDNamespace); // Validate async flags for manifest version 2 if (isManifest2) { @@ -203,6 +206,22 @@ export default class ManifestLinter { }, key, fix); } }); + + if (typeof service === "string" && dataSources) { + for (const [dataSourceName, dataSource] of Object.entries(dataSources)) { + const uri = dataSource.uri; + if (typeof uri === "string" && uri.startsWith("/")) { + const fix = new ReplaceJsonValueFix({ + key: `/sap.app/dataSources/${dataSourceName}/uri`, + pointers: source.pointers, + value: uri.slice(1), + }); + this.#reporter?.addMessage(MESSAGE.NO_ABSOLUTE_DATA_SOURCE_URI, { + dataSourceName, + }, `/sap.app/dataSources/${dataSourceName}/uri`, fix); + } + } + } } #validateAsyncFlagsForManifestV2( diff --git a/src/linter/manifestJson/fix/ReplaceJsonValueFix.ts b/src/linter/manifestJson/fix/ReplaceJsonValueFix.ts new file mode 100644 index 000000000..d76f9e509 --- /dev/null +++ b/src/linter/manifestJson/fix/ReplaceJsonValueFix.ts @@ -0,0 +1,43 @@ +import {Pointers} from "json-source-map"; +import {ChangeAction, ChangeSet} from "../../../utils/textChanges.js"; +import {JsonFix} from "./JsonFix.js"; + +interface ReplaceJsonValueFixOptions { + key: string; + pointers: Pointers; + value: string; +} + +export default class ReplaceJsonValueFix extends JsonFix { + #value: string; + + constructor(options: ReplaceJsonValueFixOptions) { + super(); + this.#value = JSON.stringify(options.value); + this.calculatePositions(options.key, options.pointers); + } + + calculatePositions(key: string, pointers: Pointers) { + const currentPointer = pointers[key]; + if (!currentPointer) { + throw new Error(`Cannot find JSON pointer: '${key}'`); + } + if (!currentPointer.value) { + throw new Error(`Cannot replace non-value pointer: '${key}'`); + } + this.startPos = currentPointer.value.pos; + this.endPos = currentPointer.valueEnd.pos; + } + + generateChanges(): ChangeSet | ChangeSet[] | undefined { + if (this.startPos === undefined || this.endPos === undefined) { + return undefined; + } + return { + action: ChangeAction.REPLACE, + start: this.startPos, + end: this.endPos, + value: this.#value, + }; + } +} diff --git a/src/linter/messages.ts b/src/linter/messages.ts index f074a89df..d0308fe16 100644 --- a/src/linter/messages.ts +++ b/src/linter/messages.ts @@ -21,6 +21,7 @@ export const RULES = { "no-outdated-manifest-version": "no-outdated-manifest-version", "no-removed-manifest-property": "no-removed-manifest-property", "no-legacy-ui5-version-in-manifest": "no-legacy-ui5-version-in-manifest", + "no-absolute-data-source-uri": "no-absolute-data-source-uri", } as const; export enum LintMessageSeverity { @@ -69,6 +70,7 @@ export enum MESSAGE { NO_ICON_POOL_RENDERER, NO_LEGACY_TEMPLATE_REQUIRE_SYNTAX, NO_LEGACY_UI5_VERSION_IN_MANIFEST, + NO_ABSOLUTE_DATA_SOURCE_URI, NO_ODATA_GLOBALS, NO_OUTDATED_MANIFEST_VERSION, NO_REMOVED_MANIFEST_PROPERTY, @@ -726,6 +728,17 @@ export const MESSAGE_INFO = { "Manifest Version 2}", }, + [MESSAGE.NO_ABSOLUTE_DATA_SOURCE_URI]: { + severity: LintMessageSeverity.Error, + ruleId: RULES["no-absolute-data-source-uri"], + + message: ({dataSourceName}: {dataSourceName: string}) => + `Data source '${dataSourceName}' uri must be relative to the base URL in Cloud Foundry platform ` + + "('sap.cloud/service' is set)", + details: ({dataSourceName}: {dataSourceName: string}) => + `Remove the leading '/' from sap.app/dataSources/${dataSourceName}/uri to make it relative to the base URL`, + }, + [MESSAGE.NO_REMOVED_MANIFEST_PROPERTY]: { severity: LintMessageSeverity.Error, ruleId: RULES["no-removed-manifest-property"], diff --git a/test/fixtures/linter/manifestJson/absolute-data-source-uri/manifest.json b/test/fixtures/linter/manifestJson/absolute-data-source-uri/manifest.json new file mode 100644 index 000000000..386266f3d --- /dev/null +++ b/test/fixtures/linter/manifestJson/absolute-data-source-uri/manifest.json @@ -0,0 +1,23 @@ +{ + "_version": "2.0.0", + "sap.app": { + "id": "my.app", + "type": "application", + "dataSources": { + "mainService": { + "uri": "/sap/opu/odata/sap/ZMY_SRV/", + "type": "OData", + "settings": { + "odataVersion": "2.0" + } + }, + "relativeService": { + "uri": "sap/opu/odata/sap/ZMY_SRV/", + "type": "OData" + } + } + }, + "sap.cloud": { + "service": "my.service" + } +} diff --git a/test/fixtures/linter/manifestJson/relative-data-source-uri-no-sap-cloud/manifest.json b/test/fixtures/linter/manifestJson/relative-data-source-uri-no-sap-cloud/manifest.json new file mode 100644 index 000000000..2203cc104 --- /dev/null +++ b/test/fixtures/linter/manifestJson/relative-data-source-uri-no-sap-cloud/manifest.json @@ -0,0 +1,16 @@ +{ + "_version": "2.0.0", + "sap.app": { + "id": "my.app", + "type": "application", + "dataSources": { + "mainService": { + "uri": "/sap/opu/odata/sap/ZMY_SRV/", + "type": "OData", + "settings": { + "odataVersion": "2.0" + } + } + } + } +} diff --git a/test/lib/linter/manifestJson/ManifestDataSourceUri.ts b/test/lib/linter/manifestJson/ManifestDataSourceUri.ts new file mode 100644 index 000000000..b84d69a2a --- /dev/null +++ b/test/lib/linter/manifestJson/ManifestDataSourceUri.ts @@ -0,0 +1,51 @@ +import anyTest, {TestFn} from "ava"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import sinonGlobal from "sinon"; +import {createMockedLinterModules} from "../_linterHelper.js"; +import SharedLanguageService from "../../../../src/linter/ui5Types/SharedLanguageService.js"; +import {RULES} from "../../../../src/linter/messages.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesBasePath = path.join(__dirname, "..", "..", "..", "fixtures", "linter", "manifestJson"); + +const test = anyTest as TestFn<{ + sinon: sinonGlobal.SinonSandbox; + lintFile: Awaited>["lintModule"]["lintFile"]; + sharedLanguageService: SharedLanguageService; +}>; + +test.beforeEach(async (t) => { + t.context.sinon = sinonGlobal.createSandbox(); + const {lintModule: {lintFile}} = await createMockedLinterModules(t.context.sinon); + t.context.lintFile = lintFile; + t.context.sharedLanguageService = new SharedLanguageService(); +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test.serial("reports absolute dataSources uri when sap.cloud/service is set", async (t) => { + const rootDir = path.join(fixturesBasePath, "absolute-data-source-uri"); + const res = await t.context.lintFile({ + rootDir, + filePatterns: ["manifest.json"], + details: true, + }, t.context.sharedLanguageService); + + t.is(res.length, 1); + t.is(res[0].messages.length, 1); + t.is(res[0].messages[0].ruleId, RULES["no-absolute-data-source-uri"]); +}); + +test.serial("does not report when sap.cloud/service is missing", async (t) => { + const rootDir = path.join(fixturesBasePath, "relative-data-source-uri-no-sap-cloud"); + const res = await t.context.lintFile({ + rootDir, + filePatterns: ["manifest.json"], + details: true, + }, t.context.sharedLanguageService); + + t.is(res.length, 0); +}); diff --git a/test/lib/linter/manifestJson/fix/ReplaceJsonValueFix.ts b/test/lib/linter/manifestJson/fix/ReplaceJsonValueFix.ts new file mode 100644 index 000000000..4386767d9 --- /dev/null +++ b/test/lib/linter/manifestJson/fix/ReplaceJsonValueFix.ts @@ -0,0 +1,103 @@ +import test, {ExecutionContext} from "ava"; +import jsonMap from "json-source-map"; +import type {Pointers} from "json-source-map"; +import ReplaceJsonValueFix from "../../../../../src/linter/manifestJson/fix/ReplaceJsonValueFix.js"; +import {applyChanges} from "../../../../../src/utils/textChanges.js"; + +interface AssertValueReplaceOptions { + source: string; + replaceKey: string; + value: string; + expected: string; +} + +function assertValueReplacement( + t: ExecutionContext, {source, replaceKey, value, expected}: AssertValueReplaceOptions +) { + const {pointers} = jsonMap.parse(source); + const fix = new ReplaceJsonValueFix({key: replaceKey, pointers, value}); + + let changes = fix.generateChanges() ?? []; + if (!Array.isArray(changes)) { + changes = [changes]; + } + + const output = applyChanges(source, changes); + t.is(output, expected); + t.notThrows(() => JSON.parse(output), "Output should be valid JSON"); +} + +test("Should replace simple property value", (t) => { + assertValueReplacement(t, { + source: `{ "text": "Hello World" }`, + replaceKey: "/text", + value: "Updated", + expected: `{ "text": "Updated" }`, + }); +}); + +test("Should replace nested property value", (t) => { + assertValueReplacement(t, { + source: `{ + "settings": { + "text": "Hello World" + } + }`, + replaceKey: "/settings/text", + value: "Updated", + expected: `{ + "settings": { + "text": "Updated" + } + }`, + }); +}); + +test("Should replace value with leading slash", (t) => { + assertValueReplacement(t, { + source: `{ "uri": "/sap/opu/odata/sap/ZMY_SRV/" }`, + replaceKey: "/uri", + value: "sap/opu/odata/sap/ZMY_SRV/", + expected: `{ "uri": "sap/opu/odata/sap/ZMY_SRV/" }`, + }); +}); + +test("Should replace value with escaped content", (t) => { + assertValueReplacement(t, { + source: `{ "text": "Hello World" }`, + replaceKey: "/text", + value: "Line 1\nLine 2", + expected: `{ "text": "Line 1\\nLine 2" }`, + }); +}); + +test("Should throw when pointer does not exist", (t) => { + const {pointers} = jsonMap.parse(`{ "text": "Hello World" }`); + t.throws(() => { + new ReplaceJsonValueFix({key: "/missing", pointers, value: "Updated"}); + }, {message: "Cannot find JSON pointer: '/missing'"}); +}); + +test("Should throw when pointer has no value", (t) => { + const pointers = { + "/text": { + key: {line: 0, column: 0, pos: 0}, + keyEnd: {line: 0, column: 0, pos: 0}, + value: undefined, + valueEnd: undefined, + }, + } as unknown as Pointers; + + t.throws(() => { + new ReplaceJsonValueFix({key: "/text", pointers, value: "Updated"}); + }, {message: "Cannot replace non-value pointer: '/text'"}); +}); + +test("Should return undefined when positions are missing", (t) => { + const {pointers} = jsonMap.parse(`{ "text": "Hello World" }`); + const fix = new ReplaceJsonValueFix({key: "/text", pointers, value: "Updated"}); + fix.startPos = undefined; + fix.endPos = undefined; + + t.is(fix.generateChanges(), undefined); +});