JsonSchemaPrompt.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import Ajv from 'ajv'
  2. import inquirer, { DistinctQuestion } from 'inquirer'
  3. import _ from 'lodash'
  4. import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
  5. import chalk from 'chalk'
  6. import { BOOL_PROMPT_OPTIONS } from './prompting'
  7. import { getSchemasLocation } from 'cd-schemas'
  8. import path from 'path'
  9. type CustomPromptMethod = () => Promise<any>
  10. type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
  11. // For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
  12. // eslint-disable-next-line @typescript-eslint/ban-types
  13. export type JsonSchemaCustomPrompts<T = Record<string, unknown>> = [keyof T | (string & {}) | RegExp, CustomPrompt][]
  14. // Default schema path for resolving refs
  15. // TODO: Would be nice to skip the filename part (but without it it doesn't work)
  16. const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
  17. export class JsonSchemaPrompter<JsonResult> {
  18. schema: JSONSchema
  19. schemaPath: string
  20. customPropmpts?: JsonSchemaCustomPrompts
  21. ajv: Ajv.Ajv
  22. filledObject: Partial<JsonResult>
  23. constructor(
  24. schema: JSONSchema,
  25. defaults?: Partial<JsonResult>,
  26. customPrompts?: JsonSchemaCustomPrompts,
  27. schemaPath: string = DEFAULT_SCHEMA_PATH
  28. ) {
  29. this.customPropmpts = customPrompts
  30. this.schema = schema
  31. this.schemaPath = schemaPath
  32. this.ajv = new Ajv()
  33. this.filledObject = defaults || {}
  34. }
  35. private oneOfToChoices(oneOf: JSONSchema[]) {
  36. const choices: { name: string; value: number | string }[] = []
  37. oneOf.forEach((pSchema, index) => {
  38. if (pSchema.description) {
  39. choices.push({ name: pSchema.description, value: index })
  40. } else if (pSchema.type === 'object' && pSchema.properties) {
  41. choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index })
  42. } else {
  43. choices.push({ name: index.toString(), value: index })
  44. }
  45. })
  46. return choices
  47. }
  48. private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
  49. const found = this.customPropmpts?.find(([pathToMatch]) =>
  50. pathToMatch instanceof RegExp ? pathToMatch.test(propertyPath) : propertyPath === pathToMatch
  51. )
  52. return found ? found[1] : undefined
  53. }
  54. private propertyDisplayName(propertyPath: string) {
  55. return chalk.green(propertyPath)
  56. }
  57. private async prompt(schema: JSONSchema, propertyPath = '', custom?: CustomPrompt): Promise<any> {
  58. const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
  59. const propDisplayName = this.propertyDisplayName(propertyPath)
  60. // Custom prompt
  61. if (typeof customPrompt === 'function') {
  62. return await this.promptWithRetry(customPrompt, propertyPath, true)
  63. }
  64. // oneOf
  65. if (schema.oneOf) {
  66. const oneOf = schema.oneOf as JSONSchema[]
  67. const choices = this.oneOfToChoices(oneOf)
  68. const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', choices })
  69. return await this.prompt(oneOf[choosen], propertyPath)
  70. }
  71. // object
  72. if (schema.type === 'object' && schema.properties) {
  73. const value: Record<string, any> = {}
  74. for (const [pName, pSchema] of Object.entries(schema.properties)) {
  75. value[pName] = await this.prompt(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
  76. }
  77. return value
  78. }
  79. // array
  80. if (schema.type === 'array' && schema.items) {
  81. return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
  82. }
  83. // "primitive" values:
  84. const currentValue = _.get(this.filledObject, propertyPath)
  85. const basicPromptOptions: DistinctQuestion = {
  86. message: propDisplayName,
  87. default: currentValue !== undefined ? currentValue : schema.default,
  88. }
  89. let additionalPromptOptions: DistinctQuestion | undefined
  90. let normalizer: (v: any) => any = (v) => v
  91. // Prompt options
  92. if (schema.enum) {
  93. additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
  94. } else if (schema.type === 'boolean') {
  95. additionalPromptOptions = BOOL_PROMPT_OPTIONS
  96. }
  97. // Normalizers
  98. if (schema.type === 'integer') {
  99. normalizer = (v) => parseInt(v)
  100. }
  101. if (schema.type === 'number') {
  102. normalizer = (v) => Number(v)
  103. }
  104. const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
  105. // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
  106. return await this.promptWithRetry(
  107. async () => normalizer(await this.promptSimple(promptOptions, propertyPath, normalizer)),
  108. propertyPath
  109. )
  110. }
  111. private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
  112. _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
  113. this.ajv.validate(this.schema, this.filledObject) as boolean
  114. return this.ajv.errors
  115. ? this.ajv.errors
  116. .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
  117. .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
  118. .join(', ')
  119. : null
  120. }
  121. private async promptArray(schema: JSONSchema, propertyPath: string) {
  122. if (!schema.items) {
  123. return []
  124. }
  125. const { maxItems = Number.MAX_SAFE_INTEGER } = schema
  126. let currItem = 0
  127. const result = []
  128. while (currItem < maxItems) {
  129. const { next } = await inquirer.prompt([
  130. {
  131. ...BOOL_PROMPT_OPTIONS,
  132. name: 'next',
  133. message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
  134. },
  135. ])
  136. if (!next) {
  137. break
  138. }
  139. const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
  140. result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
  141. ++currItem
  142. }
  143. return result
  144. }
  145. private async promptSimple(promptOptions: DistinctQuestion, propertyPath: string, normalize?: (v: any) => any) {
  146. const { result } = await inquirer.prompt([
  147. {
  148. ...promptOptions,
  149. name: 'result',
  150. validate: (v) => {
  151. v = normalize ? normalize(v) : v
  152. return (
  153. this.setValueAndGetError(propertyPath, v) ||
  154. (promptOptions.validate ? promptOptions.validate(v) : true) ||
  155. true
  156. )
  157. },
  158. },
  159. ])
  160. return result
  161. }
  162. private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
  163. let error: string | null
  164. let value: any
  165. do {
  166. value = await customMethod()
  167. error = this.setValueAndGetError(propertyPath, value, nestedErrors)
  168. if (error) {
  169. console.log('\n')
  170. console.log('Provided value:', value)
  171. console.warn(`ERROR: ${error}`)
  172. console.warn(`Try providing the input for ${propertyPath} again...`)
  173. }
  174. } while (error)
  175. return value
  176. }
  177. async getMainSchema() {
  178. return await RefParser.dereference(this.schemaPath, this.schema, {})
  179. }
  180. async promptAll() {
  181. await this.prompt(await this.getMainSchema())
  182. return this.filledObject as JsonResult
  183. }
  184. async promptMultipleProps<P extends keyof JsonResult & string, PA extends readonly P[]>(
  185. props: PA
  186. ): Promise<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> {
  187. const result: Partial<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> = {}
  188. for (const prop of props) {
  189. result[prop] = await this.promptSingleProp(prop)
  190. }
  191. return result as { [K in PA[number]]: Exclude<JsonResult[K], undefined> }
  192. }
  193. async promptSingleProp<P extends keyof JsonResult & string>(
  194. p: P,
  195. customPrompt?: CustomPrompt
  196. ): Promise<Exclude<JsonResult[P], undefined>> {
  197. const mainSchema = await this.getMainSchema()
  198. await this.prompt(mainSchema.properties![p] as JSONSchema, p, customPrompt)
  199. return this.filledObject[p] as Exclude<JsonResult[P], undefined>
  200. }
  201. }