ibm-openapi-validator
Version:
Configurable and extensible validator/linter for OpenAPI documents
200 lines (175 loc) • 7.25 kB
JavaScript
/**
* Copyright 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const {
validateNestedSchemas,
} = require('@ibm-cloud/openapi-ruleset-utilities');
const { Resolver } = require('@stoplight/spectral-ref-resolver');
const Nimma = require('nimma').default;
class Metrics {
// Holds the callback functions to be paired with each JSONPath.
// It is an object, with a JSONPath string as each key, and an
// array of callback functions as each value;
callbacks;
// Holds the running counts on each metric we're identifying.
// It is an object, with the metric name as each key, and an
// integer count as each value.
counts;
// Holds the artifact instances as we encounter them. These are
// used for de-duplicating instances in the resolved spec.
// It is an object, with the metric name as each key, and a Set
// of unique OpenAPI artifacts as each value.
collectedArtifacts;
// Holds the unresolved API definition in Object form.
apiDefinition;
constructor(unresolvedApiDef) {
this.apiDefinition = unresolvedApiDef;
this.callbacks = {};
this.counts = {};
this.collectedArtifacts = {};
}
/**
* Defines a metric to track within the API. For a given "name", it registers
* JSONPath strings, used to gather the OpenAPI artifacts relevant to the given
* metric, with a condition (implemented as a function) that specifies whether
* or not the OpenAPI artifact should be "counted" while computing the metrics.
*
* @param string metricName - the name of the metric to track - must match 'demoninator'
* fields in the rubric
* @param []string jsonPaths - list of JSONPath strings to execute the condition against
* @param function condition - a callback function that defines the condition
* that must be met in order to increment the count
* (accepts an object, returns boolean)
* @returns void
*/
register(metricName, jsonPaths, condition) {
const callback = ({ value, path }) => {
this.increment(value, path, metricName, condition);
};
this.registerCallback(metricName, jsonPaths, callback);
}
/**
* Defines a metric to track within the API, in a way that is specialized for schemas.
* The condition used to determine whether or not to include a given schema is wrapped
* within a parent function that recursively looks at nested schemas, since our JSONPath
* collections will only provide top-level schemas.
*
* @param string metricName - the name of the metric to track - must match 'demoninator'
* fields in the rubric
* @param []string jsonPaths - list of JSONPath strings to execute the condition against
* @param function condition - a callback function that defines the condition
* that must be met in order to increment the count
* (accepts an object, returns boolean)
* @returns void
*/
registerSchemas(metricName, jsonPaths, condition) {
const callback = ({ value, path }) => {
validateNestedSchemas(value, path, (schema, pathToSchema) => {
this.increment(schema, pathToSchema, metricName, condition);
return []; // validateNestedSchemas expects an array to be returned
});
};
this.registerCallback(metricName, jsonPaths, callback);
}
/**
* Compute all desired metrics for the given API definition. This method
* takes all registered metrics, along with their JSONPath string and
* condition functions, and populates all of the metrics data by executing
* them against the API. The "count" of each metric will then be accessible
* via the "getData" method.
*
* @returns void
*/
async compute() {
// The stored API definition will be in its unresolved format. We need to
// resolve it to enable use to track artifacts through references, etc.
const resolver = new Resolver();
const resolved = await resolver.resolve(this.apiDefinition);
const resolvedApiDefinition = resolved.result;
// Transform the callbacks into something Nimma can understand - it expects
// an object, where the keys are JSONPath strings and the values are callback
// functions to execute against each artifact collected with the JSONPath.
const jsonPaths = Object.keys(this.callbacks);
const nimmaCallbacks = {};
// We potentially have multiple callback functions stored for a given JSONPath.
// The callback function we define in the Nimma object needs to call all of them.
Object.entries(this.callbacks).forEach(([jsonPath, functions]) => {
nimmaCallbacks[jsonPath] = input => functions.forEach(f => f(input));
});
// Execute the JSONPaths to count everything at once.
const nimma = new Nimma(jsonPaths);
nimma.query(resolvedApiDefinition, nimmaCallbacks);
}
/**
* Retrieves the "count" for a given metric, by name.
*
* @param string name - the name of the metric to get the count of
* @returns integer - the total count for the given metric
*/
get(name) {
return this.counts[name];
}
/**
* Provides the ability to see the metric data as a string.
* Primarily for logging purposes.
*
* @returns string - the string representation of the populated metrics object.
*/
toString() {
return JSON.stringify(this.counts, null, 2);
}
/**
* A private method that sets the initial values needed
* for tracking a new metric.
*/
initializeMetric(metricName) {
// Initialize the count to 0.
if (!this.counts[metricName]) {
this.counts[metricName] = 0;
}
// Initialize the artifact collector to an empty Set.
if (!this.collectedArtifacts[metricName]) {
this.collectedArtifacts[metricName] = new Set();
}
}
/**
* A private method that provides the common logic for registering a new
* metric. It initializes what needs to be initialized and then adds the
* relevant data to the class.
*/
registerCallback(metricName, jsonPaths, callback) {
this.initializeMetric(metricName);
// Augment and register the callback to each JSONPath.
jsonPaths.forEach(jp => {
// Initialize the list of callbacks if necessary.
if (!this.callbacks[jp]) {
this.callbacks[jp] = [];
}
// Add this callback to the list of functions
// associated with a given JSONPath.
this.callbacks[jp].push(input => {
callback(input);
});
});
}
/**
* A private method that provides the common logic for incrementing the "count"
* of a given metric that we're tracking. It only increments if the user-provided
* condition holds and the given artifact has not been counted yet.
*/
increment(value, path, metricName, condition) {
// Check condition and ensure we don't increment for any duplicates.
if (
condition(value, path) &&
!this.collectedArtifacts[metricName].has(value)
) {
this.counts[metricName]++;
}
// Mark the artifact as "counted" by storing it in the collection.
this.collectedArtifacts[metricName].add(value);
}
}
module.exports = {
Metrics,
};