Skip to content
Draft
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
9 changes: 9 additions & 0 deletions docs/Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
19 changes: 19 additions & 0 deletions src/linter/manifestJson/ManifestLinter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
JSONSchemaForSAPUI5Namespace,
JSONSchemaForSAPAPPNamespace,
JSONSchemaForSAPCLOUDNamespace,
Model as ManifestModel,
DataSource as ManifestDataSource,
} from "../../manifest.d.ts";
Expand All @@ -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"];

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions src/linter/manifestJson/fix/ReplaceJsonValueFix.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
13 changes: 13 additions & 0 deletions src/linter/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
51 changes: 51 additions & 0 deletions test/lib/linter/manifestJson/ManifestDataSourceUri.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createMockedLinterModules>>["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);
});
103 changes: 103 additions & 0 deletions test/lib/linter/manifestJson/fix/ReplaceJsonValueFix.ts
Original file line number Diff line number Diff line change
@@ -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);
});