JsonSchemaPrompt.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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. type CustomPromptMethod = () => Promise<any>
  8. type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt } | 'skip'
  9. // For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
  10. // eslint-disable-next-line @typescript-eslint/ban-types
  11. export type JsonSchemaCustomPrompts<T = Record<string, unknown>> = [keyof T | (string & {}) | RegExp, CustomPrompt][]
  12. export class JsonSchemaPrompter<JsonResult> {
  13. schema: JSONSchema
  14. schemaPath: string
  15. customPropmpts?: JsonSchemaCustomPrompts
  16. ajv: Ajv.Ajv
  17. filledObject: Partial<JsonResult>
  18. constructor(
  19. schema: JSONSchema,
  20. defaults?: Partial<JsonResult>,
  21. customPrompts?: JsonSchemaCustomPrompts,
  22. schemaPath = ''
  23. ) {
  24. this.customPropmpts = customPrompts
  25. this.schema = schema
  26. this.schemaPath = schemaPath
  27. // allErrors prevents .validate from setting only one error when in fact there are multiple
  28. this.ajv = new Ajv({ allErrors: true })
  29. this.filledObject = defaults || {}
  30. }
  31. private oneOfToOptions(oneOf: JSONSchema[], currentValue: any) {
  32. let defaultValue: any
  33. const choices: { name: string; value: number | string }[] = []
  34. oneOf.forEach((pSchema, index) => {
  35. if (pSchema.description) {
  36. choices.push({ name: pSchema.description, value: index.toString() })
  37. } else if (pSchema.type === 'object' && pSchema.properties) {
  38. choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index.toString() })
  39. // Supports defaults for enum variants:
  40. if (
  41. typeof currentValue === 'object' &&
  42. currentValue !== null &&
  43. Object.keys(currentValue).join(',') === Object.keys(pSchema.properties).join(',')
  44. ) {
  45. defaultValue = index.toString()
  46. }
  47. } else {
  48. choices.push({ name: index.toString(), value: index.toString() })
  49. }
  50. })
  51. return { choices, default: defaultValue }
  52. }
  53. private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
  54. const found = this.customPropmpts?.find(([pathToMatch]) =>
  55. pathToMatch instanceof RegExp ? pathToMatch.test(propertyPath) : propertyPath === pathToMatch
  56. )
  57. return found ? found[1] : undefined
  58. }
  59. private propertyDisplayName(propertyPath: string) {
  60. return chalk.green(propertyPath)
  61. }
  62. private async prompt(
  63. schema: JSONSchema,
  64. propertyPath = '',
  65. custom?: CustomPrompt,
  66. allPropsRequired = false
  67. ): Promise<any> {
  68. const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
  69. const propDisplayName = this.propertyDisplayName(propertyPath)
  70. const currentValue = _.get(this.filledObject, propertyPath)
  71. const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
  72. if (customPrompt === 'skip') {
  73. return
  74. }
  75. // Automatically handle "null" values (useful for enum variants)
  76. if (type === 'null') {
  77. _.set(this.filledObject, propertyPath, null)
  78. return null
  79. }
  80. // Custom prompt
  81. if (typeof customPrompt === 'function') {
  82. return await this.promptWithRetry(customPrompt, propertyPath, true)
  83. }
  84. // oneOf
  85. if (schema.oneOf) {
  86. const oneOf = schema.oneOf as JSONSchema[]
  87. const options = this.oneOfToOptions(oneOf, currentValue)
  88. const choosen = await this.inquirerSinglePrompt({
  89. message: propDisplayName,
  90. type: 'list',
  91. ...options,
  92. })
  93. if (choosen !== options.default) {
  94. _.set(this.filledObject, propertyPath, undefined) // Clear any previous value if different variant selected
  95. }
  96. return await this.prompt(oneOf[parseInt(choosen)], propertyPath)
  97. }
  98. // object
  99. if (type === 'object' && schema.properties) {
  100. const value: Record<string, any> = {}
  101. for (const [pName, pSchema] of Object.entries(schema.properties)) {
  102. const objectPropertyPath = propertyPath ? `${propertyPath}.${pName}` : pName
  103. const propertyCustomPrompt = this.getCustomPrompt(objectPropertyPath)
  104. if (propertyCustomPrompt === 'skip') {
  105. continue
  106. }
  107. let confirmed = true
  108. const required = allPropsRequired || (Array.isArray(schema.required) && schema.required.includes(pName))
  109. if (!required) {
  110. confirmed = await this.inquirerSinglePrompt({
  111. message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
  112. type: 'confirm',
  113. default:
  114. _.get(this.filledObject, objectPropertyPath) !== undefined &&
  115. _.get(this.filledObject, objectPropertyPath) !== null,
  116. })
  117. }
  118. if (confirmed) {
  119. value[pName] = await this.prompt(pSchema, objectPropertyPath)
  120. } else {
  121. _.set(this.filledObject, objectPropertyPath, null)
  122. }
  123. }
  124. return value
  125. }
  126. // array
  127. if (type === 'array' && schema.items) {
  128. return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
  129. }
  130. // "primitive" values:
  131. const basicPromptOptions: DistinctQuestion = {
  132. message: propDisplayName,
  133. default: currentValue !== undefined ? currentValue : schema.default,
  134. }
  135. let additionalPromptOptions: DistinctQuestion | undefined
  136. let normalizer: (v: any) => any = (v) => v
  137. // Prompt options
  138. if (schema.enum) {
  139. additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
  140. } else if (type === 'boolean') {
  141. additionalPromptOptions = BOOL_PROMPT_OPTIONS
  142. }
  143. // Normalizers
  144. if (type === 'integer') {
  145. normalizer = (v) => (parseInt(v).toString() === v ? parseInt(v) : v)
  146. }
  147. if (type === 'number') {
  148. normalizer = (v) => (Number(v).toString() === v ? Number(v) : v)
  149. }
  150. const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
  151. // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
  152. return await this.promptWithRetry(
  153. async () => normalizer(await this.promptSimple(promptOptions, propertyPath, normalizer)),
  154. propertyPath
  155. )
  156. }
  157. private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
  158. _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
  159. this.ajv.validate(this.schema, this.filledObject) as boolean
  160. return this.ajv.errors
  161. ? this.ajv.errors
  162. .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
  163. .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
  164. .join(', ')
  165. : null
  166. }
  167. private async promptArray(schema: JSONSchema, propertyPath: string) {
  168. if (!schema.items) {
  169. return []
  170. }
  171. const { maxItems = Number.MAX_SAFE_INTEGER } = schema
  172. let currItem = 0
  173. const result = []
  174. while (currItem < maxItems) {
  175. const next = await this.inquirerSinglePrompt({
  176. ...BOOL_PROMPT_OPTIONS,
  177. message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
  178. default: _.get(this.filledObject, `${propertyPath}[${currItem}]`) !== undefined,
  179. })
  180. if (!next) {
  181. break
  182. }
  183. const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
  184. result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
  185. ++currItem
  186. }
  187. return result
  188. }
  189. private async promptSimple(promptOptions: DistinctQuestion, propertyPath: string, normalize?: (v: any) => any) {
  190. const result = await this.inquirerSinglePrompt({
  191. ...promptOptions,
  192. validate: (v) => {
  193. v = normalize ? normalize(v) : v
  194. return (
  195. this.setValueAndGetError(propertyPath, v) ||
  196. (promptOptions.validate ? promptOptions.validate(v) : true) ||
  197. true
  198. )
  199. },
  200. })
  201. return result
  202. }
  203. private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
  204. let error: string | null
  205. let value: any
  206. do {
  207. value = await customMethod()
  208. error = this.setValueAndGetError(propertyPath, value, nestedErrors)
  209. if (error) {
  210. console.log('\n')
  211. console.log('Provided value:', value)
  212. console.warn(`ERROR: ${error}`)
  213. console.warn(`Try providing the input for ${propertyPath} again...`)
  214. }
  215. } while (error)
  216. return value
  217. }
  218. async getMainSchema() {
  219. return await RefParser.dereference(this.schemaPath, this.schema, {})
  220. }
  221. async promptAll(allPropsRequired = false) {
  222. await this.prompt(await this.getMainSchema(), '', undefined, allPropsRequired)
  223. return this.filledObject as JsonResult
  224. }
  225. async promptMultipleProps<P extends keyof JsonResult & string, PA extends readonly P[]>(
  226. props: PA
  227. ): Promise<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> {
  228. const result: Partial<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> = {}
  229. for (const prop of props) {
  230. result[prop] = await this.promptSingleProp(prop)
  231. }
  232. return result as { [K in PA[number]]: Exclude<JsonResult[K], undefined> }
  233. }
  234. async promptSingleProp<P extends keyof JsonResult & string>(
  235. p: P,
  236. customPrompt?: CustomPrompt
  237. ): Promise<Exclude<JsonResult[P], undefined>> {
  238. const mainSchema = await this.getMainSchema()
  239. await this.prompt(mainSchema.properties![p] as JSONSchema, p, customPrompt)
  240. return this.filledObject[p] as Exclude<JsonResult[P], undefined>
  241. }
  242. async inquirerSinglePrompt(question: DistinctQuestion) {
  243. const { result } = await inquirer.prompt([
  244. {
  245. ...question,
  246. name: 'result',
  247. prefix: '',
  248. },
  249. ])
  250. return result
  251. }
  252. }