generated from UI5/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 5
feat: Add manifest validation tool #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dimovpetar
wants to merge
14
commits into
UI5:main
Choose a base branch
from
dimovpetar:feat_run_manifest_validation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 12 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
6fed175
feat: Add manifest validation tool
dimovpetar 278273f
refactor: Move manifest schema fetching to ui5Manifest.ts
dimovpetar dd54cd2
test(index.ts): Add tests
dimovpetar 00de711
refactor(ui5Manifest.ts): Cache the schema
dimovpetar d6970ea
docs(README.md): List run_manifest_validation
dimovpetar a5ddede
refactor(ui5Manifest): Add cache
dimovpetar f08c45d
refactor: Improve error handling
dimovpetar a74700e
test(runValidation): Add tests
dimovpetar 3c6b99c
refactor: Add comment containing link to AdaptiveCards issue
dimovpetar 4c49e47
fix(package.json): List ajv as dependency
dimovpetar 4c87335
fix(runValidation): Resolve meta schemas paths using import.meta.resolve
dimovpetar 0f4d9df
fix(runValidation): Throw error if manifest path is not absolute
dimovpetar 593f749
fix(run_manifest_validation): Normalize manifest path
dimovpetar 7d71c5e
refactor: Fetch concrete manifest schema
dimovpetar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import runValidation from "./runValidation.js"; | ||
| import {inputSchema, outputSchema} from "./schema.js"; | ||
| import {getLogger} from "@ui5/logger"; | ||
| import Context from "../../Context.js"; | ||
| import {RegisterTool} from "../../registerTools.js"; | ||
|
|
||
| const log = getLogger("tools:run_manifest_validation"); | ||
|
|
||
| export default function registerTool(registerTool: RegisterTool, _context: Context) { | ||
| registerTool("run_manifest_validation", { | ||
| title: "Manifest Validation", | ||
| description: | ||
| "Validates UI5 manifest file." + | ||
| "After making changes, you should always run the validation again " + | ||
| "to verify that no new problems have been introduced.", | ||
| annotations: { | ||
| title: "Manifest Validation", | ||
| readOnlyHint: false, | ||
| }, | ||
| inputSchema, | ||
| outputSchema, | ||
| }, async ({manifestPath}) => { | ||
| log.info(`Running manifest validation on ${manifestPath}...`); | ||
|
|
||
| const result = await runValidation(manifestPath); | ||
|
|
||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: JSON.stringify(result), | ||
| }], | ||
| structuredContent: result, | ||
| }; | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import {fetchCdn} from "../../utils/cdnHelper.js"; | ||
| import {RunSchemaValidationResult} from "./schema.js"; | ||
| import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js"; | ||
| import {readFile} from "fs/promises"; | ||
| import {getLogger} from "@ui5/logger"; | ||
| import {InvalidInputError} from "../../utils.js"; | ||
| import {getManifestSchema} from "../../utils/ui5Manifest.js"; | ||
| import {Mutex} from "async-mutex"; | ||
| import {fileURLToPath} from "url"; | ||
| import {isAbsolute} from "path"; | ||
|
|
||
| const log = getLogger("tools:run_manifest_validation:runValidation"); | ||
| const schemaCache = new Map<string, AnySchemaObject>(); | ||
| const fetchSchemaMutex = new Mutex(); | ||
|
|
||
| const AJV_SCHEMA_PATHS = { | ||
| draft06: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-06.json")), | ||
| draft07: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-07.json")), | ||
| } as const; | ||
|
|
||
| async function createUI5ManifestValidateFunction(ui5Schema: object) { | ||
| try { | ||
| const ajv = new Ajv2020.default({ | ||
| allErrors: true, // Collect all errors, not just the first one | ||
| strict: false, // Allow additional properties that are not in schema | ||
| unicodeRegExp: false, | ||
| loadSchema: async (uri) => { | ||
| const release = await fetchSchemaMutex.acquire(); | ||
|
|
||
| if (schemaCache.has(uri)) { | ||
| log.info(`Loading cached schema: ${uri}`); | ||
| return schemaCache.get(uri)!; | ||
| } | ||
|
|
||
| try { | ||
| log.info(`Loading external schema: ${uri}`); | ||
| const schema = await fetchCdn(uri) as AnySchemaObject; | ||
|
|
||
| // Special handling for Adaptive Card schema to fix unsupported "id" property | ||
| // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), | ||
| // "$id" should be used instead of "id" | ||
| // See https://github.com/microsoft/AdaptiveCards/issues/9274 | ||
| if (uri.includes("adaptive-card.json") && typeof schema.id === "string") { | ||
| schema.$id = schema.id; | ||
| delete schema.id; | ||
| } | ||
|
|
||
| schemaCache.set(uri, schema); | ||
|
|
||
| return schema; | ||
| } catch (error) { | ||
| log.warn(`Failed to load external schema ${uri}:` + | ||
| `${error instanceof Error ? error.message : String(error)}`); | ||
|
|
||
| throw error; | ||
| } finally { | ||
| release(); | ||
| } | ||
| }, | ||
| }); | ||
| const draft06MetaSchema = JSON.parse( | ||
| await readFile(AJV_SCHEMA_PATHS.draft06, "utf-8") | ||
| ) as AnySchemaObject; | ||
| const draft07MetaSchema = JSON.parse( | ||
| await readFile(AJV_SCHEMA_PATHS.draft07, "utf-8") | ||
| ) as AnySchemaObject; | ||
|
|
||
| ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); | ||
| ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); | ||
|
|
||
| const validate = await ajv.compileAsync(ui5Schema); | ||
|
|
||
| return validate; | ||
| } catch (error) { | ||
| throw new Error(`Failed to create UI5 manifest validate function: ` + | ||
| `${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
| } | ||
|
|
||
| async function readManifest(path: string) { | ||
| let content: string; | ||
| let json: object; | ||
|
|
||
| if (!isAbsolute(path)) { | ||
| throw new InvalidInputError(`The manifest path must be absolute: '${path}'`); | ||
| } | ||
|
|
||
| try { | ||
| content = await readFile(path, "utf-8"); | ||
| } catch (error) { | ||
| throw new InvalidInputError(`Failed to read manifest file at ${path}: ` + | ||
| `${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
|
|
||
| try { | ||
| json = JSON.parse(content) as object; | ||
| } catch (error) { | ||
| throw new InvalidInputError(`Failed to parse manifest file at ${path} as JSON: ` + | ||
| `${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
|
|
||
| return json; | ||
| } | ||
|
|
||
| export default async function runValidation(manifestPath: string): Promise<RunSchemaValidationResult> { | ||
| log.info(`Starting manifest validation for file: ${manifestPath}`); | ||
|
|
||
| const manifest = await readManifest(manifestPath); | ||
| const manifestVersion = "latest"; | ||
| log.info(`Using manifest version: ${manifestVersion}`); | ||
| const ui5ManifestSchema = await getManifestSchema(manifestVersion); | ||
| const validate = await createUI5ManifestValidateFunction(ui5ManifestSchema); | ||
| const isValid = validate(manifest); | ||
|
|
||
| if (isValid) { | ||
| log.info("Manifest validation successful"); | ||
|
|
||
| return { | ||
| isValid: true, | ||
| errors: [], | ||
| }; | ||
| } | ||
|
|
||
| // Map AJV errors to our schema format | ||
| const validationErrors = validate.errors ?? []; | ||
| const errors = validationErrors.map((error) => { | ||
| return { | ||
| keyword: error.keyword ?? "", | ||
| instancePath: error.instancePath ?? "", | ||
| schemaPath: error.schemaPath ?? "", | ||
| params: error.params ?? {}, | ||
| propertyName: error.propertyName, | ||
| message: error.message, | ||
| schema: error.schema, | ||
| parentSchema: error.parentSchema, | ||
| data: error.data, | ||
| }; | ||
| }); | ||
|
|
||
| log.info(`Manifest validation failed with ${errors.length} error(s)`); | ||
|
|
||
| return { | ||
| isValid: false, | ||
| errors: errors, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import {z} from "zod"; | ||
|
|
||
| export const inputSchema = { | ||
| manifestPath: z.string() | ||
| .describe("Absolute path to the manifest file to validate."), | ||
| }; | ||
|
|
||
| export const outputSchema = { | ||
| isValid: z.boolean() | ||
| .describe("Whether the manifest is valid according to the UI5 Manifest schema."), | ||
| errors: z.array( | ||
| z.object({ | ||
| keyword: z.string() | ||
| .describe("Validation keyword."), | ||
| instancePath: z.string() | ||
| .describe("JSON Pointer to the location in the data instance (e.g., `/prop/1/subProp`)."), | ||
| schemaPath: z.string() | ||
| .describe("JSON Pointer to the location of the failing keyword in the schema."), | ||
| params: z.record(z.any()) | ||
| .describe("An object with additional information about the error."), | ||
| propertyName: z.string() | ||
| .optional() | ||
| .describe("Set for errors in `propertyNames` keyword schema."), | ||
| message: z.string() | ||
| .optional() | ||
| .describe("The error message."), | ||
| schema: z.any() | ||
| .optional() | ||
| .describe("The value of the failing keyword in the schema."), | ||
| parentSchema: z.record(z.any()) | ||
| .optional() | ||
| .describe("The schema containing the keyword."), | ||
| data: z.any() | ||
| .optional() | ||
| .describe("The data validated by the keyword."), | ||
| }) | ||
| ).describe("Array of validation error objects as returned by Ajv."), | ||
| }; | ||
| const _outputSchemaObject = z.object(outputSchema); | ||
| export type RunSchemaValidationResult = z.infer<typeof _outputSchemaObject>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,73 @@ | ||
| import {getLogger} from "@ui5/logger"; | ||
| import {fetchCdn} from "./cdnHelper.js"; | ||
| import {Mutex} from "async-mutex"; | ||
|
|
||
| const log = getLogger("utils:ui5Manifest"); | ||
|
|
||
| const LATEST_SCHEMA_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/schema.json"; | ||
dimovpetar marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const schemaCache = new Map<string, object>(); | ||
| const fetchSchemaMutex = new Mutex(); | ||
|
|
||
| let UI5ToManifestVersionMapping: Record<string, string> | null = null; | ||
| const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; | ||
| const ui5ToManifestVersionMappingMutex = new Mutex(); | ||
|
|
||
| async function getUI5toManifestVersionMapping() { | ||
| const release = await ui5ToManifestVersionMappingMutex.acquire(); | ||
|
|
||
| try { | ||
| if (UI5ToManifestVersionMapping) { | ||
| log.info("Loading cached UI5 to manifest version mapping"); | ||
| return UI5ToManifestVersionMapping; | ||
| } | ||
|
|
||
| log.info("Fetching UI5 to manifest version mapping"); | ||
| const mapping = await fetchCdn(MAPPING_URL); | ||
| log.info(`Fetched UI5 to manifest version mapping from ${MAPPING_URL}`); | ||
|
|
||
| async function getUI5toManifestVersionMap() { | ||
| const mapping = await fetchCdn(MAPPING_URL); | ||
| UI5ToManifestVersionMapping = mapping as Record<string, string>; | ||
|
|
||
| return mapping as Record<string, string>; | ||
| return UI5ToManifestVersionMapping; | ||
| } finally { | ||
| release(); | ||
| } | ||
| } | ||
|
|
||
| async function fetchSchema(manifestVersion: string) { | ||
| const release = await fetchSchemaMutex.acquire(); | ||
|
|
||
| try { | ||
| if (schemaCache.has(manifestVersion)) { | ||
| log.info(`Loading cached schema for manifest version: ${manifestVersion}`); | ||
| return schemaCache.get(manifestVersion)!; | ||
| } | ||
|
|
||
| log.info(`Fetching schema for manifest version: ${manifestVersion}`); | ||
| const schema = await fetchCdn(LATEST_SCHEMA_URL); | ||
| log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); | ||
|
|
||
| schemaCache.set(manifestVersion, schema); | ||
|
|
||
| return schema; | ||
| } finally { | ||
| release(); | ||
| } | ||
| } | ||
|
|
||
| export async function getLatestManifestVersion() { | ||
| const versionMap = await getUI5toManifestVersionMap(); | ||
| const versionMap = await getUI5toManifestVersionMapping(); | ||
|
|
||
| if (!versionMap.latest) { | ||
| throw new Error("Could not determine latest manifest version."); | ||
| } | ||
|
|
||
| return versionMap.latest; | ||
| } | ||
|
|
||
| export async function getManifestSchema(manifestVersion: string) { | ||
| if (manifestVersion !== "latest") { | ||
| throw new Error(`Only 'latest' manifest version is supported, but got '${manifestVersion}'.`); | ||
| } | ||
|
|
||
| return await fetchSchema(manifestVersion); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| { | ||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | ||
| "type": "object", | ||
| "required": ["_version", "sap.app"], | ||
| "properties": { | ||
| "_version": { | ||
| "type": "string" | ||
| }, | ||
| "sap.app": { | ||
| "type": "object", | ||
| "required": ["id", "type", "applicationVersion"], | ||
| "properties": { | ||
| "id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "title": { | ||
| "type": "string" | ||
| }, | ||
| "description": { | ||
| "type": "string" | ||
| }, | ||
| "applicationVersion": { | ||
| "type": "object", | ||
| "required": ["version"], | ||
| "properties": { | ||
| "version": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "dataSources": { | ||
| "type": "object" | ||
| } | ||
| } | ||
| }, | ||
| "sap.ui": { | ||
| "type": "object" | ||
| }, | ||
| "sap.ui5": { | ||
| "type": "object" | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.