diff --git a/.changeset/old-pumpkins-peel.md b/.changeset/old-pumpkins-peel.md new file mode 100644 index 00000000000..2995d298767 --- /dev/null +++ b/.changeset/old-pumpkins-peel.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/backend-cli': minor +--- + +Add generate graphql-client-code command with mocked implementation diff --git a/packages/cli/src/commands/generate/generate_command.ts b/packages/cli/src/commands/generate/generate_command.ts index 5b5578acfb4..3b42236f9fc 100644 --- a/packages/cli/src/commands/generate/generate_command.ts +++ b/packages/cli/src/commands/generate/generate_command.ts @@ -1,5 +1,6 @@ import { Argv, CommandModule } from 'yargs'; import { GenerateConfigCommand } from './config/generate_config_command.js'; +import { GenerateGraphqlClientCodeCommand } from './graphql-client-code/generate_graphql_client_code_command.js'; /** * An entry point for generate command. @@ -18,7 +19,10 @@ export class GenerateCommand implements CommandModule { /** * Creates top level entry point for generate command. */ - constructor(private readonly generateConfigCommand: GenerateConfigCommand) { + constructor( + private readonly generateConfigCommand: GenerateConfigCommand, + private readonly generateGraphqlClientCodeCommand: GenerateGraphqlClientCodeCommand + ) { this.command = 'generate'; this.describe = 'Generates post deployment artifacts'; } @@ -38,6 +42,9 @@ export class GenerateCommand implements CommandModule { yargs // Cast to erase options types used in internal sub command implementation. Otherwise, compiler fails here. .command(this.generateConfigCommand as unknown as CommandModule) + .command( + this.generateGraphqlClientCodeCommand as unknown as CommandModule + ) .demandCommand() .strictCommands() .recommendCommands() diff --git a/packages/cli/src/commands/generate/generate_command_factory.test.ts b/packages/cli/src/commands/generate/generate_command_factory.test.ts index 03f54bf83c2..f4dd1f710c2 100644 --- a/packages/cli/src/commands/generate/generate_command_factory.test.ts +++ b/packages/cli/src/commands/generate/generate_command_factory.test.ts @@ -16,10 +16,14 @@ describe('top level generate command', () => { const parser = yargs().command(generateCommand); const commandRunner = new TestCommandRunner(parser); - it('includes generate config in help output', async () => { + it('includes generate config and graphql-client-code in help output', async () => { const output = await commandRunner.runCommand('generate --help'); assert.match(output, /Commands:/); - assert.match(output, /generate config {2}Generates client config/); + assert.match(output, /generate config\W*Generates client config/); + assert.match( + output, + /generate graphql-client-code\W*Generates graphql API code/ + ); }); it('fails if subcommand is not provided', async () => { diff --git a/packages/cli/src/commands/generate/generate_command_factory.ts b/packages/cli/src/commands/generate/generate_command_factory.ts index 93cefba2633..382609f3227 100644 --- a/packages/cli/src/commands/generate/generate_command_factory.ts +++ b/packages/cli/src/commands/generate/generate_command_factory.ts @@ -5,6 +5,8 @@ import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { ClientConfigGeneratorAdapter } from './config/client_config_generator_adapter.js'; import { LocalAppNameResolver } from '../../local_app_name_resolver.js'; import { CwdPackageJsonLoader } from '../../cwd_package_json_loader.js'; +import { GenerateGraphqlClientCodeCommand } from './graphql-client-code/generate_graphql_client_code_command.js'; +import { GraphqlClientCodeGeneratorAdapter } from './graphql-client-code/generate_graphql_client_code_generator_adapter.js'; /** * Creates wired generate command. @@ -23,5 +25,16 @@ export const createGenerateCommand = (): CommandModule => { localAppNameResolver ); - return new GenerateCommand(generateConfigCommand); + const graphqlClientCodeGeneratorAdapter = + new GraphqlClientCodeGeneratorAdapter(credentialProvider); + + const generateGraphqlClientCodeCommand = new GenerateGraphqlClientCodeCommand( + graphqlClientCodeGeneratorAdapter, + localAppNameResolver + ); + + return new GenerateCommand( + generateConfigCommand, + generateGraphqlClientCodeCommand + ); }; diff --git a/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.test.ts b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.test.ts new file mode 100644 index 00000000000..e4dccfdc308 --- /dev/null +++ b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { GenerateGraphqlClientCodeCommand } from './generate_graphql_client_code_command.js'; +import yargs, { CommandModule } from 'yargs'; +import { + TestCommandError, + TestCommandRunner, +} from '../../../test-utils/command_runner.js'; +import assert from 'node:assert'; +import path from 'path'; +import { GraphqlClientCodeGeneratorAdapter } from './generate_graphql_client_code_generator_adapter.js'; + +describe('generate graphql-client-code command', () => { + const graphqlClientCodeGeneratorAdapter = + new GraphqlClientCodeGeneratorAdapter(fromNodeProviderChain()); + + const generateClientConfigMock = mock.method( + graphqlClientCodeGeneratorAdapter, + 'generateGraphqlClientCodeToFile', + () => Promise.resolve() + ); + + const generateGraphqlClientCodeCommand = new GenerateGraphqlClientCodeCommand( + graphqlClientCodeGeneratorAdapter, + { resolve: () => Promise.resolve('testAppName') } + ); + const parser = yargs().command( + generateGraphqlClientCodeCommand as unknown as CommandModule + ); + const commandRunner = new TestCommandRunner(parser); + + beforeEach(() => { + generateClientConfigMock.mock.resetCalls(); + }); + + it('generates and writes graphql client code for stack', async () => { + await commandRunner.runCommand('graphql-client-code --stack stack_name'); + assert.equal(generateClientConfigMock.mock.callCount(), 1); + assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + stackName: 'stack_name', + format: 'graphql-codegen', + out: process.cwd(), + statementTarget: 'javascript', + }); + }); + + it('generates and writes graphql client code for branch', async () => { + await commandRunner.runCommand('graphql-client-code --branch branch_name'); + assert.equal(generateClientConfigMock.mock.callCount(), 1); + assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + appName: 'testAppName', + branchName: 'branch_name', + out: process.cwd(), + format: 'graphql-codegen', + statementTarget: 'javascript', + }); + }); + + it('generates and writes graphql client code for appID and branch', async () => { + await commandRunner.runCommand( + 'graphql-client-code --branch branch_name --appId app_id' + ); + assert.equal(generateClientConfigMock.mock.callCount(), 1); + assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + backendId: 'app_id', + branchName: 'branch_name', + out: process.cwd(), + format: 'graphql-codegen', + statementTarget: 'javascript', + }); + }); + + it('can generate to custom absolute path', async () => { + await commandRunner.runCommand( + 'graphql-client-code --stack stack_name --out /foo/bar' + ); + assert.equal(generateClientConfigMock.mock.callCount(), 1); + assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + stackName: 'stack_name', + out: path.join('/', 'foo', 'bar'), + format: 'graphql-codegen', + statementTarget: 'javascript', + }); + }); + + it('can generate to custom relative path', async () => { + await commandRunner.runCommand( + 'graphql-client-code --stack stack_name --out foo/bar' + ); + assert.equal(generateClientConfigMock.mock.callCount(), 1); + assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + stackName: 'stack_name', + format: 'graphql-codegen', + statementTarget: 'javascript', + out: path.join(process.cwd(), 'foo', 'bar'), + }); + }); + + it('shows available options in help output', async () => { + const output = await commandRunner.runCommand('graphql-client-code --help'); + assert.match(output, /--stack/); + assert.match(output, /--appId/); + assert.match(output, /--branch/); + assert.match(output, /--format/); + assert.match(output, /--statementTarget/); + assert.match(output, /--typeTarget/); + assert.match(output, /--modelTarget/); + assert.match(output, /--out/); + }); + + it('fails if both stack and branch are present', async () => { + await assert.rejects( + () => + commandRunner.runCommand( + 'graphql-client-code --stack foo --branch baz' + ), + (err: TestCommandError) => { + assert.equal(err.error.name, 'YError'); + assert.match(err.error.message, /Arguments .* mutually exclusive/); + assert.match(err.output, /Arguments .* are mutually exclusive/); + return true; + } + ); + }); + + // it('can be invoked explicitly with graphql-codegen format', async () => { + // await commandRunner.runCommand('graphql-client-code --stack stack_name --format graphql-codegen'); + // assert.equal(generateClientConfigMock.mock.callCount(), 1); + // assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + // stackName: 'stack_name', + // format: 'graphql-codegen', + // statementType: 'javascript', + // out: process.cwd(), + // }); + // }); + + // it('can be invoked explicitly with modelgen format', async () => { + // await commandRunner.runCommand('graphql-client-code --stack stack_name --format modelgen'); + // assert.equal(generateClientConfigMock.mock.callCount(), 1); + // assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + // stackName: 'stack_name', + // format: 'modelgen', + // modelType: 'javascript', + // out: process.cwd(), + // }); + // }); + + // it('can be invoked explicitly with introspection format', async () => { + // await commandRunner.runCommand('graphql-client-code --stack stack_name --format introspection'); + // assert.equal(generateClientConfigMock.mock.callCount(), 1); + // assert.deepEqual(generateClientConfigMock.mock.calls[0].arguments[0], { + // stackName: 'stack_name', + // format: 'introspection', + // out: process.cwd(), + // }); + // }); +}); diff --git a/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts new file mode 100644 index 00000000000..4ab2f2a822f --- /dev/null +++ b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_command.ts @@ -0,0 +1,212 @@ +import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { BackendIdentifier } from '@aws-amplify/client-config'; +import { AppNameResolver } from '../../../local_app_name_resolver.js'; +import { GraphqlClientCodeGeneratorAdapter } from './generate_graphql_client_code_generator_adapter.js'; +import { isAbsolute, resolve } from 'path'; + +export const formatChoices = ['amplify-codegen', 'introspection', 'modelgen']; +export const modelgenTargetChoices = [ + 'java', + 'swift', + 'javascript', + 'typescript', + 'dart', +]; +export const statementsTargetChoices = [ + 'javascript', + 'graphql', + 'flow', + 'typescript', + 'angular', +]; +export const typesTargetChoice = [ + 'json', + 'swift', + 'ts', + 'typescript', + 'flow', + 'scala', + 'flow-modern', + 'angular', +]; + +export type GenerateGraphqlClientCodeCommandOptions = { + stack: string | undefined; + appId: string | undefined; + branch: string | undefined; + format: (typeof formatChoices)[number] | undefined; + modelTarget: (typeof modelgenTargetChoices)[number] | undefined; + statementTarget: (typeof statementsTargetChoices)[number] | undefined; + typeTarget: (typeof typesTargetChoice)[number] | undefined; + out: string | undefined; +}; + +/** + * Command that generates client config. + */ +export class GenerateGraphqlClientCodeCommand + implements CommandModule +{ + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + private readonly missingArgsError = new Error( + 'Either --stack or --branch must be provided' + ); + + /** + * Creates graphql client code generation command. + */ + constructor( + private readonly graphqlClientCodeGeneratorAdapter: GraphqlClientCodeGeneratorAdapter, + private readonly appNameResolver: AppNameResolver + ) { + this.command = 'graphql-client-code'; + this.describe = 'Generates graphql API code'; + } + + private getTargetParts = ( + format: string, + args: ArgumentsCamelCase + ): Record => { + switch (format) { + case 'graphql-codegen': + return { + statementTarget: args.statementTarget ?? 'javascript', + ...(args.typeTarget ? { typeTarget: args.typeTarget } : {}), + }; + case 'modelgen': + return { + modelTarget: args.modelTarget ?? 'javascript', + }; + case 'introspection': + return {}; + default: + throw new Error(`Unexpected format ${format} received`); + } + }; + + private getOutDir = ( + args: ArgumentsCamelCase + ) => { + const cwd = process.cwd(); + if (!args.out) { + return cwd; + } + return isAbsolute(args.out) ? args.out : resolve(cwd, args.out); + }; + + /** + * @inheritDoc + */ + handler = async ( + args: ArgumentsCamelCase + ): Promise => { + const backendIdentifierParts = await this.getBackendIdentifier(args); + const out = this.getOutDir(args); + const format = args.format ?? ('graphql-codegen' as unknown as any); + const targetParts = this.getTargetParts(format, args); + + await this.graphqlClientCodeGeneratorAdapter.generateGraphqlClientCodeToFile( + { + ...backendIdentifierParts, + out, + format, + ...targetParts, + } + ); + }; + + /** + * Translates args to BackendIdentifier. + * Throws if translation can't be made (this should never happen if command validation works correctly). + */ + private getBackendIdentifier = async ( + args: ArgumentsCamelCase + ): Promise => { + if (args.stack) { + return { stackName: args.stack }; + } else if (args.appId && args.branch) { + return { backendId: args.appId, branchName: args.branch }; + } else if (args.branch) { + return { + appName: await this.appNameResolver.resolve(), + branchName: args.branch, + }; + } + throw this.missingArgsError; + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv): Argv => { + return yargs + .option('stack', { + conflicts: ['appId', 'branch'], + describe: 'A stack name that contains an Amplify backend', + type: 'string', + array: false, + group: 'Stack identifier', + }) + .option('appId', { + conflicts: ['stack'], + describe: 'The Amplify App ID of the project', + type: 'string', + array: false, + implies: 'branch', + group: 'Project identifier', + }) + .option('branch', { + conflicts: ['stack'], + describe: 'A git branch of the Amplify project', + type: 'string', + array: false, + group: 'Project identifier', + }) + .option('out', { + describe: + 'A path to directory where config is written. If not provided defaults to current process working directory.', + type: 'string', + array: false, + }) + .option('format', { + describe: + 'The format that the GraphQL client code should be generated in.', + type: 'string', + array: false, + choices: formatChoices, + }) + .option('modelTarget', { + describe: 'TK', + type: 'string', + array: false, + choices: modelgenTargetChoices, + }) + .option('statementTarget', { + describe: 'TK', + type: 'string', + array: false, + choices: statementsTargetChoices, + }) + .option('typeTarget', { + describe: 'TK', + type: 'string', + array: false, + choices: typesTargetChoice, + }) + .check((argv) => { + if (!argv.stack && !argv.branch) { + throw this.missingArgsError; + } + return true; + }); + }; +} diff --git a/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_generator_adapter.ts b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_generator_adapter.ts new file mode 100644 index 00000000000..3ab293be711 --- /dev/null +++ b/packages/cli/src/commands/generate/graphql-client-code/generate_graphql_client_code_generator_adapter.ts @@ -0,0 +1,57 @@ +import fs from 'fs'; +import path from 'path'; +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import { + GenerateAPICodeProps, + GeneratedOutput, + generateAPICode, +} from './mock_code_generator.js'; + +export type GenerateGraphqlClientCodeProps = GenerateAPICodeProps; +export type GenerateGraphqlClientCodeToFileProps = + GenerateGraphqlClientCodeProps & { + out: string; + }; + +/** + * Adapts wraps calls to the code generator, and generates credentials providers, etc. + */ +export class GraphqlClientCodeGeneratorAdapter { + /** + * Creates new adapter for generateClientConfigToFile from @aws-amplify/client-config. + */ + constructor( + private readonly awsCredentialProvider: AwsCredentialIdentityProvider + ) {} + + /** + * Generates the platform-specific graphql client code for a given backend + */ + generateGraphqlClientCode = ( + props: GenerateGraphqlClientCodeProps + ): Promise => + generateAPICode({ + ...props, + // credentialProvider: this.awsCredentialProvider, + }); + + /** + * Generates the platform-specific graphql client code for a given backend, and write the outputs to the specified target. + */ + generateGraphqlClientCodeToFile = async ( + props: GenerateGraphqlClientCodeToFileProps + ): Promise => { + const { out, ...rest } = props; + + const generatedCode = await generateAPICode({ + ...rest, + // credentialProvider: this.awsCredentialProvider, + }); + + Object.entries(generatedCode).forEach(([filePathSuffix, fileContents]) => { + const filePath = path.join(out, filePathSuffix); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, fileContents); + }); + }; +} diff --git a/packages/cli/src/commands/generate/graphql-client-code/mock_code_generator.ts b/packages/cli/src/commands/generate/graphql-client-code/mock_code_generator.ts new file mode 100644 index 00000000000..15dbddb694f --- /dev/null +++ b/packages/cli/src/commands/generate/graphql-client-code/mock_code_generator.ts @@ -0,0 +1,74 @@ +import { BackendIdentifier } from '@aws-amplify/client-config'; +import path from 'path'; + +export type GeneratedOutput = { [filename: string]: string }; + +export type GenerateModelsOptions = { + format: 'modelgen'; + modelTarget: 'java' | 'swift' | 'javascript' | 'typescript' | 'dart'; + generateIndexRules?: boolean; + emitAuthProvider?: boolean; + useExperimentalPipelinedTranformer?: boolean; + transformerVersion?: boolean; + respectPrimaryKeyAttributesOnConnectionField?: boolean; + generateModelsForLazyLoadAndCustomSelectionSet?: boolean; + addTimestampFields?: boolean; + handleListNullabilityTransparently?: boolean; +}; + +export type GenerateGraphqlCodegenOptions = { + format: 'graphql-codegen'; + statementTarget: 'javascript' | 'graphql' | 'flow' | 'typescript' | 'angular'; + maxDepth?: number; + typenameIntrospection?: boolean; + typeTarget?: + | 'json' + | 'swift' + | 'typescript' + | 'flow' + | 'scala' + | 'flow-modern' + | 'angular'; + multipleSwiftFiles?: boolean; +}; + +export type GenerateIntrospectionOptions = { + format: 'introspection'; +}; + +export type GenerateOptions = + | GenerateGraphqlCodegenOptions + | GenerateModelsOptions + | GenerateIntrospectionOptions; + +export type GenerateAPICodeProps = BackendIdentifier & GenerateOptions; + +/** + * Mock generateApiCode command. + */ +export const generateAPICode = async ( + props: GenerateAPICodeProps +): Promise => { + switch (props.format) { + case 'graphql-codegen': + return { + [path.join('src', 'graphql', 'mutations.js')]: 'type Mutations {}', + [path.join('src', 'graphql', 'queries.js')]: 'type Queries {}', + [path.join('src', 'graphql', 'subscriptions.js')]: + 'type Subscriptions {}', + }; + case 'modelgen': + return { + [path.join('src', 'models', 'index.js')]: 'export me', + [path.join('src', 'models', 'models.js')]: 'im a models', + }; + case 'introspection': + return { + 'model-introspection-schema.json': JSON.stringify( + { version: 1, models: [], nonModels: [] }, + null, + 4 + ), + }; + } +};