diff --git a/src/commands/entities/create.ts b/src/commands/entities/create.ts new file mode 100644 index 00000000..9b7ed34b --- /dev/null +++ b/src/commands/entities/create.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2022, Nuance, Inc. and its contributors. + * All rights reserved. + * + * This source code is licensed under the Apache-2.0 license found in + * the LICENSE file in the root directory of this source tree. + */ + +import chalk from 'chalk' +import {flags} from '@oclif/command' +import makeDebug from 'debug' + +import * as EntitiesAPI from '../../mix/api/entities' +import * as MixFlags from '../../utils/flags' +import {DomainOption} from '../../utils/validations' +import {EntitiesCreateParams, MixClient, MixResponse, MixResult} from '../../mix/types' +import MixCommand from '../../utils/base/mix-command' +import {validateRegexEntityParams, validateRuleBasedEntityParams} from '../../utils/validations' + +const debug = makeDebug('mix:commands:entities:create') + +export default class EntitiesCreate extends MixCommand { + static description = `create a new entity + +Use this command to create a new entity in a project. + +Mix supports several types of entities: freeform, list, regex, +relational and rule-based. There are many attributes that can +be passed for the creation of an entity and a good number of +these attributes are common to all entity types. However, certain +attributes are mandatory and apply to specific entity types only. + +Regex entities make use of regular expressions specific to a single +locale. The --pattern and --locale flags must be set when creating +an entity of type "regex". + +Relationial entities can have zero or one isA relation and +zero or many hasA relations. One --is-A or --has-A flag must be +set at a minimum when creating an entity of type "relational". + +The --entity-type, --name and --project flags are mandatory for +the creation of any entity type. + +The examples below show how to create each type of entity. +In each example, every allowed or mandatory flag is used. +Note that many flags have default values and do not need to be +explicitly provided. +` + + static examples = [ + 'Create a freeform entity', + '$ mix entities:create -P 1922 --entity-type=freeform --name MESSAGE \\', + ' --sensitive --no-canonicalize --data-type not-set', + '', + 'Create a list entity', + '$ mix entities:create -P 1922 --entity-type=list --name DRINK_SIZE \\', + ' --dynamic --sensitive --no-canonicalize --anaphora-type not-set \\', + ' --data-type not-set', + '', + 'Create a regex entity', + '$ mix entities:create -P 1922 --entity-type=regex --name PHONE_NUMBER \\', + ' --locale en-US --pattern \\d{10} --sensitive --no-canonicalize \\', + ' --anaphora-type not-set --data-type digits', + '', + 'Create a relational entity', + '$ mix entities:create -P 1922 --entity-type=relational --name ARRIVAL_CITY \\', + ' --is-a CITY --sensitive --no-canonicalize --anaphora-type not-set \\', + ' --data-type not-set', + '', + 'Create a rule-based entity', + '$ mix entities:create -P 1922 --entity-type=rule-based --name CARD_TYPE \\', + ' --sensitive --no-canonicalize --anaphora-type not-set --data-type not-set', + ] + + static flags = { + 'anaphora-type': MixFlags.anaphoraTypeFlag, + 'data-type': MixFlags.dataTypeFlag, + dynamic: MixFlags.dynamicFlag, + 'entity-type': MixFlags.withEntityTypeFlag, + 'has-a': MixFlags.hasAFlag, + 'is-a': MixFlags.isAFlag, + locale: MixFlags.regexLocaleFlag, + name: MixFlags.entityNameFlag, + 'no-canonicalize': MixFlags.noCanonicalizeFlag, + pattern: MixFlags.patternFlag, + project: MixFlags.projectFlag, + sensitive: MixFlags.sensitiveUserDataFlag, + // output flags + json: MixFlags.jsonFlag, + yaml: MixFlags.yamlFlag, + } + + get domainOptions(): DomainOption[] { + debug('get domainOptions()') + return ['locale', 'project'] + } + + async buildRequestParameters(options: Partial): Promise { + debug('buildRequestParameters()') + const { + 'anaphora-type': anaphora, + 'no-canonicalize': noCanonicalize, + 'data-type': dataType, + 'has-a': hasA, + 'is-a': isA, + dynamic: isDynamic, + 'entity-type': entityType, + sensitive: isSensitive, + locale, + name, + pattern, + project: projectId, + } = options + + return { + anaphora: `ANAPHORA_${anaphora.toUpperCase().replace('-', '_')}`, + canonicalize: !noCanonicalize, + dataType: dataType.toUpperCase().replace('-', '_'), + entityType: entityType, + hasA, + isA, + isDynamic, + isSensitive, + locale, + name, + pattern, + projectId, + } + } + + doRequest(client: MixClient, params: EntitiesCreateParams): Promise { + debug('doRequest()') + return EntitiesAPI.createEntity(client, params) + } + + outputHumanReadable(transformedData: any) { + debug('outputHumanReadable()') + const {entityId, name} = transformedData + this.log(`Entity ${chalk.cyan(name)} with ID ${chalk.cyan(entityId)} created.`) + } + + setRequestActionMessage(options: any) { + debug('setRequestActionMessage()') + this.requestActionMessage = `Creating entity ${options.name} in project ${options.project}` + } + + transformResponse(result: MixResult) { + debug('transformResponse()') + const data = result.data as any + const [type] = Object.keys(data?.entity) + const entity = data?.entity[type] ?? {} + + const { + name, + id: entityId, + } = entity + + return { + entityId, + name, + } + } + + tryDomainOptionsValidation(options: Partial, domainOptions: DomainOption[]) { + debug('tryDomainOptionsValidation()') + super.tryDomainOptionsValidation(options, domainOptions) + + const { + 'entity-type': entityType, + 'has-a': hasA, + 'is-a': isA, + locale, + pattern, + } = options + + // Entity types 'regex' and 'relational' require mandatory flags. + // Command bails out if mandatory parameters are missing. + // Extraneous parameters are ignored. + if (entityType === 'regex') { + validateRegexEntityParams(locale, pattern) + } + + if (entityType === 'relational') { + validateRuleBasedEntityParams(hasA, isA) + } + } +} diff --git a/src/mix/api/entities-types.ts b/src/mix/api/entities-types.ts index ab0b2f2b..ce7b435b 100644 --- a/src/mix/api/entities-types.ts +++ b/src/mix/api/entities-types.ts @@ -8,15 +8,85 @@ import {Expand} from './shared-types' +export const AnaphoraDefault = 'not-set' +export const Anaphoras = { + [AnaphoraDefault]: 'ANAPHORA_NOT_SET', + 'ref-moment': 'ANAPHORA_REF_MOMENT', + 'ref-person': 'ANAPHONRA_REF_PERSON', + 'ref-place': 'ANAPHORA_REF_PLACE', + 'ref-thing': 'ANAPHORA_REF_THING', +} + +export type Anaphora = typeof Anaphoras[keyof typeof Anaphoras] + +export const DataTypeDefault = 'not-set' +export const DataTypes = { + alphanum: 'ALPHANUM', + amount: 'AMOUNT', + boolean: 'BOOLEAN', + date: 'DATE', + digits: 'DIGITS', + distance: 'DISTANCE', + 'no-format': 'NO_FORMAT', + [DataTypeDefault]: 'NOT_SET', + number: 'NUMBER', + temperature: 'TEMPERATURE', + time: 'TIME', + 'yes-no': 'YES_NO', +} + +export type DataType = typeof DataTypes[keyof typeof DataTypes] + /** Entity type */ -export type Entity = - | 'UNSPECIFIED' - | 'BASE' - | 'RELATIONAL' - | 'LIST' - | 'FREEFORM' - | 'REGEX' - | 'RULE_BASED' +export const Entities = { + base: 'BASE', + freeform: 'FREEFORM', + list: 'LIST', + regex: 'REGEX', + relational: 'RELATIONAL', + 'rule-based': 'RULE_BASED', +} + +export type Entity = typeof Entities[keyof typeof Entities] + +/** @hidden */ +export type EntitiesConfigureBodyParams = { + /** Name of the entity that this entity has an `isA` relationship with */ + isA?: string, + + /** Names of the entities that this entity has a `hasA` relationship with */ + hasA?: string[], + + /** Specifies the referrer for this entity */ + anaphora?: Anaphora + + /** Data type for the entity */ + dataType?: DataType + + /** Data type for the entity */ + entityType?: Entity + + /** When set to true, indicates that the entity is dynamic */ + isDynamic?: boolean + + /** When set to true, indicates that the entity is sensitive */ + isSensitive?: boolean + + /** When set to true, indicates that the entity is canonicalized */ + canonicalize?: boolean + + /** Locale for the pattern */ + locale?: string + + /** Regular expression pattern for this entity */ + pattern?: string +} + +/** @hidden */ +export type EntitiesCreateBodyParams = EntitiesConfigureBodyParams & { + /** New entity name */ + name: string, +} /** @hidden */ export type EntitiesGetPathParams = { @@ -46,6 +116,8 @@ export type EntitiesRenameBodyParams = { } /** @hidden */ +export type EntitiesConfigureParams = Expand +export type EntitiesCreateParams = Expand export type EntitiesDeleteParams = Expand export type EntitiesGetParams = Expand export type EntitiesListParams = Expand @@ -53,6 +125,8 @@ export type EntitiesRenameParams = Expand { + debug('createEntity()') + const {projectId, ...bodyParams} = params + const body = buildCreateOrUpdateEntityBody(bodyParams) + + debug('body: %O', body) + + return client.request({ + method: 'post', + url: buildURL(client.getServer(), `/v4/projects/${projectId}/entities`), + data: body, + }) +} + /** * Delete an entity from a project. * diff --git a/src/mix/api/utils/entities-helpers.ts b/src/mix/api/utils/entities-helpers.ts new file mode 100644 index 00000000..c71515cf --- /dev/null +++ b/src/mix/api/utils/entities-helpers.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2022, Nuance, Inc. and its contributors. + * All rights reserved. + * + * This source code is licensed under the Apache-2.0 license found in + * the LICENSE file in the root directory of this source tree. + */ + +import makeDebug from 'debug' + +const debug = makeDebug.debug('mix:utils:entities-helpers') + +export const buildCreateOrUpdateEntityBody = (params: any) => { + debug('buildCreateOrUpdateEntityBody()') + + const { + anaphora, + canonicalize, + dataType, + entityType, + hasA, + isA, + isDynamic, + isSensitive, + locale, + name, + pattern, + } = params + + let entityTypeKey + entityTypeKey = entityType === 'rule-based' ? 'ruleBased' : entityType + entityTypeKey = `${entityTypeKey}Entity` + + return { + [entityTypeKey]: { + ...(anaphora !== undefined && {anaphora}), + ...(entityType === 'list') && {data: {}}, + ...(dataType !== undefined && {dataType}), + ...(hasA !== undefined && {hasA: {entities: hasA}}), + ...(isA !== undefined && {isA}), + ...(isDynamic !== undefined && {isDynamic}), + ...(locale !== undefined && {locale}), + ...(name !== undefined && {name}), + ...(pattern !== undefined && {pattern}), + settings: { + ...(canonicalize !== undefined && {canonicalize}), + ...(isSensitive !== undefined && {isSensitive}), + }, + }, + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index a359c5ca..a134cf72 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -43,6 +43,7 @@ export enum Codes { InvalidColumnError = 'EINVALIDCOLUMNERROR', MiscNotFound = 'ENOTFOUND', MismatchedValues = 'EMISMATCHEDVALUES', + MissingParameter = 'EMISSINGPARAMETER', NoBuildInfo = 'ENOBUILDINFO', NotConfirmed = 'ENOTCONFIRMED', TokenFileFormat = 'ETOKENFILEFORMAT', @@ -129,6 +130,16 @@ export const eMismatchedValues = (message?: string, suggestions?: string[]) => { }) } +export const eMissingParameter = (message?: string, suggestions?: string[]) => { + return new MixCLIError( + message ?? 'one or more flags are missing.', + { + code: Codes.MissingParameter, + exit: 1, + suggestions: suggestions ?? ['verify the values passed to the command flags.'], // default + }) +} + export const eNoBuildInfo = (message?: string, suggestions?: string[]) => { return new MixCLIError( message ?? `Required flag(s) missing. diff --git a/src/utils/flags.ts b/src/utils/flags.ts index 1e809bb4..ede35da2 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -8,6 +8,14 @@ import {flags} from '@oclif/command' +import { + AnaphoraDefault, + Anaphoras, + DataTypeDefault, + DataTypes, + Entities, +} from '../mix/types' + // We keep all flag descriptions in a single place to encourage consistency of // flags across commands. // Readability in the commands code is not affected if flags are named properly. @@ -53,6 +61,12 @@ export const projectDescWithDefault = `project ID (defaults to ${projectEnvVarDe // Flag options export const buildTypeOptions = ['asr', 'dialog', 'nlu'] +export const anaphoraTypeFlag = flags.string({ + default: AnaphoraDefault, + description: 'anaphora type', + options: Object.keys(Anaphoras).sort(), +}) + // Flag objects export const appConfigurationFlag = flags.integer({ char: appConfigurationShortcut, @@ -82,6 +96,12 @@ export const confirmFlag = flags.string({ description: 'skip confirmation prompt by pre-supplying value', }) +export const dataTypeFlag = flags.string({ + default: DataTypeDefault, + description: 'data type of entity', + options: Object.keys(DataTypes).sort(), +}) + export const deploymentFlowFlag = flags.integer({ char: deploymentFlowIDShortcut, description: 'deployment flow ID', @@ -99,6 +119,11 @@ export const dataPackTopicFlag = flags.string({ required: true, }) +export const dynamicFlag = flags.boolean({ + description: 'make list entity dynamic', + default: false, +}) + export const enginePackFlag = flags.string({ description: 'engine pack ID (UUID format)', }) @@ -119,7 +144,6 @@ export const entityFlag = flags.string({ }) export const entityNameFlag = flags.string({ - char: 'n', description: 'new entity name', required: true, }) @@ -133,21 +157,30 @@ export const geoNameFlag = flags.string({ description: geoNameDesc, }) +export const hasAFlag = flags.string({ + description: 'define hasA relationship for relational entity', + multiple: true, +}) + export const inputFilePathFlag = flags.string({ char: filePathShortucut, description: 'input file path', required: true, }) +export const intentNameFlag = flags.string({ + description: 'new intent name', + required: true, +}) + export const intentFlag = flags.string({ char: intentNameShortcut, description: 'intent name', required: true, }) -export const intentNameFlag = flags.string({ - description: 'new intent name', - required: true, +export const isAFlag = flags.string({ + description: 'define isA relationship for relational entity', }) export const jobFlag = flags.string({ @@ -229,6 +262,11 @@ export const nluModelTypeFlag = flags.string({ options: ['accurate', 'fast'], }) +export const noCanonicalizeFlag = flags.boolean({ + default: false, + description: 'prevent canonicalization', +}) + export const offsetFlag = flags.integer({ description: 'to exclude e.g., the first 10 (sorted) results, set --offset=10', }) @@ -257,6 +295,10 @@ export const overwriteFileFlag = flags.boolean({ description: 'overwrite output file if it exists', }) +export const patternFlag = flags.string({ + description: 'regular expression for regex entity', +}) + export const projectFlag = flags.integer({ char: projectShortcut, description: projectDesc, @@ -277,6 +319,11 @@ export const projectTableFlag = flags.string({ options: ['channels', 'data-packs', 'project'], }) +export const regexLocaleFlag = flags.string({ + char: localeShortcut, + description: 'locale for regex entity', +}) + export const replaceEntityFlag = flags.boolean({ default: false, description: 'replace, rather than append, existing entity literals', @@ -293,6 +340,11 @@ export const runtimeApplicationFlag = flags.string({ required: true, }) +export const sensitiveUserDataFlag = flags.boolean({ + default: false, + description: 'mask user sentitive data in logs', +}) + export const showAllOrganizationsFlag = flags.boolean({ default: false, description: 'show all organizations', @@ -390,7 +442,7 @@ export const withDeploymentFlowFlag = flags.integer({ export const withEntityTypeFlag = flags.string({ description: 'entity type', - options: ['base', 'freeform', 'list', 'regex', 'relational', 'rule-based'], + options: Object.keys(Entities).sort(), }) export const withLocaleMultipleFlag = flags.string({ diff --git a/src/utils/validations.ts b/src/utils/validations.ts index 9e77bb38..b1463184 100644 --- a/src/utils/validations.ts +++ b/src/utils/validations.ts @@ -9,6 +9,8 @@ import makeDebug from 'debug' import {z} from 'zod' +import {eMissingParameter} from './errors' + export type DomainOption = | 'build-label' | 'build-version' @@ -91,3 +93,24 @@ export function validateDomainOptions(options: any, validations: Array { + test + .env(testEnvData.env) + .nock(serverURL, api => api + .post(`/v4/projects/${td.request.project}/entities`) + .reply(200, td.getEntityResponse) + ) + .stdout() + .command(['entities:create', + '--entity-type', td.request.entityType, + '--name', td.request.entity, + '--project', td.request.project, + ]) + .it('creates a new list entity', ctx => { + expect(ctx.stdout).to.contain(`Entity ${td.request.entity} with ID ${td.getEntityResponse.entity.listEntity.id} created`) + }) + + test + .env(testEnvData.env) + .stderr() + .command(['entities:create']) + .catch(ctx => { + expect(ctx.message).to.contain('Missing required flag') + }) + .it('errors out when no parameters supplied') + + test + .env(testEnvData.env) + .stderr() + .command(['entities:create', + '--entity-type', 'regex', + '--name', td.request.entity, + '--project', td.request.project, + ]) + .catch(ctx => { + expect(ctx.message).to.contain('Regex entities require a pattern and a locale') + }) + .it('errors out when mandatory parameters are missing to create a regex entity') + + test + .env(testEnvData.env) + .stderr() + .command(['entities:create', + '--entity-type', 'relational', + '--name', td.request.entity, + '--project', td.request.project, + ]) + .catch(ctx => { + expect(ctx.message).to.contain('Relational entities require has-a and/or is-a relation') + }) + .it('errors out when mandatory parameters are missing to create a relational entity') + + test + .env(testEnvData.env) + .nock(serverURL, api => api + .post(`/v4/projects/${td.request.project}/entities`, td.createListEntityBody) + .reply(400, td.invalidEntityResponse) + ) + .stdout() + .command(['entities:create', + '--entity-type', td.request.entityType, + '--name', td.request.invalidEntity, + '--project', td.request.project, + ]) + .catch(ctx => { + expect(ctx.message).to.contain('400 Bad Request') + }) + .it('errors out when given unknown entity name to create') + + test + .env(testEnvData.env) + .nock(serverURL, api => api + .post(`/v4/projects/${td.request.unknownProject}/entities`) + .reply(400, td.projectInvalidResponse) + ) + .stdout() + .command(['entities:create', + '--entity-type', td.request.entityType, + '--name', td.request.entity, + '--project', td.request.unknownProject, + ]) + .catch(ctx => { + expect(ctx.message).to.contain(`Project ${td.request.unknownProject} is not available`) + }) + .it('errors out when given an invalid project ID') +}) diff --git a/test/commands/entities/entities-test-data.ts b/test/commands/entities/entities-test-data.ts index fc4e71a2..31d63ee9 100644 --- a/test/commands/entities/entities-test-data.ts +++ b/test/commands/entities/entities-test-data.ts @@ -10,16 +10,32 @@ module.exports = { request: { project: '1922', entity: 'DrinkSize', + entityType: 'list', invalidEntity: '1234', newName: 'DrinkFormat', unknownEntity: 'NONE', unknownProject: '99999', }, + createListEntityBody: { + listEntity: { + anaphora: 'ANAPHORA_NOT_SET', + data: {}, + dataType: 'NOT_SET', + isDynamic: false, + name: '1234', + settings: { canonicalize: true, isSensitive: false }, + } + }, entityNotFoundResponse: { code: 5, message: 'No entity found.', details: [], }, + invalidEntityResponse: { + code: 3, + message: '400 Bad Request: "["Illegal Argument -> Bad Request Exception","Caused by: Name with value: 1234, does not conform to javascript variable name syntax: http://mothereff.in/js-variables"]"', + details: [], + }, projectInvalidResponse: { code: 3, message: 'Project 99999 is not available.', details: [] } , getEntityResponse: { entity: {