* refactor: 💡 initial refactor for utils to share components * refactor: 💡 actions logic * Add missing copyright headers * refactor: 💡 simplify actions getter logic * refactor: 💡 convert output fields logic into a separate getter * test: 💍 fix unit test * fix: 🐛 bug with unknown fields lint error * refactor: 💡 pr feedback - simplify template check * refactor: 💡 remove unnecessary unit test file
395 lines
12 KiB
JavaScript
395 lines
12 KiB
JavaScript
/**
|
|
* Copyright IBM Corp. 2021, 2026
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import {
|
|
GRANT_FIELDS,
|
|
TEMPLATE_RESOURCE_TYPES,
|
|
normalizeGrantsSchema,
|
|
parseGrantLine,
|
|
getValidActions,
|
|
getValidOutputFields,
|
|
} from './grant-parser';
|
|
|
|
const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}'];
|
|
const CRUDL_ACTIONS = new Set(['create', 'read', 'update', 'delete', 'list']);
|
|
|
|
const filterByPrefix = (values, partial) =>
|
|
values.filter((value) => value.startsWith(partial));
|
|
const parseIds = (idsValue = '') => idsValue.split(',').filter(Boolean);
|
|
const withWildCard = (values = []) => ['*', ...new Set(values)];
|
|
|
|
const getNoSuggestionsOption = (noSuggestionsLabel) => ({
|
|
label: noSuggestionsLabel,
|
|
type: 'text',
|
|
apply: () => {},
|
|
});
|
|
|
|
const parseGrantLineForCompletions = (lineText = '') => {
|
|
const { fields, fieldMap } = parseGrantLine(lineText);
|
|
|
|
return {
|
|
parsedFieldNames: fields.map(({ key }) => key),
|
|
idsValue: fieldMap['ids'],
|
|
typeValue: fieldMap['type'],
|
|
actionsValue: fieldMap['actions'],
|
|
outputFieldsValue: fieldMap['output_fields'],
|
|
};
|
|
};
|
|
|
|
const getResourceTypesForId = (schema, id) => {
|
|
const templateType = TEMPLATE_RESOURCE_TYPES[id];
|
|
if (templateType) {
|
|
return [templateType].filter((type) => schema.resourcesByType[type]);
|
|
}
|
|
|
|
if (id.includes('_')) {
|
|
const prefix = id.split('_')[0];
|
|
const type = schema.resourceTypesByIdPrefix[prefix];
|
|
return type ? [type] : [];
|
|
}
|
|
|
|
return Object.entries(schema.resourceTypesByIdPrefix)
|
|
.filter(([prefix]) => prefix.startsWith(id))
|
|
.map(([, type]) => type);
|
|
};
|
|
|
|
const getCompatibleResourceTypeForIds = (schema, idsValue) => {
|
|
const resourceTypesById = parseIds(idsValue).flatMap((id) =>
|
|
getResourceTypesForId(schema, id),
|
|
);
|
|
const [firstResourceType] = resourceTypesById;
|
|
|
|
if (
|
|
!firstResourceType ||
|
|
!resourceTypesById.every(
|
|
(resourceType) => firstResourceType === resourceType,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return firstResourceType;
|
|
};
|
|
|
|
const getTypeOptions = (schema, idsValue, compatibleType) => {
|
|
// If there is no ids value, we can only suggest top-level resource types that have collection actions
|
|
if (!idsValue) {
|
|
return schema.topLevelTypes;
|
|
}
|
|
|
|
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
|
|
if (hasSpecificIds) {
|
|
if (!compatibleType) {
|
|
return [];
|
|
}
|
|
|
|
return schema.childResourceTypesByParentType[compatibleType] ?? [];
|
|
}
|
|
|
|
// Return the ones that have id based actions (currently only billing doesn't have them)
|
|
return Object.entries(schema.resourcesByType)
|
|
.filter(([, resource]) => resource.idActions.length > 0)
|
|
.map(([type]) => type);
|
|
};
|
|
|
|
export const analyzeGrantString = (grantsSchema, grantString = '') => {
|
|
const schema = normalizeGrantsSchema(grantsSchema);
|
|
const { idsValue, typeValue } = parseGrantLineForCompletions(grantString);
|
|
|
|
const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*');
|
|
const hasExplicitType = Boolean(typeValue) && typeValue !== '*';
|
|
|
|
const hasTemplateIds =
|
|
hasSpecificIds &&
|
|
parseIds(idsValue).some((id) => id.startsWith('{{') && id.endsWith('}}'));
|
|
|
|
const hasLiteralIds = hasSpecificIds && !hasTemplateIds;
|
|
|
|
const compatibleIdsResourceType = hasLiteralIds
|
|
? getCompatibleResourceTypeForIds(schema, idsValue)
|
|
: null;
|
|
|
|
const hasInvalidType = hasExplicitType && !schema.resourcesByType[typeValue];
|
|
const hasInvalidIds = hasLiteralIds && !compatibleIdsResourceType;
|
|
|
|
const hasInvalidPinnedIdTypeCombination =
|
|
hasSpecificIds &&
|
|
hasExplicitType &&
|
|
!hasInvalidIds &&
|
|
!hasInvalidType &&
|
|
!(
|
|
schema.childResourceTypesByParentType[compatibleIdsResourceType] ?? []
|
|
).includes(typeValue);
|
|
|
|
let detectedResourceType = null;
|
|
if (typeValue && typeValue !== '*') {
|
|
// Explicit type specified, use it directly
|
|
detectedResourceType = typeValue;
|
|
} else if (compatibleIdsResourceType && typeValue === '*') {
|
|
// Pinned IDs with wildcard type, return the child resource types
|
|
const childTypes =
|
|
schema.childResourceTypesByParentType[compatibleIdsResourceType] ?? [];
|
|
detectedResourceType = childTypes.length ? childTypes : null;
|
|
} else if (typeValue !== '*' && idsValue) {
|
|
// No type field, infer resource type from the ID prefix
|
|
detectedResourceType = compatibleIdsResourceType;
|
|
}
|
|
|
|
const hasWildcardIds = idsValue === '*';
|
|
const crudlOnly = hasWildcardIds && !hasExplicitType;
|
|
|
|
const actionOptions =
|
|
idsValue || typeValue
|
|
? getValidActions(schema, {
|
|
typeValue,
|
|
idsValue,
|
|
idsWildcard: hasWildcardIds,
|
|
idsKnownType: compatibleIdsResourceType,
|
|
}).actions
|
|
: [];
|
|
const actions =
|
|
actionOptions
|
|
.filter(
|
|
(action) => action !== '*' && (!crudlOnly || CRUDL_ACTIONS.has(action)),
|
|
)
|
|
.sort((left, right) => left.localeCompare(right)) ?? [];
|
|
|
|
return {
|
|
actions,
|
|
detectedResourceType,
|
|
hasInvalidType,
|
|
hasInvalidIds,
|
|
hasInvalidPinnedIdTypeCombination,
|
|
};
|
|
};
|
|
|
|
const getIdLookupTypes = (schema, typeValue, enteredIds) => {
|
|
if (enteredIds.length > 0) {
|
|
const compatibleResourceType = getCompatibleResourceTypeForIds(
|
|
schema,
|
|
enteredIds.join(','),
|
|
);
|
|
// No valid suggestions if there are mixed resource types across entered IDs
|
|
return compatibleResourceType ? [compatibleResourceType] : [];
|
|
}
|
|
|
|
if (typeValue === '*') {
|
|
// Only parent resource IDs (those that have child types) are useful
|
|
return Object.keys(schema.childResourceTypesByParentType);
|
|
}
|
|
|
|
if (typeValue) {
|
|
const parentType = schema.resourcesByType[typeValue]?.parentType;
|
|
// Restrict to the parent's IDs otherwise there are no valid ID suggestions besides wildcard
|
|
return parentType ? [parentType] : [];
|
|
}
|
|
|
|
// All types are potentially valid
|
|
return null;
|
|
};
|
|
|
|
async function grantCompletions(
|
|
context,
|
|
schema,
|
|
translate,
|
|
idLookup,
|
|
getIsLoading,
|
|
) {
|
|
const line = context.state.doc.lineAt(context.pos);
|
|
const beforeCursor = line.text.slice(0, context.pos - line.from);
|
|
|
|
const { parsedFieldNames, idsValue, typeValue } =
|
|
parseGrantLineForCompletions(line.text);
|
|
const compatibleType = idsValue
|
|
? getCompatibleResourceTypeForIds(schema, idsValue)
|
|
: null;
|
|
|
|
// Check if we're typing a field name (e.g. "type=") by starting at the beginning or after a semicolon.
|
|
// This allows us to provide field name suggestions when the user is typing a new field
|
|
// and restrict suggestions to only fields that haven't been used yet in the current line
|
|
const fieldNameMatch = beforeCursor.match(/(?:^|;)([a-z_]*)$/);
|
|
if (fieldNameMatch) {
|
|
const partial = fieldNameMatch[1];
|
|
const options = filterByPrefix(
|
|
GRANT_FIELDS.filter((field) => !parsedFieldNames.includes(field)),
|
|
partial,
|
|
).map((field) => ({
|
|
label: `${field}=`,
|
|
type: 'keyword',
|
|
}));
|
|
|
|
return {
|
|
from: context.pos - partial.length,
|
|
options,
|
|
filter: false, // This is to preserve the original order of our array and not let codemirror sort it
|
|
};
|
|
}
|
|
|
|
// Check if we're providing a value for the "type" field
|
|
const typeValueMatch = beforeCursor.match(/type=([a-z-*]*)$/);
|
|
if (typeValueMatch) {
|
|
const partial = typeValueMatch[1];
|
|
const matchingTypeOptions = getTypeOptions(
|
|
schema,
|
|
idsValue,
|
|
compatibleType,
|
|
);
|
|
const typeOptions =
|
|
partial === '' && matchingTypeOptions.length
|
|
? withWildCard(matchingTypeOptions)
|
|
: matchingTypeOptions;
|
|
const options = filterByPrefix(typeOptions, partial).map((type) => ({
|
|
label: type,
|
|
type: 'type',
|
|
info: type === '*' ? translate('wildcard-types') : '',
|
|
}));
|
|
|
|
return {
|
|
from: context.pos - partial.length,
|
|
options: options.length
|
|
? options
|
|
: [getNoSuggestionsOption(translate('no-suggestions'))],
|
|
};
|
|
}
|
|
|
|
const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/);
|
|
if (idsValueMatch) {
|
|
const enteredIds = idsValueMatch[1].split(',');
|
|
const partial = enteredIds.pop() ?? '';
|
|
const hasEnteredIds = enteredIds.length > 0;
|
|
const hasEnteredTemplateIds = enteredIds.some((id) => id.startsWith('{{'));
|
|
|
|
const staticOptions = filterByPrefix(['*', ...ID_TEMPLATES], partial)
|
|
.filter((value) => !hasEnteredIds || value !== '*')
|
|
.filter(
|
|
(value) =>
|
|
(!hasEnteredIds && !typeValue) || !ID_TEMPLATES.includes(value),
|
|
)
|
|
.filter((value) => !enteredIds.includes(value))
|
|
.map((value) => ({
|
|
label: value,
|
|
type: value === '*' ? 'constant' : 'interface',
|
|
info:
|
|
value === '*'
|
|
? translate('wildcard-ids')
|
|
: translate('template-value'),
|
|
}));
|
|
|
|
const compatibleResourceTypes = getIdLookupTypes(
|
|
schema,
|
|
typeValue,
|
|
enteredIds,
|
|
);
|
|
const from = context.pos - partial.length;
|
|
|
|
const ids = hasEnteredTemplateIds
|
|
? []
|
|
: await idLookup(partial, compatibleResourceTypes);
|
|
const idOptions = ids
|
|
.filter(({ id }) => !enteredIds.includes(id))
|
|
.map(({ id, name }) => ({
|
|
label: id,
|
|
detail: name,
|
|
type: 'variable',
|
|
}));
|
|
|
|
const allOptions = [...staticOptions, ...idOptions];
|
|
|
|
// Show a loading placeholder when IDs are still being fetched in the background
|
|
if (getIsLoading()) {
|
|
allOptions.push({
|
|
label: translate('loading-ids'),
|
|
type: 'text',
|
|
apply: () => {},
|
|
});
|
|
}
|
|
|
|
return {
|
|
from,
|
|
options: allOptions.length
|
|
? allOptions
|
|
: [getNoSuggestionsOption(translate('no-suggestions'))],
|
|
// We do our own filtering via DB search, so disable CodeMirror's
|
|
// built-in filtering. Without this, name-based searches are dropped
|
|
// because the label (the ID) doesn't match the typed text (the name).
|
|
filter: false,
|
|
};
|
|
}
|
|
|
|
const actionsValueMatch = beforeCursor.match(/actions=([a-z0-9_:,*-]*)$/);
|
|
|
|
if (actionsValueMatch) {
|
|
const enteredActions = actionsValueMatch[1].split(',');
|
|
const partial = enteredActions.pop() ?? '';
|
|
const hasEnteredActions = enteredActions.length > 0;
|
|
|
|
const actionOptions = getValidActions(schema, {
|
|
typeValue,
|
|
idsValue,
|
|
idsWildcard: idsValue?.includes('*'),
|
|
idsKnownType: compatibleType,
|
|
}).actions;
|
|
const options = filterByPrefix(actionOptions, partial)
|
|
.filter((action) => !hasEnteredActions || action !== '*')
|
|
.filter((action) => !enteredActions.includes(action))
|
|
.map((action) => ({
|
|
label: action,
|
|
type: 'constant',
|
|
info: action === '*' ? translate('wildcard-actions') : '',
|
|
}));
|
|
|
|
return {
|
|
from: context.pos - partial.length,
|
|
options: options.length
|
|
? options
|
|
: [getNoSuggestionsOption(translate('no-suggestions'))],
|
|
};
|
|
}
|
|
|
|
const outputFieldsMatch = beforeCursor.match(
|
|
/output_fields=([a-z0-9_,*-]*)$/,
|
|
);
|
|
|
|
if (outputFieldsMatch) {
|
|
const enteredOutputFields = outputFieldsMatch[1].split(',');
|
|
const partial = enteredOutputFields.pop() ?? '';
|
|
const hasEnteredOutputFields = enteredOutputFields.length > 0;
|
|
|
|
const { outputFields } = getValidOutputFields(schema, {
|
|
typeValue,
|
|
idsKnownType: compatibleType,
|
|
});
|
|
const options = filterByPrefix(outputFields, partial)
|
|
.filter((value) => !hasEnteredOutputFields || value !== '*')
|
|
.filter((value) => !enteredOutputFields.includes(value))
|
|
.map((value) => ({
|
|
label: value,
|
|
type: 'constant',
|
|
info: value === '*' ? translate('all-fields') : '',
|
|
}));
|
|
|
|
return {
|
|
from: context.pos - partial.length,
|
|
options: options.length
|
|
? options
|
|
: [getNoSuggestionsOption(translate('no-suggestions'))],
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const createGrantCompletionSource = (
|
|
grantsSchema,
|
|
translate,
|
|
idLookup,
|
|
getIsLoading,
|
|
) => {
|
|
const schema = normalizeGrantsSchema(grantsSchema);
|
|
|
|
return (context) =>
|
|
grantCompletions(context, schema, translate, idLookup, getIsLoading);
|
|
};
|