UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

524 lines (447 loc) 15.7 kB
/* eslint-disable no-process-env, no-process-exit, max-statements */ import {type CliCommandContext, type CliOutputter, type CliPrompter} from '@sanity/cli' import {type SanityClient} from '@sanity/client' import {get} from 'lodash' import oneline from 'oneline' import {hideBin} from 'yargs/helpers' import yargs from 'yargs/yargs' import {debug} from '../../debug' import {getClientUrl} from '../../util/getClientUrl' import {getUrlHeaders} from '../../util/getUrlHeaders' import {extractFromSanitySchema} from './extractFromSanitySchema' import gen1 from './gen1' import gen2 from './gen2' import gen3 from './gen3' import {getGraphQLAPIs} from './getGraphQLAPIs' import {SchemaError} from './SchemaError' import {type DeployResponse, type GeneratedApiSpecification, type ValidationResponse} from './types' const latestGeneration = 'gen3' const generations = { gen1, gen2, gen3, } const apiIdRegex = /^[a-z0-9_-]+$/ const isInteractive = process.stdout.isTTY && process.env.TERM !== 'dumb' && !('CI' in process.env) const ignoredWarnings: string[] = ['OPTIONAL_INPUT_FIELD_ADDED'] const ignoredBreaking: string[] = [] interface DeployTask { dataset: string projectId: string tag: string enablePlayground: boolean schema: GeneratedApiSpecification } // eslint-disable-next-line complexity export default async function deployGraphQLApiAction( args: {argv?: string[]}, context: CliCommandContext, ): Promise<void> { // Reparsing CLI flags for better control of binary flags const flags = await parseCliFlags(args) const { force, dryRun, api: onlyApis, dataset: datasetFlag, tag: tagFlag, playground: playgroundFlag, generation: generationFlag, 'non-null-document-fields': nonNullDocumentFieldsFlag, withUnionCache, } = flags const {apiClient, output, prompt} = context let spinner const client = apiClient({ requireUser: true, // Don't throw if we do not have a project ID defined, as we will infer it from the // source/ workspace of each configured API later requireProject: false, }).config({apiVersion: '2023-08-01'}) const apiDefs = await getGraphQLAPIs(context) const hasMultipleApis = apiDefs.length > 1 || (flags.api && flags.api.length > 1) const usedFlags = [ datasetFlag && '--dataset', tagFlag && '--tag', typeof playgroundFlag !== 'undefined' && '--playground', typeof generationFlag !== 'undefined' && '--generation', typeof nonNullDocumentFieldsFlag !== 'undefined' && '--non-null-document-fields', ].filter(Boolean) if (hasMultipleApis && usedFlags.length > 0) { output.warn(`WARN: More than one API defined, and ${usedFlags.join('/')} is specified`) output.warn(`WARN: This will use the specified flag(s) for ALL APIs, overriding config!`) if (flags.force) { output.warn(`WARN: --force specified, continuing...`) } else if ( !(await prompt.single({ type: 'confirm', message: 'Continue with flag overrides for all APIs?', default: false, })) ) { process.exit(1) } } const deployTasks: DeployTask[] = [] const apiNames = new Set<string>() const apiIds = new Set<string>() for (const apiDef of apiDefs) { const dataset = datasetFlag || apiDef.dataset const tag = tagFlag || apiDef.tag || 'default' const apiName = [dataset, tag].join('/') if (apiNames.has(apiName)) { throw new Error(`Multiple GraphQL APIs with the same dataset and tag found (${apiName})`) } if (apiDef.id) { if (typeof apiDef.id !== 'string' || !apiIdRegex.test(apiDef.id)) { throw new Error( `Invalid GraphQL API id "${apiDef.id}" - only a-z, 0-9, underscore and dashes are allowed`, ) } if (apiIds.has(apiDef.id)) { throw new Error(`Multiple GraphQL APIs with the same ID found (${apiDef.id})`) } apiIds.add(apiDef.id) } apiNames.add(apiName) } for (const apiId of onlyApis || []) { if (!apiDefs.some((apiDef) => apiDef.id === apiId)) { throw new Error(`GraphQL API with id "${apiId}" not found`) } } if (onlyApis) { output.warn(`Deploying only specified APIs: ${onlyApis.join(', ')}`) } let index = -1 for (const apiDef of apiDefs) { if (onlyApis && (!apiDef.id || !onlyApis.includes(apiDef.id))) { continue } index++ const dataset = datasetFlag || apiDef.dataset const tag = tagFlag || apiDef.tag || 'default' const {projectId, playground, nonNullDocumentFields, schema} = apiDef const apiName = [dataset, tag].join('/') spinner = output.spinner(`Generating GraphQL API: ${apiName}`).start() if (!dataset) { throw new Error(`No dataset specified for API at index ${index}`) } const projectClient = client.clone().config({projectId, useProjectHostname: true}) const {currentGeneration, playgroundEnabled} = await getCurrentSchemaProps( projectClient, dataset, tag, ) // CLI flag trumps configuration const specifiedGeneration = typeof generationFlag === 'undefined' ? apiDef.generation : generationFlag const generation = await resolveApiGeneration({ currentGeneration, specifiedGeneration, index, force, output, prompt, }) if (!generation) { // User cancelled spinner.fail() continue } if (!isRecognizedApiGeneration(generation)) { throw new Error(`Unknown API generation "${generation}" for API at index ${index}`) } const enablePlayground = await shouldEnablePlayground({ dryRun, spinner, playgroundCliFlag: playgroundFlag, playgroundConfiguration: playground, playgroundCurrentlyEnabled: playgroundEnabled, prompt, }) let apiSpec: GeneratedApiSpecification try { const generateSchema = generations[generation] const extracted = extractFromSanitySchema(schema, { // Allow CLI flag to override configured setting nonNullDocumentFields: typeof nonNullDocumentFieldsFlag === 'undefined' ? nonNullDocumentFields : nonNullDocumentFieldsFlag, withUnionCache, }) apiSpec = generateSchema(extracted, {filterSuffix: apiDef.filterSuffix}) } catch (err) { spinner.fail() if (err instanceof SchemaError) { err.print(output) process.exit(1) // eslint-disable-line no-process-exit } throw err } let valid: ValidationResponse | undefined try { valid = await projectClient.request<ValidationResponse>({ url: `/apis/graphql/${dataset}/${tag}/validate`, method: 'POST', body: {enablePlayground, schema: apiSpec}, maxRedirects: 0, }) } catch (err) { const validationError = get(err, 'response.body.validationError') spinner.fail() throw validationError ? new Error(validationError) : err } // when the result is not valid and there are breaking changes afoot! if (!isResultValid(valid, {spinner, force})) { // not valid and a dry run? then it can exit with a error if (dryRun) { spinner.fail() renderBreakingChanges(valid, output) process.exit(1) } if (!isInteractive) { spinner.fail() renderBreakingChanges(valid, output) throw new Error( 'Dangerous changes found - falling back. Re-run the command with the `--force` flag to force deployment.', ) } spinner.stop() renderBreakingChanges(valid, output) const shouldDeploy = await prompt.single({ type: 'confirm', message: 'Do you want to deploy a new API despite the dangerous changes?', default: false, }) if (!shouldDeploy) { spinner.fail() continue } spinner.succeed() } else if (dryRun) { spinner.succeed() output.print('GraphQL API is valid and has no breaking changes') process.exit(0) } deployTasks.push({ projectId, dataset, tag, enablePlayground, schema: apiSpec, }) } // Give some space for deployment tasks output.print('') for (const task of deployTasks) { const {dataset, tag, schema, projectId, enablePlayground} = task output.print(`Project: ${projectId}`) output.print(`Dataset: ${dataset}`) output.print(`Tag: ${tag}`) spinner = output.spinner('Deploying GraphQL API').start() try { const projectClient = client.clone().config({projectId, useProjectHostname: true}) const response = await projectClient.request<DeployResponse>({ url: `/apis/graphql/${dataset}/${tag}`, method: 'PUT', body: {enablePlayground, schema}, maxRedirects: 0, }) spinner.stop() const apiUrl = getClientUrl( projectClient, response.location.replace(/^\/(v1|v\d{4}-\d{2}-\d{2})\//, '/'), ) output.print(`URL: ${apiUrl}`) spinner.start('Deployed!').succeed() output.print('') } catch (err) { spinner.fail() throw err } } // Because of side effects when loading the schema, we can end up in situations where // the API has been successfully deployed, but some timer or other handle is keeping // the process from naturally exiting. process.exit(0) } async function shouldEnablePlayground({ dryRun, spinner, playgroundCliFlag, playgroundConfiguration, playgroundCurrentlyEnabled, prompt, }: { dryRun: boolean spinner: ReturnType<CliCommandContext['output']['spinner']> playgroundCliFlag?: boolean playgroundConfiguration?: boolean playgroundCurrentlyEnabled?: boolean prompt: CliCommandContext['prompt'] }): Promise<boolean> { // On a dry run, it doesn't matter, return true 🤷‍♂️ if (dryRun) { return true } // Prioritize CLI flag if set if (typeof playgroundCliFlag !== 'undefined') { return playgroundCliFlag } // If explicitly set true/false in configuration, use that if (typeof playgroundConfiguration !== 'undefined') { return playgroundConfiguration } // If API is already deployed, use the current state if (typeof playgroundCurrentlyEnabled !== 'undefined') { return playgroundCurrentlyEnabled } // If no API is deployed, default to true if non-interactive if (!isInteractive) { return true } // Interactive environment, so prompt the user const prevText = spinner.text spinner.warn() const shouldDeploy = await prompt.single<boolean>({ type: 'confirm', message: 'Do you want to enable a GraphQL playground?', default: true, }) spinner.clear().start(prevText) return shouldDeploy } async function getCurrentSchemaProps( client: SanityClient, dataset: string, tag: string, ): Promise<{ currentGeneration?: string playgroundEnabled?: boolean }> { try { const apiUrl = getClientUrl(client, `/apis/graphql/${dataset}/${tag}`) const res = await getUrlHeaders(apiUrl, { Authorization: `Bearer ${client.config().token}`, }) return { currentGeneration: res['x-sanity-graphql-generation'], playgroundEnabled: res['x-sanity-graphql-playground'] === 'true', } } catch (err) { if (err.statusCode === 404) { return {} } throw err } } function parseCliFlags(args: {argv?: string[]}) { return yargs(hideBin(args.argv || process.argv).slice(2)) .option('tag', {type: 'string'}) .option('dataset', {type: 'string'}) .option('api', {type: 'string', array: true}) .option('dry-run', {type: 'boolean', default: false}) .option('generation', {type: 'string'}) .option('non-null-document-fields', {type: 'boolean'}) .option('playground', {type: 'boolean'}) .option('with-union-cache', {type: 'boolean'}) .option('force', {type: 'boolean'}).argv } function isResultValid( valid: ValidationResponse, {spinner, force}: {spinner: any; force?: boolean}, ) { const {validationError, breakingChanges: breaking, dangerousChanges: dangerous} = valid if (validationError) { spinner.fail() throw new Error(`GraphQL schema is not valid:\n\n${validationError}`) } const breakingChanges = breaking.filter((change) => !ignoredBreaking.includes(change.type)) const dangerousChanges = dangerous.filter((change) => !ignoredWarnings.includes(change.type)) const hasProblematicChanges = breakingChanges.length > 0 || dangerousChanges.length > 0 if (force && hasProblematicChanges) { spinner.text = 'Validating GraphQL API: Dangerous changes. Forced with `--force`.' spinner.warn() return true } else if (force || !hasProblematicChanges) { spinner.succeed() return true } spinner.warn() return false } function renderBreakingChanges(valid: ValidationResponse, output: CliOutputter) { const {breakingChanges: breaking, dangerousChanges: dangerous} = valid const breakingChanges = breaking.filter((change) => !ignoredBreaking.includes(change.type)) const dangerousChanges = dangerous.filter((change) => !ignoredWarnings.includes(change.type)) if (dangerousChanges.length > 0) { output.print('\nFound potentially dangerous changes from previous schema:') dangerousChanges.forEach((change) => output.print(` - ${change.description}`)) } if (breakingChanges.length > 0) { output.print('\nFound BREAKING changes from previous schema:') breakingChanges.forEach((change) => output.print(` - ${change.description}`)) } output.print('') } async function resolveApiGeneration({ currentGeneration, specifiedGeneration, index, force, output, prompt, }: { index: number currentGeneration?: string specifiedGeneration?: string force?: boolean output: CliOutputter prompt: CliPrompter }): Promise<string | undefined> { // a) If no API is currently deployed: // use the specificed one from config, or use whichever generation is the latest // b) If an API generation is specified explicitly: // use the given one, but _prompt_ if it differs from the current one // c) If no API generation is specified explicitly: // use whichever is already deployed, but warn if differs from latest if (!currentGeneration) { const generation = specifiedGeneration || latestGeneration debug( 'There is no current generation deployed, using %s (%s)', generation, specifiedGeneration ? 'specified' : 'default', ) return generation } if (specifiedGeneration && specifiedGeneration !== currentGeneration) { if (!force && !isInteractive) { throw new Error(oneline` Specified generation (${specifiedGeneration}) for API at index ${index} differs from the one currently deployed (${currentGeneration}). Re-run the command with \`--force\` to force deployment. `) } output.warn( `Specified generation (${specifiedGeneration}) for API at index ${index} differs from the one currently deployed (${currentGeneration}).`, ) const confirmDeploy = force || (await prompt.single({ type: 'confirm', message: 'Are you sure you want to deploy?', default: false, })) return confirmDeploy ? specifiedGeneration : undefined } if (specifiedGeneration) { debug('Using specified (%s) generation', specifiedGeneration) return specifiedGeneration } debug('Using the currently deployed version (%s)', currentGeneration) return currentGeneration } function isRecognizedApiGeneration(generation: string): generation is 'gen1' | 'gen2' | 'gen3' { return generations.hasOwnProperty(generation) }