cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
265 lines (264 loc) • 13.3 kB
JavaScript
import $RefParser, { JSONParserErrorGroup, } from "@apidevtools/json-schema-ref-parser";
import * as path from "path";
import { pathToFileURL, fileURLToPath } from "url";
import * as fs from "fs";
import lodash1 from "lodash";
import * as lodash2 from "lodash";
import { C8yPactObjectKeys } from "../shared/c8ypact/c8ypact";
const _ = lodash1 || lodash2;
function traverseAndPreprocessRefs(currentNode, currentPath, collectedParams, baseFolder) {
if (typeof currentNode !== "object" || currentNode === null) {
return;
}
for (const key in currentNode) {
if (!Object.prototype.hasOwnProperty.call(currentNode, key)) {
continue;
}
const value = currentNode[key];
if (key === "$ref" && _.isString(value)) {
const originalRefValue = value; // Save the full original $ref string
// 1. Separate query string first
const queryIndex = originalRefValue.indexOf("?");
const basePathAndFragment = queryIndex === -1
? originalRefValue
: originalRefValue.substring(0, queryIndex);
const queryStringPart = queryIndex === -1 ? "" : originalRefValue.substring(queryIndex);
// 2. Separate fragment from the base path
const fragmentIndex = basePathAndFragment.indexOf("#");
const mainPathPart = // Ensure this is const
fragmentIndex === -1
? basePathAndFragment
: basePathAndFragment.substring(0, fragmentIndex);
const fragmentPart = fragmentIndex === -1
? ""
: basePathAndFragment.substring(fragmentIndex); // Includes the '#'
let processedMainPath = mainPathPart;
// 3. Transform mainPathPart if it's a potential local file path
// Use regex to robustly detect URI schemes and internal refs
if (!/^#/.test(mainPathPart) && // Excludes internal refs like "#/definitions/foo"
!_.isEmpty(mainPathPart) && // Excludes empty paths
!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//i.test(mainPathPart) // Excludes any URI scheme (e.g., http://, https://, file://, custom://)
) {
if (baseFolder) {
if (path.isAbsolute(mainPathPart)) {
// Absolute OS path given, convert to file URI, ignore baseFolder for this path
processedMainPath = pathToFileURL(mainPathPart).href;
}
else {
// Relative OS path, resolve against baseFolder and convert to file URI
processedMainPath = pathToFileURL(path.resolve(baseFolder, mainPathPart)).href;
}
}
else {
// No baseFolder provided.
// Resolve mainPathPart against CWD (if relative) or use as is (if absolute),
// then convert to file URI. This ensures all file paths become absolute file:// URLs.
// process.cwd() should return the mocked CWD in a test environment.
processedMainPath = pathToFileURL(path.resolve(process.cwd(), mainPathPart)).href;
}
}
// If mainPathPart starts with "#", or is empty (from an internal ref like "#/foo"),
// or starts with a known URI scheme, it's not modified here.
// 4. Reconstruct the final $ref value in the document for $RefParser
let finalRefValue;
if (processedMainPath === "" && fragmentPart === "") {
// Handles cases like originalRefValue = "" or originalRefValue = "?query=val"
// These should point to the root of the current document.
finalRefValue = "#" + queryStringPart;
}
else {
finalRefValue = processedMainPath + fragmentPart + queryStringPart;
}
currentNode[key] = finalRefValue;
// Construct the clean reference string for $RefParser
// This ensures query parameters are NOT included in the ref path itself.
let refForParser;
if (processedMainPath) {
// We have a scheme (http, file) or a path that will be resolved by the parser.
// Add fragment if it exists (e.g., "file:///doc.json#/foo").
// If fragmentPart is empty, it refers to the root of the external document.
refForParser = processedMainPath + (fragmentPart || "");
}
else {
// No processedMainPath, so it's an internal reference to the current document.
// fragmentPart would be like "#/definitions/foo", or "#" (if originalRef was "#" or "#?query"),
// or "" (if originalRef was "" or "?query", making baseRefPath "", thus mainPathPart and fragmentPart also "").
// Default to "#" to reference the root of the current document if fragmentPart is empty.
refForParser = fragmentPart || "#";
}
// Update the $ref in the document with the clean reference for $RefParser
currentNode[key] = refForParser;
// 5. Parameter collection logic (uses originalRefValue for query parsing)
if (queryIndex !== -1) {
const params = {};
new URLSearchParams(originalRefValue.substring(queryIndex + 1)).forEach((paramValue, paramKey) => {
const intMatch = paramValue.match(/^Int\(([-+]?\d+)\)$/);
const floatMatch = paramValue.match(/^Float\(([-+]?\d*\.?\d+)\)$/);
const boolMatch = paramValue.match(/^Bool\((true|false)\)$/i);
if (intMatch) {
params[paramKey] = parseInt(intMatch[1], 10);
}
else if (floatMatch) {
params[paramKey] = parseFloat(floatMatch[1]);
}
else if (boolMatch) {
params[paramKey] = boolMatch[1].toLowerCase() === "true";
}
else {
params[paramKey] = paramValue;
}
});
collectedParams.push({
keyPath: [...currentPath], // Path to the object containing this $ref
params,
originalRefValue: originalRefValue, // Store the original full $ref for post-processing
});
}
}
else if (typeof value === "object" && value !== null) {
const nextPathSegment = Array.isArray(currentNode) ? parseInt(key) : key;
traverseAndPreprocessRefs(value, [...currentPath, nextPathSegment], collectedParams, baseFolder);
}
}
}
// Helper: Get value from object by path using lodash
function getValueByPath(obj, path) {
return _.get(obj, path);
}
// Helper: Set value in object by path using lodash
function setValueByPath(obj, path, value) {
_.set(obj, path, value);
}
// Helper: Replace placeholders in a copy of the target
function replacePlaceholdersInCopy(target, params // Changed from string to any
) {
if (_.isString(target)) {
// Check if the entire string is a single placeholder
for (const paramKey in params) {
if (Object.prototype.hasOwnProperty.call(params, paramKey)) {
const placeholder = `{{${paramKey}}}`;
if (target === placeholder) {
return params[paramKey]; // Return the raw (potentially typed) value
}
}
}
// If not a single placeholder, perform string interpolation
let result = target;
for (const paramKey in params) {
if (Object.prototype.hasOwnProperty.call(params, paramKey)) {
const placeholder = `{{${paramKey}}}`;
// Ensure param is stringified for interpolation within a larger string
result = result.split(placeholder).join(String(params[paramKey]));
}
}
return result;
}
else if (Array.isArray(target)) {
return target.map((item) => replacePlaceholdersInCopy(item, params));
}
else if (typeof target === "object" && target !== null) {
const copy = {}; // Create a new object
for (const key in target) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
copy[key] = replacePlaceholdersInCopy(target[key], params);
}
}
return copy;
}
return target; // For numbers, booleans, null, undefined
}
export async function resolveRefs(doc, baseFolder, // Optional base folder for resolving relative file paths
parserOptions // Optional custom options for $RefParser
) {
if (doc == null || typeof doc !== "object") {
return doc;
}
// only resolve in C8yPact objects
if (!C8yPactObjectKeys.some((key) => key in doc)) {
return doc;
}
const parameterizationInfoList = [];
const docForProcessing = _.cloneDeep(doc);
// 1. Custom "parsing" step: Traverse, transform $refs, collect parameterization info
traverseAndPreprocessRefs(docForProcessing, [], parameterizationInfoList, baseFolder);
// 2. Dereference using the standard mechanism with the preprocessed document
const defaultOptions = {
dereference: {
circular: "ignore",
excludedPathMatcher: (jsonPointerPath) => {
const pathFragment = jsonPointerPath.includes("#")
? jsonPointerPath.substring(jsonPointerPath.indexOf("#") + 1)
: jsonPointerPath;
const segments = pathFragment
.split("/")
.filter((segment) => !_.isEmpty(segment));
// Iterate through all actual path segments (keys or array indices).
// Start from index 1 to skip the initial empty string if path starts with '#/'.
for (let i = 0; i < segments.length; i++) {
const currentSegment = segments[i];
if (i === 0 && !C8yPactObjectKeys.includes(currentSegment)) {
continue;
}
// We don't want to exclude based on the "$ref" keyword itself,
// only based on its parent/ancestor keys.
if (currentSegment === "$ref") {
continue;
}
if (currentSegment === "jsonSchema" ||
// options.schema on a C8yPactRecord holds a JSON Schema object.
// Its $ref keywords are schema references, not pact refs — exclude
// the entire subtree once we see "schema" preceded by "options".
(currentSegment === "schema" &&
i > 0 &&
segments[i - 1] === "options") ||
currentSegment.startsWith("$") ||
currentSegment.startsWith("%24")) {
return true;
}
}
return false;
},
},
continueOnError: true,
resolve: {
file: {
order: 1,
canRead: /^file:/i,
read: async (file) => {
// Convert file:// URL to path and read using the current fs (which may be mocked)
const filePath = fileURLToPath(file.url);
return fs.readFileSync(filePath, "utf8");
},
},
},
};
// Merge custom options with defaults
const mergedOptions = _.merge({}, defaultOptions, parserOptions);
const dereferencedDoc = await $RefParser.dereference(docForProcessing, mergedOptions);
// 3. Post-Dereferencing Replacement
const finalDoc = dereferencedDoc;
for (const info of parameterizationInfoList) {
const resolvedValueAtPath = getValueByPath(finalDoc, info.keyPath);
if (typeof resolvedValueAtPath !== "undefined") {
const replacedAndFinalValue = replacePlaceholdersInCopy(resolvedValueAtPath, info.params);
setValueByPath(finalDoc, info.keyPath, replacedAndFinalValue);
}
}
return _.pick(finalDoc, C8yPactObjectKeys);
}
export function logJSONParserErrorGroup(error, logger) {
if (!(error instanceof JSONParserErrorGroup))
return;
logger(` Error Type: JSONParserErrorGroup`);
logger(` Summary: ${error.message}`); // Main message from the error group
logger(` Individual Errors:`);
error.errors.forEach((errorItem, index) => {
const errorPath = errorItem.path && Array.isArray(errorItem.path)
? errorItem.path.join("/")
: "N/A";
logger(` Error ${index + 1}:`);
logger(` Name: ${errorItem.name}`);
logger(` Message: ${errorItem.message}`);
logger(` Path in Document: ${errorPath}`); // JSON Pointer path within the document
});
}