Skip to content

Commit

Permalink
fix(cli): in backup CLI, add common action to extract error from API …
Browse files Browse the repository at this point in the history
…response, use yargs to parse CLI flags
  • Loading branch information
j33ty committed Feb 12, 2024
1 parent fcd7c74 commit b0622b9
Show file tree
Hide file tree
Showing 15 changed files with 123 additions and 78 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import zlib from 'node:zlib'
import {createWriteStream} from 'node:fs'
import {ProgressData} from 'archiver'
import zlib from 'node:zlib'

import {type ProgressData} from 'archiver'

import debug from './debug'

const archiver = require('archiver')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {CliCommandContext} from '@sanity/cli'
import {type CliCommandContext} from '@sanity/cli'

import {defaultApiVersion} from '../../commands/backup/backupGroup'
import resolveApiClient from './resolveApiClient'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import rimraf from 'rimraf'

import debug from './debug'

function cleanupTmpDir(tmpDir: string): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import path from 'node:path'
import {createWriteStream} from 'node:fs'
import path from 'node:path'

import {getIt} from 'get-it'
import {keepAlive, promise} from 'get-it/middleware'
import withRetry from './withRetry'

import debug from './debug'
import withRetry from './withRetry'

const CONNECTION_TIMEOUT = 15 * 1000 // 15 seconds
const READ_TIMEOUT = 3 * 60 * 1000 // 3 minutes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {MiddlewareResponse} from 'get-it'
import {getIt} from 'get-it'
import {getIt, type MiddlewareResponse} from 'get-it'
import {keepAlive, promise} from 'get-it/middleware'

import debug from './debug'
import withRetry from './withRetry'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Readable} from 'node:stream'
import {SanityClient, QueryParams} from '@sanity/client'

import {type QueryParams, type SanityClient} from '@sanity/client'

type File = {
name: string
Expand Down
35 changes: 35 additions & 0 deletions packages/sanity/src/_internal/cli/actions/backup/parseApiErr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// apiErr is a type that represents an error returned by the API
interface ApiErr {
statusCode: number
message: string
}

// parseApiErr is a function that attempts with the best effort to parse
// an error returned by the API since different API endpoint may end up
// returning different error structures.
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types
function parseApiErr(err: any): ApiErr {
const apiErr = {} as ApiErr
if (err.code) {
apiErr.statusCode = err.code
} else if (err.statusCode) {
apiErr.statusCode = err.statusCode
}

if (err.message) {
apiErr.message = err.message
} else if (err.statusMessage) {
apiErr.message = err.statusMessage
} else if (err?.response?.body?.message) {
apiErr.message = err.response.body.message
} else if (err?.response?.data?.message) {
apiErr.message = err.response.data.message
} else {
// If no message can be extracted, print the whole error.
apiErr.message = JSON.stringify(err)
}

return apiErr
}

export default parseApiErr
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {CliOutputter} from '@sanity/cli'
import {type CliOutputter} from '@sanity/cli'
import prettyMs from 'pretty-ms'

type ProgressEvent = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {CliCommandContext} from '@sanity/cli'
import {SanityClient} from '@sanity/client'
import {type CliCommandContext} from '@sanity/cli'
import {type SanityClient} from '@sanity/client'

import {chooseDatasetPrompt} from '../dataset/chooseDatasetPrompt'

type ResolvedApiClient = {
Expand Down
14 changes: 1 addition & 13 deletions packages/sanity/src/_internal/cli/commands/backup/backupGroup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {CliCommandGroupDefinition} from '@sanity/cli'
import {type CliCommandGroupDefinition} from '@sanity/cli'

// defaultApiVersion is the backend API version used for dataset backup.
// First version of the backup API is vX since this feature is not yet released
Expand All @@ -13,16 +13,4 @@ const datasetBackupGroup: CliCommandGroupDefinition = {
hideFromHelp: true,
}

export function validateLimit(limit: string): string {
const parsed = parseInt(limit, 10)

// We allow limit up to Number.MAX_SAFE_INTEGER to leave it for server-side validation,
// while still sending sensible value in limit string.
if (isNaN(parsed) || parsed < 1 || parsed > Number.MAX_SAFE_INTEGER) {
throw new Error(`--limit must be an integer between 1 and ${Number.MAX_SAFE_INTEGER}`)
}

return limit.toString()
}

export default datasetBackupGroup
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {CliCommandDefinition} from '@sanity/cli'
import {type CliCommandDefinition} from '@sanity/cli'

import resolveApiClient from '../../actions/backup/resolveApiClient'
import {defaultApiVersion} from './backupGroup'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import {createWriteStream, existsSync, mkdirSync} from 'node:fs'
import {mkdtemp} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {mkdtemp} from 'node:fs/promises'
import type {
CliCommandArguments,
CliCommandContext,
CliCommandDefinition,
SanityClient,

import {
type CliCommandArguments,
type CliCommandContext,
type CliCommandDefinition,
type SanityClient,
} from '@sanity/cli'
import {absolutify} from '@sanity/util/fs'
import {isBoolean, isNumber, isString} from 'lodash'
import prettyMs from 'pretty-ms'
import {Mutex} from 'async-mutex'
import createDebug from 'debug'
import {isString} from 'lodash'
import prettyMs from 'pretty-ms'
import {hideBin} from 'yargs/helpers'
import yargs from 'yargs/yargs'

import archiveDir from '../../actions/backup/archiveDir'
import chooseBackupIdPrompt from '../../actions/backup/chooseBackupIdPrompt'
import resolveApiClient from '../../actions/backup/resolveApiClient'
import cleanupTmpDir from '../../actions/backup/cleanupTmpDir'
import downloadAsset from '../../actions/backup/downloadAsset'
import downloadDocument from '../../actions/backup/downloadDocument'
import {type File, PaginatedGetBackupStream} from '../../actions/backup/fetchNextBackupPage'
import parseApiErr from '../../actions/backup/parseApiErr'
import newProgress from '../../actions/backup/progressSpinner'
import {PaginatedGetBackupStream, File} from '../../actions/backup/fetchNextBackupPage'
import archiveDir from '../../actions/backup/archiveDir'
import cleanupTmpDir from '../../actions/backup/cleanupTmpDir'
import resolveApiClient from '../../actions/backup/resolveApiClient'
import humanFileSize from '../../util/humanFileSize'
import isPathDirName from '../../util/isPathDirName'
import {defaultApiVersion} from './backupGroup'

const debug = createDebug('sanity:backup')
Expand Down Expand Up @@ -53,6 +59,14 @@ Examples
sanity backup download DATASET_NAME --backup-id 2024-01-01-backup-3 --out /path/to/file --overwrite
`

function parseCliFlags(args: {argv?: string[]}) {
return yargs(hideBin(args.argv || process.argv).slice(2))
.options('backup-id', {type: 'string'})
.options('out', {type: 'string'})
.options('concurrency', {type: 'number', default: DEFAULT_DOWNLOAD_CONCURRENCY})
.options('overwrite', {type: 'boolean', default: false}).argv
}

const downloadBackupCommand: CliCommandDefinition = {
name: 'download',
group: 'backup',
Expand Down Expand Up @@ -152,12 +166,8 @@ const downloadBackupCommand: CliCommandDefinition = {
)
} catch (error) {
progressSpinner.fail()
let msg = error.statusCode ? error.response.body.message : error.message
// If no message can be extracted, print the whole error.
if (msg === undefined) {
msg = String(error)
}
throw new Error(`Downloading dataset backup failed: ${msg}`)
const {message} = parseApiErr(error)
throw new Error(`Downloading dataset backup failed: ${message}`)
}

progressSpinner.set({step: `Archiving files into a tarball...`, update: true})
Expand Down Expand Up @@ -189,7 +199,7 @@ async function prepareBackupOptions(
context: CliCommandContext,
args: CliCommandArguments,
): Promise<[SanityClient, DownloadBackupOptions]> {
const flags = args.extOptions
const flags = await parseCliFlags(args)
const [dataset] = args.argsWithoutOptions
const {prompt, workDir} = context
const {projectId, datasetName, client} = await resolveApiClient(
Expand All @@ -213,25 +223,14 @@ async function prepareBackupOptions(
}

if ('concurrency' in flags) {
if (
!isNumber(flags.concurrency) ||
Number(flags.concurrency) < 1 ||
Number(flags.concurrency) > MAX_DOWNLOAD_CONCURRENCY
) {
if (flags.concurrency < 1 || flags.concurrency > MAX_DOWNLOAD_CONCURRENCY) {
throw new Error(`concurrency should be in 1 to ${MAX_DOWNLOAD_CONCURRENCY} range`)
}
}

if ('overwrite' in flags && !isBoolean(flags.overwrite)) {
throw new Error(`overwrite should be valid boolean`)
}

const defaultOutFileName = `${datasetName}-backup-${backupId}.tar.gz`
let out = await (async (): Promise<string> => {
if ('out' in flags) {
if (!isString(flags.out)) {
throw new Error(`output path should be valid string`)
}
if (flags.out !== undefined) {
// Rewrite the output path to an absolute path, if it is not already.
return absolutify(flags.out)
}
Expand Down Expand Up @@ -274,15 +273,10 @@ async function prepareBackupOptions(
token,
outDir: path.dirname(out),
outFileName: path.basename(out),
overwrite: Boolean(flags.overwrite),
concurrency: Number(flags.concurrency) || DEFAULT_DOWNLOAD_CONCURRENCY,
overwrite: flags.overwrite,
concurrency: flags.concurrency || DEFAULT_DOWNLOAD_CONCURRENCY,
},
]
}

function isPathDirName(filepath: string): boolean {
// Check if the path has an extension, commonly indicating a file
return !/\.\w+$/.test(filepath)
}

export default downloadBackupCommand
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {CliCommandDefinition} from '@sanity/cli'
import {type CliCommandDefinition} from '@sanity/cli'

import resolveApiClient from '../../actions/backup/resolveApiClient'
import {defaultApiVersion} from './backupGroup'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type {CliCommandDefinition} from '@sanity/cli'
import {type CliCommandDefinition} from '@sanity/cli'
import {Table} from 'console-table-printer'
import {isAfter, isValid, lightFormat, parse} from 'date-fns'
import {hideBin} from 'yargs/helpers'
import yargs from 'yargs/yargs'

import parseApiErr from '../../actions/backup/parseApiErr'
import resolveApiClient from '../../actions/backup/resolveApiClient'
import {defaultApiVersion, validateLimit} from './backupGroup'
import {defaultApiVersion} from './backupGroup'

const DEFAULT_LIST_BACKUP_LIMIT = 30

Expand Down Expand Up @@ -40,6 +44,13 @@ Examples
sanity backup list DATASET_NAME --after 2024-01-31 --before 2024-01-10
`

function parseCliFlags(args: {argv?: string[]}) {
return yargs(hideBin(args.argv || process.argv).slice(2))
.options('after', {type: 'string'})
.options('before', {type: 'string'})
.options('limit', {type: 'number', default: DEFAULT_LIST_BACKUP_LIMIT, alias: 'l'}).argv
}

const listDatasetBackupCommand: CliCommandDefinition<ListDatasetBackupFlags> = {
name: 'list',
group: 'backup',
Expand All @@ -48,7 +59,7 @@ const listDatasetBackupCommand: CliCommandDefinition<ListDatasetBackupFlags> = {
helpText,
action: async (args, context) => {
const {output, chalk} = context
const flags = args.extOptions
const flags = await parseCliFlags(args)
const [dataset] = args.argsWithoutOptions

const {projectId, datasetName, token, client} = await resolveApiClient(
Expand All @@ -59,19 +70,22 @@ const listDatasetBackupCommand: CliCommandDefinition<ListDatasetBackupFlags> = {

const query: ListBackupRequestQueryParams = {limit: DEFAULT_LIST_BACKUP_LIMIT.toString()}
if (flags.limit) {
try {
query.limit = validateLimit(flags.limit)
} catch (err) {
throw new Error(`Parsing --limit: ${err}`)
// We allow limit up to Number.MAX_SAFE_INTEGER to leave it for server-side validation,
// while still sending sensible value in limit string.
if (flags.limit < 1 || flags.limit > Number.MAX_SAFE_INTEGER) {
throw new Error(
`Parsing --limit: must be an integer between 1 and ${Number.MAX_SAFE_INTEGER}`,
)
}
query.limit = flags.limit.toString()
}

if (flags.before || flags.after) {
try {
const parsedBefore = processDateFlags(flags.before)
const parsedAfter = processDateFlags(flags.after)

if (parsedAfter && parsedBefore && isAfter(parsedBefore, parsedAfter)) {
if (parsedAfter && parsedBefore && isAfter(parsedAfter, parsedBefore)) {
throw new Error('--after date must be before --before')
}

Expand All @@ -90,10 +104,8 @@ const listDatasetBackupCommand: CliCommandDefinition<ListDatasetBackupFlags> = {
query: {...query},
})
} catch (error) {
const msg = error.statusCode
? error.response.body.message
: error.message || error.statusMessage
output.error(`${chalk.red(`List dataset backup failed: ${msg}`)}\n`)
const {message} = parseApiErr(error)
output.error(`${chalk.red(`List dataset backup failed: ${message}`)}\n`)
}

if (response && response.backups) {
Expand Down Expand Up @@ -126,7 +138,7 @@ const listDatasetBackupCommand: CliCommandDefinition<ListDatasetBackupFlags> = {

function processDateFlags(date: string | undefined): Date | undefined {
if (!date) return undefined
const parsedDate = parse(date, 'YYYY-MM-DD', new Date())
const parsedDate = parse(date, 'yyyy-MM-dd', new Date())
if (isValid(parsedDate)) {
return parsedDate
}
Expand Down
6 changes: 6 additions & 0 deletions packages/sanity/src/_internal/cli/util/isPathDirName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
function isPathDirName(filepath: string): boolean {
// Check if the path has an extension, commonly indicating a file
return !/\.\w+$/.test(filepath)
}

export default isPathDirName

0 comments on commit b0622b9

Please sign in to comment.