Skip to content

Commit

Permalink
feat: add support for Q&A Schema App input
Browse files Browse the repository at this point in the history
  • Loading branch information
rossiam committed Aug 21, 2023
1 parent 92f34cd commit a427e34
Show file tree
Hide file tree
Showing 20 changed files with 484 additions and 66 deletions.
5 changes: 5 additions & 0 deletions .changeset/funny-chicken-marry.md
@@ -0,0 +1,5 @@
---
"@smartthings/cli-lib": minor
---

Miscellaneous updates to item-input module.
5 changes: 5 additions & 0 deletions .changeset/green-fireants-try.md
@@ -0,0 +1,5 @@
---
"@smartthings/cli": minor
---

Added support for Q&A input and updating of Schema Apps.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Expand Up @@ -127,6 +127,7 @@ We're always looking for more opinions on discussions in the issue tracker. It's
- For ambitious tasks, you should try to get your work in front of the community for feedback as soon as possible. Open a pull request as soon as you have done the minimum needed to demonstrate your idea. At this early stage, don't worry about making things perfect, or 100% complete. Describe what you still need to do and submit a [draft pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/). This lets reviewers know not to nit-pick small details or point out improvements you already know you need to make.
- Don't include unrelated changes
- New features should be accompanied with tests and documentation
- Pull requests should include only a single commit. You can use `git rebase -i main` to combine multiple commits into a single one if necessary.
- Commit messages
- Use a clear and descriptive title for the pull request and commits
- Commit messages must be formatted properly using [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). Our CI will check this and fail any PRs that are formatted incorrectly.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/README.md
Expand Up @@ -5465,13 +5465,14 @@ update an ST Schema connector

```
USAGE
$ smartthings schema:update [ID] [-h] [-p <value>] [-t <value>] [--language <value>] [-j] [-y] [-i <value>]
$ smartthings schema:update [ID] [-h] [-p <value>] [-t <value>] [--language <value>] [-j] [-y] [-i <value>] [-d]
[--authorize] [--principal <value>] [--statement <value>]
ARGUMENTS
ID the app id
FLAGS
-d, --dry-run produce JSON but don't actually submit
--authorize authorize Lambda functions to be called by SmartThings
--principal=<value> use this principal instead of the default when authorizing lambda functions
--statement=<value> use this statement id instead of the default when authorizing lambda functions
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/commands/schema/create.test.ts
Expand Up @@ -22,6 +22,7 @@ describe('SchemaAppCreateCommand', () => {
tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'],
}),
expect.any(Function),
expect.anything(),
)
})

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/commands/schema/update.test.ts
Expand Up @@ -47,6 +47,7 @@ describe('SchemaUpdateCommand', () => {

expect(inputItemMock).toBeCalledWith(
expect.any(SchemaUpdateCommand),
expect.anything(),
)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/apps/create.ts
Expand Up @@ -122,7 +122,7 @@ export default class AppCreateCommand extends APICommand<typeof AppCreateCommand
name: 'action',
message: 'What kind of app do you want to create? (Currently, only OAuth-In apps are supported.)',
choices: [
{ name: 'OAuth-Inn App', value: 'oauth-in' },
{ name: 'OAuth-In App', value: 'oauth-in' },
{ name: 'Cancel', value: 'cancel' },
],
default: 'oauth-in',
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/apps/update.ts
Expand Up @@ -16,7 +16,7 @@ import {
arrayDef,
InputDefsByProperty,
} from '@smartthings/cli-lib'
import { addPermission } from '../../lib/aws-utils'
import { addPermission, awsHelpText } from '../../lib/aws-utils'
import { chooseApp, smartAppHelpText, tableFieldDefinitions } from '../../lib/commands/apps-util'


Expand Down Expand Up @@ -82,8 +82,7 @@ export default class AppUpdateCommand extends APICommand<typeof AppUpdateCommand
}
if (appType === AppType.LAMBDA_SMART_APP) {
startingRequest.lambdaSmartApp = lambdaSmartApp
const helpText = 'More information on AWS Lambdas can be found at:\n' +
' https://docs.aws.amazon.com/lambda/latest/dg/welcome.html'
const helpText = awsHelpText
propertyInputDefs.lambdaSmartApp = objectDef('Lambda SmartApp',
{ functions: arrayDef('Lambda Functions', stringDef('Lambda Function', { helpText }), { helpText }) })
}
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/schema/create.ts
Expand Up @@ -2,10 +2,15 @@ import { Flags } from '@oclif/core'

import { SchemaAppRequest, SchemaCreateResponse } from '@smartthings/core-sdk'

import { APICommand, inputAndOutputItem, lambdaAuthFlags } from '@smartthings/cli-lib'
import {
APICommand,
inputAndOutputItem,
lambdaAuthFlags,
userInputProcessor,
} from '@smartthings/cli-lib'

import { addSchemaPermission } from '../../lib/aws-utils'
import { SCHEMA_AWS_PRINCIPAL } from '../../lib/commands/schema-util'
import { SCHEMA_AWS_PRINCIPAL, getSchemaAppCreateFromUser } from '../../lib/commands/schema-util'


export default class SchemaAppCreateCommand extends APICommand<typeof SchemaAppCreateCommand.flags> {
Expand Down Expand Up @@ -48,6 +53,7 @@ export default class SchemaAppCreateCommand extends APICommand<typeof SchemaAppC
}
await inputAndOutputItem(this,
{ tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'] },
createApp)
createApp, userInputProcessor(() => getSchemaAppCreateFromUser(this)),
)
}
}
15 changes: 13 additions & 2 deletions packages/cli/src/commands/schema/update.ts
Expand Up @@ -2,9 +2,10 @@ import { Flags, Errors } from '@oclif/core'

import { SchemaApp, SchemaAppRequest } from '@smartthings/core-sdk'

import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig } from '@smartthings/cli-lib'
import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig, userInputProcessor, inputAndOutputItem } from '@smartthings/cli-lib'

import { addSchemaPermission } from '../../lib/aws-utils'
import { getSchemaAppUpdateFromUser } from '../../lib/commands/schema-util'


export default class SchemaUpdateCommand extends APICommand<typeof SchemaUpdateCommand.flags> {
Expand All @@ -14,6 +15,11 @@ export default class SchemaUpdateCommand extends APICommand<typeof SchemaUpdateC
static flags = {
...APICommand.flags,
...inputItem.flags,
// eslint-disable-next-line @typescript-eslint/naming-convention
'dry-run': Flags.boolean({
char: 'd',
description: "produce JSON but don't actually submit",
}),
authorize: Flags.boolean({
description: 'authorize Lambda functions to be called by SmartThings',
}),
Expand All @@ -36,7 +42,12 @@ export default class SchemaUpdateCommand extends APICommand<typeof SchemaUpdateC
listItems: () => this.client.schema.list(),
})

const [request] = await inputItem<SchemaAppRequest>(this)
const getInputFromUser = async (): Promise<SchemaAppRequest> => {
const original = await this.client.schema.get(id)
return getSchemaAppUpdateFromUser(this, original, this.flags['dry-run'])
}

const [request] = await inputItem<SchemaAppRequest>(this, userInputProcessor(getInputFromUser))
if (this.flags.authorize) {
if (request.hostingType === 'lambda') {
if (request.lambdaArn) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/aws-utils.ts
Expand Up @@ -34,3 +34,9 @@ export async function addPermission(arn: string, principal = '906037444270', sta
export function addSchemaPermission(arn: string, principal = '148790070172', statementId = 'smartthings'): Promise<string> {
return addPermission(arn, principal, statementId)
}

/**
* Help text for use in `InputDefinition` instances.
*/
export const awsHelpText = 'More information on AWS Lambdas can be found at:\n' +
' https://docs.aws.amazon.com/lambda/latest/dg/welcome.html'
134 changes: 134 additions & 0 deletions packages/cli/src/lib/commands/schema-util.ts
@@ -1 +1,135 @@
import { SchemaApp, SchemaAppRequest, SmartThingsURLProvider, ViperAppLinks } from '@smartthings/core-sdk'

import {
InputDefinition,
SmartThingsCommandInterface,
booleanDef,
clipToMaximum,
createFromUserInput,
emailValidate,
httpsURLValidate,
listSelectionDef,
maxItemValueLength,
objectDef,
optionalDef,
optionalStringDef,
staticDef,
stringDef,
undefinedDef,
updateFromUserInput,
} from '@smartthings/cli-lib'
import { awsHelpText } from '../aws-utils'


export const SCHEMA_AWS_PRINCIPAL = '148790070172'

const arnDef = (name: string, inChina: boolean, initialValue?: SchemaAppRequest, options?: { forChina?: boolean }): InputDefinition<string | undefined> => {
if (inChina && !options?.forChina || !inChina && options?.forChina) {
return undefinedDef
}

const helpText = awsHelpText
const initiallyActive = initialValue?.hostingType === 'lambda'

// In China there is only one ARN field so we can make it required. Globally there are three
// and at least one of the three is required, but individually all are optional.
// (See `validateFinal` function below for the validation requiring at least one.)
return optionalDef(inChina ? stringDef(name, { helpText }) : optionalStringDef(name, { helpText }),
(context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'hostingType'>)?.hostingType === 'lambda',
{ initiallyActive })
}

const webHookUrlDef = (inChina: boolean, initialValue?: SchemaAppRequest): InputDefinition<string | undefined> => {
if (inChina) {
return undefinedDef
}

const initiallyActive = initialValue?.hostingType === 'webhook'
return optionalDef(stringDef('Webhook URL'),
(context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'hostingType'>)?.hostingType === 'webhook',
{ initiallyActive })
}

// Create a type with some extra temporary fields.
type InputData = SchemaAppRequest & { includeAppLinks: boolean }

const validateFinal = (schemaAppRequest: InputData): true | string => {
if ( schemaAppRequest.hostingType === 'lambda'
&& !schemaAppRequest.lambdaArn
&& !schemaAppRequest.lambdaArnEU
&& !schemaAppRequest.lambdaArnAP
&& !schemaAppRequest.lambdaArnCN) {
return 'At least one lambda ARN is required.'
}
return true
}

const appLinksDefSummarize = (value?: ViperAppLinks): string =>
clipToMaximum(`android: ${value?.android}, ios: ${value?.ios}`, maxItemValueLength)
const appLinksDef = objectDef<ViperAppLinks>('App-to-app Links', {
android: stringDef('Android Link'),
ios: stringDef('iOS Link'),
isLinkingEnabled: staticDef(true),
}, { summarizeForEdit: appLinksDefSummarize })

const buildInputDefinition = (command: SmartThingsCommandInterface, initialValue?: SchemaAppRequest): InputDefinition<InputData> => {
// TODO: should do more type checking on this, perhaps using zod or
const baseURL = (command.profile.clientIdProvider as SmartThingsURLProvider | undefined)?.baseURL
const inChina = typeof baseURL === 'string' && baseURL.endsWith('cn')

const hostingTypeDef = inChina
? staticDef('lambda')
: listSelectionDef('Hosting Type', ['lambda', 'webhook'], { default: 'webhook' })

return objectDef<InputData>('Schema App', {
partnerName: stringDef('Partner Name'),
userEmail: stringDef('User email', { validate: emailValidate }),
appName: optionalStringDef('App Name', {
default: (context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'partnerName'>)?.partnerName ?? '',
}),
oAuthAuthorizationUrl: stringDef('OAuth Authorization URL', { validate: httpsURLValidate }),
oAuthTokenUrl: stringDef('Partner OAuth Refresh Token URL', { validate: httpsURLValidate }),
icon: optionalStringDef('Icon URL', { validate: httpsURLValidate }),
icon2x: optionalStringDef('2x Icon URL', { validate: httpsURLValidate }),
icon3x: optionalStringDef('3x Icon URL', { validate: httpsURLValidate }),
oAuthClientId: stringDef('Partner OAuth Client Id'),
oAuthClientSecret: stringDef('Partner OAuth Client Secret'),
oAuthScope: optionalStringDef('Partner OAuth Scope'),
schemaType: staticDef('st-schema'),
hostingType: hostingTypeDef,
lambdaArn: arnDef('Lambda ARN for US region', inChina, initialValue),
lambdaArnEU: arnDef('Lambda ARN for EU region', inChina, initialValue),
lambdaArnCN: arnDef('Lambda ARN for CN region', inChina, initialValue, { forChina: true }),
lambdaArnAP: arnDef('Lambda ARN for AP region', inChina, initialValue),
webhookUrl: webHookUrlDef(inChina),
includeAppLinks: booleanDef('Enable app-to-app linking?', { default: false }),
viperAppLinks: optionalDef(appLinksDef,
(context?: unknown[]) => (context?.[0] as Pick<InputData, 'includeAppLinks'>)?.includeAppLinks,
{ initiallyActive: !!initialValue?.viperAppLinks }),
}, { validateFinal })
}

const stripTempInputFields = (inputData: InputData): SchemaAppRequest => {
// Strip out extra temporary data to make the `InputData` into a `SchemaAppRequest`.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { includeAppLinks, ...result } = inputData

return result
}

export const getSchemaAppUpdateFromUser = async (command: SmartThingsCommandInterface, original: SchemaApp, dryRun: boolean): Promise<SchemaAppRequest> => {
const inputDef = buildInputDefinition(command, original)

const inputData = await updateFromUserInput(command, inputDef,
{ ...original, includeAppLinks: !!original.viperAppLinks }, { dryRun })

return stripTempInputFields(inputData)
}

export const getSchemaAppCreateFromUser = async (command: SmartThingsCommandInterface): Promise<SchemaAppRequest> => {
const inputDef = buildInputDefinition(command)

const inputData = await createFromUserInput(command, inputDef, { dryRun: command.flags['dry-run'] })

return stripTempInputFields(inputData)
}
9 changes: 7 additions & 2 deletions packages/lib/src/input.ts
Expand Up @@ -121,8 +121,13 @@ export type UserInputCommand<T> = {
* always be the last one in the list since input processors are checked in order and this can
* always provide data.
*/
export function userInputProcessor<T>(command: UserInputCommand<T>): InputProcessor<T> {
return inputProcessor(() => true, () => command.getInputFromUser())
export function userInputProcessor<T>(command: UserInputCommand<T>): InputProcessor<T>
export function userInputProcessor<T>(readFn: () => Promise<T>): InputProcessor<T>
export function userInputProcessor<T>(commandOrReadFn: UserInputCommand<T> | (() => Promise<T>)): InputProcessor<T> {
if (typeof commandOrReadFn === 'function') {
return inputProcessor(() => true, commandOrReadFn)
}
return inputProcessor(() => true, () => commandOrReadFn.getInputFromUser())
}

export class CombinedInputProcessor<T> implements InputProcessor<T> {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/item-input/array.ts
Expand Up @@ -206,7 +206,7 @@ export type CheckboxDefItem<T> = T extends string ? string | ComplexCheckboxDefI

export function checkboxDef<T>(name: string, items: CheckboxDefItem<T>[], options?: CheckboxDefOptions<T>): InputDefinition<T[]> {
const editValues = async (values: T[]): Promise<T[] | CancelAction> => {
// We can't add help to the inquirer `checkbox` so, at least for now, we'll just display
// We can't add help to the inquirer `checkbox` so, at least for now, we'll display
// the help before we display the checkbox.
if (options?.helpText) {
console.log(`\n${options.helpText}\n`)
Expand Down
18 changes: 16 additions & 2 deletions packages/lib/src/item-input/command-helpers.ts
Expand Up @@ -11,6 +11,7 @@ import {
previewJSONAction,
previewYAMLAction,
} from './defs'
import { red } from 'chalk'


export type UpdateFromUserInputOptions = {
Expand All @@ -33,6 +34,7 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings
// TODO: this should probably be moved to someplace more common
const indent = command.flags.indent ?? command.cliConfig.profile.indent ?? (formatter === yamlFormatter ? 2 : 4)
const output = formatter(indent)(retVal)
// TODO: use `askForBoolean`
const editAgain = (await inquirer.prompt({
type: 'confirm',
name: 'editAgain',
Expand All @@ -49,11 +51,23 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings

// eslint-disable-next-line no-constant-condition
while (true) {
const validationResult = inputDefinition.validateFinal ? inputDefinition.validateFinal(retVal) : true
if (validationResult !== true) {
console.log(red(validationResult))
const answer = await inputDefinition.updateFromUserInput(retVal)
if (answer !== cancelAction) {
retVal = answer
}
continue
}
const choices: ChoiceCollection = [
editOption(inputDefinition.name),
{ name: 'Preview JSON.', value: previewJSONAction },
{ name: 'Preview YAML.', value: previewYAMLAction },
{ name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`, value: finishAction },
{
name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`,
value: finishAction,
},
{ name: `Cancel creating ${inputDefinition.name}.`, value: cancelAction },
]

Expand All @@ -62,7 +76,7 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings
name: 'action',
message: 'Choose an action.',
choices,
default: finishAction,
default: validationResult === true ? finishAction : editAction,
})).action

if (action === editAction) {
Expand Down

0 comments on commit a427e34

Please sign in to comment.