diff --git a/.codebuild/e2e_workflow.yml b/.codebuild/e2e_workflow.yml index 742dc0c15..e75a50bb1 100644 --- a/.codebuild/e2e_workflow.yml +++ b/.codebuild/e2e_workflow.yml @@ -120,15 +120,27 @@ batch: CLI_REGION: ap-northeast-1 depend-on: - publish_to_local_registry - - identifier: build_app_ts + - identifier: >- + build_app_ts_uninitialized_project_codegen_js_uninitialized_project_modelgen_android_uninitialized_project_modelgen_flutter buildspec: .codebuild/run_e2e_tests.yml env: compute-type: BUILD_GENERAL1_LARGE variables: - TEST_SUITE: src/__tests__/build-app-ts.test.ts + TEST_SUITE: >- + src/__tests__/build-app-ts.test.ts|src/__tests__/uninitialized-project-codegen-js.test.ts|src/__tests__/uninitialized-project-modelgen-android.test.ts|src/__tests__/uninitialized-project-modelgen-flutter.test.ts CLI_REGION: ap-southeast-1 depend-on: - publish_to_local_registry + - identifier: uninitialized_project_modelgen_ios_uninitialized_project_modelgen_js + buildspec: .codebuild/run_e2e_tests.yml + env: + compute-type: BUILD_GENERAL1_LARGE + variables: + TEST_SUITE: >- + src/__tests__/uninitialized-project-modelgen-ios.test.ts|src/__tests__/uninitialized-project-modelgen-js.test.ts + CLI_REGION: ap-southeast-2 + depend-on: + - publish_to_local_registry - identifier: cleanup_e2e_resources buildspec: .codebuild/cleanup_e2e_resources.yml env: diff --git a/packages/amplify-codegen-e2e-core/src/categories/codegen.ts b/packages/amplify-codegen-e2e-core/src/categories/codegen.ts index d8010d10c..ab5a83a1f 100644 --- a/packages/amplify-codegen-e2e-core/src/categories/codegen.ts +++ b/packages/amplify-codegen-e2e-core/src/categories/codegen.ts @@ -20,9 +20,50 @@ export function generateModels(cwd: string, outputDir?: string, settings: { errM }); } -export function generateStatementsAndTypes(cwd: string) : Promise { +export const generateModelsWithOptions = (cwd: string, options: Record): Promise => new Promise((resolve, reject) => { + spawn(getCLIPath(), ['codegen', 'models', ...(Object.entries(options).flat())], { cwd, stripColors: true }).run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); +}); + +export function generateStatementsAndTypes(cwd: string, errorMessage?: string) : Promise { + return new Promise((resolve, reject) => { + const chain = spawn(getCLIPath(), ['codegen'], { cwd, stripColors: true }) + + if (errorMessage) { + chain.wait(errorMessage); + } + + return chain.run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }) + }); +} + +export function generateStatements(cwd: string) : Promise { return new Promise((resolve, reject) => { - spawn(getCLIPath(), ['codegen'], { cwd, stripColors: true }) + spawn(getCLIPath(), ['codegen', 'statements'], { cwd, stripColors: true }) + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }) + }); +} + +export function generateTypes(cwd: string) : Promise { + return new Promise((resolve, reject) => { + spawn(getCLIPath(), ['codegen', 'types'], { cwd, stripColors: true }) .run((err: Error) => { if (!err) { resolve(); @@ -36,7 +77,10 @@ export function generateStatementsAndTypes(cwd: string) : Promise { // CLI workflow to add codegen to Amplify project export function addCodegen(cwd: string, settings: any = {}): Promise { return new Promise((resolve, reject) => { - const chain = spawn(getCLIPath(), ['codegen', 'add'], { cwd, stripColors: true }); + const params = settings.params + ? ['codegen', 'add', ...settings.params] + : ['codegen', 'add']; + const chain = spawn(getCLIPath(), params, { cwd, stripColors: true }); if (settings.isAPINotAdded) { chain.wait("There are no GraphQL APIs available."); chain.wait("Add by running $amplify api add"); @@ -156,22 +200,26 @@ export function generateModelIntrospection(cwd: string, settings: { outputDir?: } // CLI workflow to add codegen to non-Amplify JS project -export function addCodegenNonAmplifyJS(cwd: string): Promise { +export function addCodegenNonAmplifyJS(cwd: string, params: Array, initialFailureMessage?: string): Promise { return new Promise((resolve, reject) => { - const cmdOptions = ['codegen', 'add', '--apiId', 'mockapiid']; - const chain = spawn(getCLIPath(), cmdOptions, { cwd, stripColors: true }); - chain - .wait("Choose the type of app that you're building") - .sendCarriageReturn() - .wait('What javascript framework are you using') - .sendCarriageReturn() - .wait('Choose the code generation language target').sendCarriageReturn() - .wait('Enter the file name pattern of graphql queries, mutations and subscriptions') - .sendCarriageReturn() - .wait('Do you want to generate/update all possible GraphQL operations') - .sendLine('y') - .wait('Enter maximum statement depth [increase from default if your schema is deeply') - .sendCarriageReturn(); + const chain = spawn(getCLIPath(), ['codegen', 'add', ...params], { cwd, stripColors: true }); + + if (initialFailureMessage) { + chain.wait(initialFailureMessage) + } else { + chain + .wait("Choose the type of app that you're building") + .sendCarriageReturn() + .wait('What javascript framework are you using') + .sendCarriageReturn() + .wait('Choose the code generation language target').sendCarriageReturn() + .wait('Enter the file name pattern of graphql queries, mutations and subscriptions') + .sendCarriageReturn() + .wait('Do you want to generate/update all possible GraphQL operations') + .sendLine('y') + .wait('Enter maximum statement depth [increase from default if your schema is deeply') + .sendCarriageReturn(); + } chain.run((err: Error) => { if (!err) { @@ -182,3 +230,31 @@ export function addCodegenNonAmplifyJS(cwd: string): Promise { }); }); } + +export function addCodegenNonAmplifyTS(cwd: string, params: Array, initialFailureMessage?: string): Promise { + return new Promise((resolve, reject) => { + const chain = spawn(getCLIPath(), ['codegen', 'add', ...params], { cwd, stripColors: true }); + + if (initialFailureMessage) { + chain.wait(initialFailureMessage) + } else { + chain + .wait("Choose the type of app that you're building").sendCarriageReturn() + .wait('What javascript framework are you using').sendCarriageReturn() + .wait('Choose the code generation language target').sendKeyDown().sendCarriageReturn() + .wait('Enter the file name pattern of graphql queries, mutations and subscriptions').sendCarriageReturn() + .wait('Do you want to generate/update all possible GraphQL operations').sendLine('y') + .wait('Enter maximum statement depth [increase from default if your schema is deeply').sendCarriageReturn() + .wait('Enter the file name for the generated code').sendCarriageReturn() + .wait('Do you want to generate code for your newly created GraphQL API').sendCarriageReturn(); + } + + chain.run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); + }); +} \ No newline at end of file diff --git a/packages/amplify-codegen-e2e-core/src/utils/frontend-config-helper.ts b/packages/amplify-codegen-e2e-core/src/utils/frontend-config-helper.ts index 5e8afa579..8d8452ca6 100644 --- a/packages/amplify-codegen-e2e-core/src/utils/frontend-config-helper.ts +++ b/packages/amplify-codegen-e2e-core/src/utils/frontend-config-helper.ts @@ -1,5 +1,6 @@ export enum AmplifyFrontend { javascript = 'javascript', + typescript = 'typescript', ios = 'ios', android = 'android', flutter = 'flutter' diff --git a/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.graphql b/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.graphql new file mode 100644 index 000000000..19f1eb1b3 --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.graphql @@ -0,0 +1,11 @@ +type Query { + echo: String +} + +type Mutation { + mymutation: String +} + +type Subscription { + mysub: String +} diff --git a/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.json b/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.json new file mode 100644 index 000000000..b133d772b --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/schemas/sdl/schema.json @@ -0,0 +1,1163 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": { + "name": "Mutation" + }, + "subscriptionType": { + "name": "Subscription" + }, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "fields": [ + { + "name": "echo", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Built-in String", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ + { + "name": "mymutation", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Subscription", + "description": null, + "fields": [ + { + "name": "mysub", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Introspection defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "directives", + "description": "'A list of all directives supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "'If this server support subscription, the type that subscription operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": null, + "fields": [ + { + "name": "kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given __Type is", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Built-in Boolean", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "args", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onOperation", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onField", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "An enum describing valid locations where a directive can be placed", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Indicates the directive is valid on queries.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Indicates the directive is valid on mutations.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Indicates the directive is valid on fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Indicates the directive is valid on fragment definitions.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Indicates the directive is valid on fragment spreads.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Indicates the directive is valid on inline fragments.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Indicates the directive is valid on a schema SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Indicates the directive is valid on a scalar SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates the directive is valid on an object SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Indicates the directive is valid on a field SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Indicates the directive is valid on a field argument SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates the directive is valid on an interface SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates the directive is valid on an union SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates the directive is valid on an enum SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Indicates the directive is valid on an enum value SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates the directive is valid on an input object SDL definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Indicates the directive is valid on an input object field SDL definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": true, + "onField": true + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if`'argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": true, + "onField": true + }, + { + "name": "defer", + "description": "This directive allows results to be deferred during execution", + "locations": [ + "FIELD" + ], + "args": [], + "onOperation": false, + "onFragment": false, + "onField": true + }, + { + "name": "aws_api_key", + "description": "Tells the service this field/object has access authorized by an API key.", + "locations": [ + "OBJECT", + "FIELD_DEFINITION" + ], + "args": [], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_iam", + "description": "Tells the service this field/object has access authorized by sigv4 signing.", + "locations": [ + "OBJECT", + "FIELD_DEFINITION" + ], + "args": [], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_oidc", + "description": "Tells the service this field/object has access authorized by an OIDC token.", + "locations": [ + "OBJECT", + "FIELD_DEFINITION" + ], + "args": [], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_lambda", + "description": "Tells the service this field/object has access authorized by a Lambda Authorizer.", + "locations": [ + "OBJECT", + "FIELD_DEFINITION" + ], + "args": [], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_subscribe", + "description": "Tells the service which mutation triggers this subscription.", + "locations": [ + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "mutations", + "description": "List of mutations which will trigger this subscription when they are called.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_cognito_user_pools", + "description": "Tells the service this field/object has access authorized by a Cognito User Pools token.", + "locations": [ + "OBJECT", + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "cognito_groups", + "description": "List of cognito user pool groups which have access on this field", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "deprecated", + "description": null, + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "args": [ + { + "name": "reason", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"" + } + ], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_auth", + "description": "Directs the schema to enforce authorization on a field", + "locations": [ + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "cognito_groups", + "description": "List of cognito user pool groups which have access on this field", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": false, + "onField": false + }, + { + "name": "aws_publish", + "description": "Tells the service which subscriptions will be published to when this mutation is called. This directive is deprecated use @aws_susbscribe directive instead.", + "locations": [ + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "subscriptions", + "description": "List of subscriptions which will be published to when this mutation is called.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "onOperation": false, + "onFragment": false, + "onField": false + } + ] + } + } +} \ No newline at end of file diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/add-codegen-js.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/add-codegen-js.test.ts index a78fbc75a..2a23c972d 100644 --- a/packages/amplify-codegen-e2e-tests/src/__tests__/add-codegen-js.test.ts +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/add-codegen-js.test.ts @@ -6,13 +6,17 @@ import { createRandomName, addApiWithoutSchema, updateApiSchema, - addCodegenNonAmplifyJS } from "@aws-amplify/amplify-codegen-e2e-core"; -import { existsSync, writeFileSync } from "fs"; +import { existsSync } from "fs"; import path from 'path'; import { isNotEmptyDir } from '../utils'; -import { deleteAmplifyProject, testAddCodegen, testSetupBeforeAddCodegen, -getGraphQLConfigFilePath, testValidGraphQLConfig } from '../codegen-tests-base'; +import { + deleteAmplifyProject, + testAddCodegen, + testSetupBeforeAddCodegen, + getGraphQLConfigFilePath, + testValidGraphQLConfig, +} from '../codegen-tests-base'; const schema = 'simple_model.graphql'; @@ -74,35 +78,7 @@ describe('codegen add tests - JS', () => { await testAddCodegen(config, projectRoot, schema); }); - it(`Adding codegen outside of Amplify project`, async () => { - // init project and add API category - const testSchema = ` - type Query { - echo: String - } - - type Mutation { - mymutation: String - } - - type Subscription { - mysub: String - } - `; - - // Setup the non-amplify project with schema and pre-existing files - const userSourceCodePath = testSetupBeforeAddCodegen(projectRoot, config); - const schemaPath = path.join(projectRoot, 'schema.graphql'); - writeFileSync(schemaPath, testSchema); - - // add codegen without init - await expect(addCodegenNonAmplifyJS(projectRoot)).resolves.not.toThrow(); - - // pre-existing file should still exist - expect(existsSync(userSourceCodePath)).toBe(true); - // GraphQL statements are generated - expect(isNotEmptyDir(path.join(projectRoot, config.graphqlCodegenDir))).toBe(true); - // graphql configuration should be added - expect(existsSync(getGraphQLConfigFilePath(projectRoot))).toBe(true); + it(`supports add codegen with redundant region parameter`, async () => { + await testAddCodegen(config, projectRoot, schema, ['--region', 'us-fake-1']); }); -}); \ No newline at end of file +}); diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-codegen-js.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-codegen-js.test.ts new file mode 100644 index 000000000..076e7b952 --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-codegen-js.test.ts @@ -0,0 +1,114 @@ +import { createNewProjectDir, DEFAULT_JS_CONFIG, AmplifyFrontend, generateStatementsAndTypes } from '@aws-amplify/amplify-codegen-e2e-core'; +import path from 'path'; +import { deleteAmplifyProject, testAddCodegenUninitialized } from '../codegen-tests-base'; +import { rmSync } from "fs-extra"; + +describe('codegen add tests - JS', () => { + let projectRoot: string; + const javascriptConfig = DEFAULT_JS_CONFIG; + const typescriptConfig = { + ...DEFAULT_JS_CONFIG, + frontendType: AmplifyFrontend.typescript, + }; + + beforeEach(async () => { + projectRoot = await createNewProjectDir('uninitializedProjectCodegenJS'); + }); + + afterEach(async () => { + await deleteAmplifyProject(projectRoot); + }); + + it(`graphql sdl file`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: javascriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.js', 'queries.js', 'subscriptions.js'], + }); + }); + + it(`region is ignored if schema file is provided`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: javascriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.js', 'queries.js', 'subscriptions.js'], + additionalParams: ['--region', 'us-fake-1'], + }); + }); + + it(`json sdl file`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: javascriptConfig, + sdlFilename: 'schema.json', + expectedFilenames: ['mutations.js', 'queries.js', 'subscriptions.js'], + }); + }); + + it(`typescript`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: typescriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.ts', 'queries.ts', 'subscriptions.ts'], + }); + }); + + it(`drop and regenerate`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: typescriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.ts', 'queries.ts', 'subscriptions.ts'], + dropAndRunCodegen: true, + }); + }); + + it(`drop and regenerate statements`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: typescriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.ts', 'queries.ts', 'subscriptions.ts'], + dropAndRunCodegenStatements: true, + }); + }); + + it(`drop and regenerate types`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: typescriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.ts', 'queries.ts', 'subscriptions.ts'], + dropAndRunCodegenStatements: true, + dropAndRunCodegenTypes: true, + }); + }); + + it(`throws a sane warning on missing graphqlconfig file`, async () => { + // Add codegen + await testAddCodegenUninitialized({ + projectRoot, + config: javascriptConfig, + sdlFilename: 'schema.graphql', + expectedFilenames: ['mutations.js', 'queries.js', 'subscriptions.js'], + }); + + // Remove .graphqlconfig.yml file + rmSync(path.join(projectRoot, '.graphqlconfig.yml')); + + // Run and expect failure message + await generateStatementsAndTypes(projectRoot, 'code generation is not configured'); + }); + + it(`throws a sane warning on missing sdl schema and no api id specified`, async () => { + await testAddCodegenUninitialized({ + projectRoot, + config: javascriptConfig, + expectedFilenames: ['mutations.js', 'queries.js', 'subscriptions.js'], + initialFailureMessage: 'Provide an AppSync API ID with --apiId or manually download schema.graphql or schema.json' + }); + }); +}); diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-android.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-android.test.ts new file mode 100644 index 000000000..ac8bb8015 --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-android.test.ts @@ -0,0 +1,44 @@ +import { createNewProjectDir, DEFAULT_ANDROID_CONFIG, deleteProjectDir } from '@aws-amplify/amplify-codegen-e2e-core'; +import { testUninitializedCodegenModels } from '../codegen-tests-base'; +import * as path from 'path'; + +const schemaName = 'modelgen/model_gen_schema_with_aws_scalars.graphql'; + +describe('Uninitialized Project Modelgen tests - Android', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await createNewProjectDir('uninitializedProjectModelgenAndroid'); + }); + + afterEach(() => deleteProjectDir(projectRoot)); + + it(`should generate files at desired location and not delete src files`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_ANDROID_CONFIG, + projectRoot, + schemaName, + outputDir: path.join('app', 'src', 'main', 'guava'), + shouldSucceed: true, + expectedFilenames: [ + 'AmplifyModelProvider.java', + 'Attration.java', + 'Comment.java', + 'License.java', + 'Person.java', + 'Post.java', + 'Status.java', + 'User.java', + ], + }); + }); + + it(`should not generate files at desired location and not delete src files if no output dir is specified`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_ANDROID_CONFIG, + projectRoot, + schemaName, + shouldSucceed: false, + }); + }); +}); diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-flutter.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-flutter.test.ts new file mode 100644 index 000000000..5c8e1beea --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-flutter.test.ts @@ -0,0 +1,44 @@ +import { createNewProjectDir, DEFAULT_FLUTTER_CONFIG, deleteProjectDir } from '@aws-amplify/amplify-codegen-e2e-core'; +import { testUninitializedCodegenModels } from '../codegen-tests-base'; +import * as path from 'path'; + +const schemaName = 'modelgen/model_gen_schema_with_aws_scalars.graphql'; + +describe('Uninitialized Project Modelgen tests - Flutter', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await createNewProjectDir('uninitializedProjectModelgenFlutter'); + }); + + afterEach(() => deleteProjectDir(projectRoot)); + + it(`should generate files at desired location and not delete src files`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_FLUTTER_CONFIG, + projectRoot, + schemaName, + outputDir: path.join('lib', 'blueprints'), + shouldSucceed: true, + expectedFilenames: [ + 'Attration.dart', + 'Comment.dart', + 'License.dart', + 'ModelProvider.dart', + 'Person.dart', + 'Post.dart', + 'Status.dart', + 'User.dart', + ], + }); + }); + + it(`should not generate files at desired location and not delete src files if no output dir is specified`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_FLUTTER_CONFIG, + projectRoot, + schemaName, + shouldSucceed: false, + }); + }); +}); diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-ios.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-ios.test.ts new file mode 100644 index 000000000..0e3e259fa --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-ios.test.ts @@ -0,0 +1,50 @@ +import { createNewProjectDir, DEFAULT_IOS_CONFIG, deleteProjectDir } from '@aws-amplify/amplify-codegen-e2e-core'; +import { testUninitializedCodegenModels } from '../codegen-tests-base'; +import * as path from 'path'; + +const schemaName = 'modelgen/model_gen_schema_with_aws_scalars.graphql'; + +describe('Uninitialized Project Modelgen tests - IOS', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await createNewProjectDir('uninitializedProjectModelgenIOS'); + }); + + afterEach(() => deleteProjectDir(projectRoot)); + + it(`should generate files at desired location and not delete src files`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_IOS_CONFIG, + projectRoot, + schemaName, + outputDir: path.join('amplification', 'manufactured', 'models'), + shouldSucceed: true, + expectedFilenames: [ + 'AmplifyModels.swift', + 'Attration+Schema.swift', + 'Attration.swift', + 'Comment+Schema.swift', + 'Comment.swift', + 'License+Schema.swift', + 'License.swift', + 'Person+Schema.swift', + 'Person.swift', + 'Post+Schema.swift', + 'Post.swift', + 'Status.swift', + 'User+Schema.swift', + 'User.swift', + ], + }); + }); + + it(`should not generate files at desired location and not delete src files if no output dir is specified`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_IOS_CONFIG, + projectRoot, + schemaName, + shouldSucceed: false, + }); + }); +}); diff --git a/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-js.test.ts b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-js.test.ts new file mode 100644 index 000000000..57d929d01 --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/__tests__/uninitialized-project-modelgen-js.test.ts @@ -0,0 +1,57 @@ +import { AmplifyFrontend, createNewProjectDir, DEFAULT_JS_CONFIG, deleteProjectDir } from '@aws-amplify/amplify-codegen-e2e-core'; +import { testUninitializedCodegenModels } from '../codegen-tests-base'; +import * as path from 'path'; + +const schemaName = 'modelgen/model_gen_schema_with_aws_scalars.graphql'; + +describe('Uninitialized Project Modelgen tests - JS', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await createNewProjectDir('uninitializedProjectModelgenJS'); + }); + + afterEach(() => deleteProjectDir(projectRoot)); + + it(`should generate files at desired location and not delete src files`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_JS_CONFIG, + projectRoot, + schemaName, + outputDir: path.join('src', 'backmodels'), + shouldSucceed: true, + expectedFilenames: [ + 'index.d.ts', + 'index.js', + 'schema.d.ts', + 'schema.js', + ], + }); + }); + + it(`should generate files at desired location and not delete src files for typescript variant`, async () => { + await testUninitializedCodegenModels({ + config: { + ...DEFAULT_JS_CONFIG, + frontendType: AmplifyFrontend.typescript, + }, + projectRoot, + schemaName, + outputDir: path.join('src', 'backmodels'), + shouldSucceed: true, + expectedFilenames: [ + 'index.ts', + 'schema.ts', + ], + }); + }); + + it(`should not generate files at desired location and not delete src files if no output dir is specified`, async () => { + await testUninitializedCodegenModels({ + config: DEFAULT_JS_CONFIG, + projectRoot, + schemaName, + shouldSucceed: false, + }); + }); +}); diff --git a/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/add-codegen.ts b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/add-codegen.ts index 9df7e9d96..4ae362489 100644 --- a/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/add-codegen.ts +++ b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/add-codegen.ts @@ -4,14 +4,20 @@ import { updateApiSchema, addCodegen, AmplifyFrontendConfig, - createRandomName + createRandomName, + addCodegenNonAmplifyJS, + addCodegenNonAmplifyTS, + AmplifyFrontend, + generateStatementsAndTypes, + generateStatements, + generateTypes, } from "@aws-amplify/amplify-codegen-e2e-core"; -import { existsSync } from "fs"; +import { existsSync, readFileSync, writeFileSync, readdirSync, rmSync } from "fs"; import path from 'path'; import { isNotEmptyDir } from '../utils'; -import { testSetupBeforeAddCodegen, testValidGraphQLConfig } from "./test-setup"; +import { getGraphQLConfigFilePath, testSetupBeforeAddCodegen, testValidGraphQLConfig } from "./test-setup"; -export async function testAddCodegen(config: AmplifyFrontendConfig, projectRoot: string, schema: string) { +export async function testAddCodegen(config: AmplifyFrontendConfig, projectRoot: string, schema: string, additionalParams?: Array) { // init project and add API category await initProjectWithProfile(projectRoot, { ...config }); const projectName = createRandomName(); @@ -21,7 +27,7 @@ export async function testAddCodegen(config: AmplifyFrontendConfig, projectRoot: const userSourceCodePath = testSetupBeforeAddCodegen(projectRoot, config); // add codegen succeeds - await expect(addCodegen(projectRoot, { ...config })).resolves.not.toThrow(); + await expect(addCodegen(projectRoot, { ...config, params: additionalParams ?? [] })).resolves.not.toThrow(); // pre-existing file should still exist expect(existsSync(userSourceCodePath)).toBe(true); @@ -29,3 +35,112 @@ export async function testAddCodegen(config: AmplifyFrontendConfig, projectRoot: expect(isNotEmptyDir(path.join(projectRoot, config.graphqlCodegenDir))).toBe(true); testValidGraphQLConfig(projectRoot, config); } + +export type TestAddCodegenUninitializedProps = { + config: AmplifyFrontendConfig; + projectRoot: string; + sdlFilename?: string; + expectedFilenames: Array; + dropAndRunCodegen?: boolean; + dropAndRunCodegenStatements?: boolean; + dropAndRunCodegenTypes?: boolean; + initialFailureMessage?: string; + additionalParams?: Array; +}; + +const assertTypeFileExists = (projectRoot: string): void => { + expect(existsSync(path.join(projectRoot, 'src', 'API.ts'))).toBe(true) +}; + +/** + * Ensure that all values provided in the expected set are present in the received set, allowing for additional values in received. + * @param expectedValues the expected values to check + * @param receivedValues the received values to check + */ +const ensureAllExpectedValuesAreReceived = (expectedValues: Array, receivedValues: Array): void => { + const receivedValueSet = new Set(receivedValues); + console.log(`Comparing received values: ${JSON.stringify(receivedValues)} to expected values: ${JSON.stringify(expectedValues)}`); + expectedValues.forEach((expectedFilename) => expect(receivedValueSet.has(expectedFilename)).toBe(true)); +}; + +export async function testAddCodegenUninitialized({ + config, + projectRoot, + sdlFilename, + expectedFilenames, + dropAndRunCodegen, + dropAndRunCodegenStatements, + dropAndRunCodegenTypes, + initialFailureMessage, + additionalParams, +}: TestAddCodegenUninitializedProps) { + // Setup the non-amplify project with schema and pre-existing files + const userSourceCodePath = testSetupBeforeAddCodegen(projectRoot, config); + + // Write SDL Schema + if (sdlFilename) { + const sdlSchema = readFileSync(path.join(__dirname, '..', '..', 'schemas', 'sdl', sdlFilename), 'utf-8'); + writeFileSync(path.join(projectRoot, sdlFilename), sdlSchema); + } + + // add codegen without init + switch (config.frontendType) { + case AmplifyFrontend.javascript: + await addCodegenNonAmplifyJS(projectRoot, additionalParams ?? [], initialFailureMessage); + break; + case AmplifyFrontend.typescript: + await addCodegenNonAmplifyTS(projectRoot, additionalParams ?? [], initialFailureMessage); + break; + default: + throw new Error(`Received unexpected frontendType ${config.frontendType}`); + } + + // return if we expected the add command to fail + if (initialFailureMessage) { + return; + } + + // pre-existing file should still exist + expect(existsSync(userSourceCodePath)).toBe(true); + // GraphQL statements are generated + ensureAllExpectedValuesAreReceived(expectedFilenames, readdirSync(path.join(projectRoot, config.graphqlCodegenDir))) + // graphql configuration should be added + expect(existsSync(getGraphQLConfigFilePath(projectRoot))).toBe(true); + if (config.frontendType === AmplifyFrontend.typescript) { + assertTypeFileExists(projectRoot) + } + + if (dropAndRunCodegen || dropAndRunCodegenStatements || dropAndRunCodegenTypes) { + rmSync(path.join(projectRoot, config.graphqlCodegenDir), { recursive: true }); + // pre-existing file should still exist + expect(existsSync(userSourceCodePath)).toBe(true); + // Graphql statements are deleted + expect(existsSync(path.join(projectRoot, config.graphqlCodegenDir))).toBe(false); + } + + if (dropAndRunCodegen) { + await generateStatementsAndTypes(projectRoot); + + // GraphQL statements are regenerated + ensureAllExpectedValuesAreReceived(expectedFilenames, readdirSync(path.join(projectRoot, config.graphqlCodegenDir))) + + if (config.frontendType === AmplifyFrontend.typescript) { + assertTypeFileExists(projectRoot) + } + } + + if (dropAndRunCodegenStatements) { + await generateStatements(projectRoot); + + // GraphQL statements are regenerated + ensureAllExpectedValuesAreReceived(expectedFilenames, readdirSync(path.join(projectRoot, config.graphqlCodegenDir))) + } + + if (dropAndRunCodegenTypes) { + await generateTypes(projectRoot); + + if (config.frontendType === AmplifyFrontend.typescript) { + assertTypeFileExists(projectRoot) + } + } +} diff --git a/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/index.ts b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/index.ts index d2018778f..3f9e550ba 100644 --- a/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/index.ts +++ b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/index.ts @@ -5,3 +5,4 @@ export * from './configure-codegen'; export * from './remove-codegen'; export * from './datastore-modelgen'; export * from './push-codegen'; +export * from './uninitialized-modelgen'; diff --git a/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/uninitialized-modelgen.ts b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/uninitialized-modelgen.ts new file mode 100644 index 000000000..609ec17e9 --- /dev/null +++ b/packages/amplify-codegen-e2e-tests/src/codegen-tests-base/uninitialized-modelgen.ts @@ -0,0 +1,70 @@ +import { generateModelsWithOptions, AmplifyFrontendConfig, AmplifyFrontend, getSchemaPath } from '@aws-amplify/amplify-codegen-e2e-core'; +import { existsSync, writeFileSync, readFileSync, readdirSync } from 'fs'; +import { isNotEmptyDir, generateSourceCode } from '../utils'; +import { createPubspecLockFile } from './datastore-modelgen'; +import path from 'path'; + +export type TestUninitializedCodegenModelsProps = { + config: AmplifyFrontendConfig; + projectRoot: string; + schemaName: string; + shouldSucceed: boolean; + outputDir?: string; + featureFlags?: Record; + expectedFilenames?: Array; +}; + +export const testUninitializedCodegenModels = async ({ + config, + projectRoot, + schemaName, + outputDir, + shouldSucceed, + featureFlags, + expectedFilenames, +}: TestUninitializedCodegenModelsProps): Promise => { + // generate pre existing user file + const userSourceCodePath = generateSourceCode(projectRoot, config.srcDir); + + // Write Schema File to Schema Path + const schemaPath = getSchemaPath(schemaName); + const schema = readFileSync(schemaPath).toString(); + const modelSchemaPath = path.join(config.srcDir, 'schema.graphql'); + writeFileSync(path.join(projectRoot, modelSchemaPath), schema); + + // For flutter frontend, we need to have a pubspec lock file with supported dart version + if (config?.frontendType === AmplifyFrontend.flutter) { + createPubspecLockFile(projectRoot); + } + + // generate models + try { + await generateModelsWithOptions(projectRoot, { + '--target': config.frontendType, + '--model-schema': modelSchemaPath, + '--output-dir': outputDir, + ...(featureFlags ? Object.entries(featureFlags).map(([ffName, ffVal]) => [`--feature-flag:${ffName}`, ffVal]).flat() : []), + }); + } catch (_) { + // This is temporarily expected to throw, since the post-modelgen hook in amplify cli fails, even though modelgen succeeds. + } + + // pre-existing file should still exist + expect(existsSync(userSourceCodePath)).toBe(true); + + // datastore models are generated at correct location + const partialDirToCheck = outputDir + ? path.join(projectRoot, outputDir) + : path.join(projectRoot, config.modelgenDir); + const dirToCheck = config.frontendType === AmplifyFrontend.android + ? path.join(partialDirToCheck, 'com', 'amplifyframework', 'datastore', 'generated', 'model') + : partialDirToCheck; + + expect(isNotEmptyDir(dirToCheck)).toBe(shouldSucceed); + + if (expectedFilenames) { + const foundFiles = new Set(readdirSync(dirToCheck)); + console.log(`Comparing written files: ${JSON.stringify(Array.from(foundFiles))} to expected files: ${JSON.stringify(expectedFilenames)}`); + expectedFilenames.forEach((expectedFilename) => expect(foundFiles.has(expectedFilename)).toBe(true)); + } +}; diff --git a/packages/amplify-codegen/commands/codegen/add.js b/packages/amplify-codegen/commands/codegen/add.js index 89ac18581..0a64e45fd 100644 --- a/packages/amplify-codegen/commands/codegen/add.js +++ b/packages/amplify-codegen/commands/codegen/add.js @@ -9,14 +9,16 @@ module.exports = { try { const { options = {} } = context.parameters; const keys = Object.keys(options); - if (keys.length && !keys.includes('apiId')) { - const paramMsg = keys.length > 1 ? 'Invalid parameters ' : 'Invalid parameter '; + // frontend and framework are undocumented, but are read when apiId is also supplied + const { apiId = null, region, yes, frontend, framework, debug, ...rest } = options; + const extraOptions = Object.keys(rest); + if (extraOptions.length) { + const paramMsg = extraOptions.length > 1 ? 'Invalid parameters' : 'Invalid parameter'; context.print.info(`${paramMsg} ${keys.join(', ')}`); context.print.info(constants.INFO_MESSAGE_ADD_ERROR); return; } - const apiId = context.parameters.options.apiId || null; - await codeGen.add(context, apiId); + await codeGen.add(context, apiId, region); } catch (ex) { context.print.error(ex.message); } diff --git a/packages/amplify-codegen/package.json b/packages/amplify-codegen/package.json index ee71e86bb..296e27e59 100644 --- a/packages/amplify-codegen/package.json +++ b/packages/amplify-codegen/package.json @@ -46,7 +46,7 @@ "coverageThreshold": { "global": { "branches": 54, - "functions": 65, + "functions": 64, "lines": 72 } }, diff --git a/packages/amplify-codegen/src/commands/add.js b/packages/amplify-codegen/src/commands/add.js index 713e3194b..6b3a609a9 100644 --- a/packages/amplify-codegen/src/commands/add.js +++ b/packages/amplify-codegen/src/commands/add.js @@ -1,4 +1,5 @@ const Ora = require('ora'); +const process = require('process'); const { loadConfig } = require('../codegen-config'); const constants = require('../constants'); const generateStatements = require('./statements'); @@ -23,11 +24,11 @@ const askForFramework = require('../walkthrough/questions/selectFramework'); const frontends = ['android', 'ios', 'javascript']; const frameworks = ['angular', 'ember', 'ionic', 'react', 'react-native', 'vue', 'none']; -async function add(context, apiId = null) { +async function add(context, apiId = null, region = 'us-east-1') { let withoutInit = false; // Determine if working in an amplify project try { - context.amplify.getProjectMeta(); + await context.amplify.getProjectMeta(); } catch (e) { withoutInit = true; const config = loadConfig(context, withoutInit); @@ -37,9 +38,9 @@ async function add(context, apiId = null) { } const schemaPath = ['schema.graphql', 'schema.json'].map(p => path.join(process.cwd(), p)).find(p => fs.existsSync(p)); - if (withoutInit && !schemaPath) { + if (withoutInit && !(apiId || schemaPath)) { throw Error( - `Please download schema.graphql or schema.json and place in ${process.cwd()} before adding codegen when not in an amplify project`, + `Provide an AppSync API ID with --apiId or manually download schema.graphql or schema.json and place in ${process.cwd()} before adding codegen when not in an amplify project`, ); } // Grab the frontend @@ -69,7 +70,6 @@ async function add(context, apiId = null) { } } - let region = 'us-east-1'; if (!withoutInit) { region = getProjectAwsRegion(context); } @@ -78,36 +78,37 @@ async function add(context, apiId = null) { throw new Error(constants.ERROR_CODEGEN_SUPPORT_MAX_ONE_API); } let apiDetails; - if (!withoutInit) { - if (!apiId) { - const availableAppSyncApis = getAppSyncAPIDetails(context); // published and un-published - if (availableAppSyncApis.length === 0) { - throw new NoAppSyncAPIAvailableError(constants.ERROR_CODEGEN_NO_API_AVAILABLE); - } - [apiDetails] = availableAppSyncApis; - apiDetails.isLocal = true; - } else { - let shouldRetry = true; - while (shouldRetry) { - const apiDetailSpinner = new Ora(); - try { - apiDetailSpinner.start('Getting API details'); - apiDetails = await getAppSyncAPIInfo(context, apiId, region); - apiDetailSpinner.succeed(); + if (!withoutInit && !apiId) { + const availableAppSyncApis = getAppSyncAPIDetails(context); // published and un-published + if (availableAppSyncApis.length === 0) { + throw new NoAppSyncAPIAvailableError(constants.ERROR_CODEGEN_NO_API_AVAILABLE); + } + [apiDetails] = availableAppSyncApis; + apiDetails.isLocal = true; + } else if (apiId) { + let shouldRetry = true; + while (shouldRetry) { + const apiDetailSpinner = new Ora(); + try { + apiDetailSpinner.start('Getting API details'); + apiDetails = await getAppSyncAPIInfo(context, apiId, region); + if (!withoutInit) { await updateAmplifyMeta(context, apiDetails); - break; - } catch (e) { - apiDetailSpinner.fail(); - if (e instanceof AmplifyCodeGenAPINotFoundError) { - context.print.info(`AppSync API was not found in region ${region}`); - ({ shouldRetry, region } = await changeAppSyncRegion(context, region)); - } else { - throw e; - } + } + apiDetailSpinner.succeed(); + break; + } catch (e) { + apiDetailSpinner.fail(); + if (e instanceof AmplifyCodeGenAPINotFoundError) { + context.print.info(`AppSync API was not found in region ${region}`); + ({ shouldRetry, region } = await changeAppSyncRegion(context, region)); + } else { + throw e; } } } } + // else no appsync API, but has schema.graphql or schema.json if (!withoutInit && !apiDetails) { return; @@ -123,6 +124,8 @@ async function add(context, apiId = null) { } else { schema = getSDLSchemaLocation(apiDetails.name); } + } else if (apiDetails) { + schema = await downloadIntrospectionSchemaWithProgress(context, apiDetails.id, path.join(process.cwd(), 'schema.json'), region); } else { schema = schemaPath; } diff --git a/packages/amplify-codegen/src/commands/generateStatementsAndType.js b/packages/amplify-codegen/src/commands/generateStatementsAndType.js index 92459dbdc..f04b9c240 100644 --- a/packages/amplify-codegen/src/commands/generateStatementsAndType.js +++ b/packages/amplify-codegen/src/commands/generateStatementsAndType.js @@ -4,7 +4,7 @@ const generateTypes = require('./types'); const generateStatements = require('./statements'); const { loadConfig } = require('../codegen-config'); const constants = require('../constants'); -const { ensureIntrospectionSchema, getAppSyncAPIDetails } = require('../utils'); +const { ensureIntrospectionSchema, getAppSyncAPIDetails, getAppSyncAPIInfoFromProject } = require('../utils'); const path = require('path'); const fs = require('fs-extra'); @@ -17,30 +17,31 @@ async function generateStatementsAndTypes(context, forceDownloadSchema, maxDepth withoutInit = true; } - // Check if introspection schema exists - const schemaPath = ['schema.graphql', 'schema.json'].map(p => path.join(process.cwd(), p)).find(p => fs.existsSync(p)); - if (withoutInit && !schemaPath) { - throw Error( - `Please download the schema.graphql or schema.json and place in ${process.cwd()} before adding codegen when not in an amplify project` - ); - } - - if (withoutInit) { - forceDownloadSchema = false; - } const config = loadConfig(context, withoutInit); const projects = config.getProjects(); if (!projects.length) { throw new NoAppSyncAPIAvailableError(constants.ERROR_CODEGEN_NO_API_CONFIGURED); } + const project = projects[0]; + const schemaPath = ['schema.graphql', 'schema.json'].map(p => path.join(process.cwd(), p)).find(p => fs.existsSync(p)); + if (withoutInit && !project && !schemaPath) { + throw Error( + `Please download the schema.graphql or schema.json and place in ${process.cwd()} before adding codegen when not in an amplify project`, + ); + } + let apis = []; if (!withoutInit) { apis = getAppSyncAPIDetails(context); + } else { + const api = await getAppSyncAPIInfoFromProject(context, project); + if (api) { + apis = [api]; + } } if (!apis.length && !withoutInit) { throw new NoAppSyncAPIAvailableError(constants.ERROR_CODEGEN_NO_API_META); } - const project = projects[0]; const { frontend } = project.amplifyExtension; let projectPath = process.cwd(); if (!withoutInit) { @@ -48,10 +49,10 @@ async function generateStatementsAndTypes(context, forceDownloadSchema, maxDepth } let downloadPromises; - if (!withoutInit) { + if (apis.length) { downloadPromises = projects.map( async cfg => - await ensureIntrospectionSchema(context, join(projectPath, cfg.schema), apis[0], cfg.amplifyExtension.region, forceDownloadSchema) + await ensureIntrospectionSchema(context, join(projectPath, cfg.schema), apis[0], cfg.amplifyExtension.region, forceDownloadSchema), ); await Promise.all(downloadPromises); } diff --git a/packages/amplify-codegen/src/commands/models.js b/packages/amplify-codegen/src/commands/models.js index 4767b4197..637653461 100644 --- a/packages/amplify-codegen/src/commands/models.js +++ b/packages/amplify-codegen/src/commands/models.js @@ -1,158 +1,301 @@ const path = require('path'); const fs = require('fs-extra'); -const { parse } = require('graphql'); const glob = require('glob-all'); const { FeatureFlags, pathManager } = require('@aws-amplify/amplify-cli-core'); const { generateModels: generateModelsHelper } = require('@aws-amplify/graphql-generator'); const { validateAmplifyFlutterMinSupportedVersion } = require('../utils/validateAmplifyFlutterMinSupportedVersion'); +const defaultDirectiveDefinitions = require('../utils/defaultDirectiveDefinitions'); +const getProjectRoot = require('../utils/getProjectRoot'); +const { getModelSchemaPathParam, hasModelSchemaPathParam } = require('../utils/getModelSchemaPathParam'); -const platformToLanguageMap = { +/** + * Amplify Context type. + * @typedef {import('@aws-amplify/amplify-cli-core').$TSContext} AmplifyContext + */ + +/** + * Modelgen Frontend type. + * @typedef {'android' | 'ios' | 'flutter' | 'javascript' | 'typescript' | 'introspection'} ModelgenFrontend + */ + +/** + * Modelgen Target type. + * @typedef {import('@aws-amplify/appsync-modelgen-plugin').Target} ModelgenTarget + */ + +/** + * Mapping from modelgen frontends (as configurable in Amplify init) to modelgen targets (languages) + * @type {Record} + */ +const modelgenFrontendToTargetMap = { android: 'java', ios: 'swift', flutter: 'dart', javascript: 'javascript', typescript: 'typescript', + introspection: 'introspection', }; +/** + * Return feature flag override values from the cli in the format --feature-flag: + * @param {!AmplifyContext} context the amplify runtime context + * @param {!string} flagName the feature flag name + * @returns {any | null} the raw value if found, else null + */ +const cliFeatureFlagOverride = (context, flagName) => context.parameters?.options?.[`feature-flag:${flagName}`]; + /** * Returns feature flag value, default to `false` - * @param {string} key feature flag id - * @returns + * @param {!AmplifyContext} context the amplify runtime context + * @param {!string} flagName feature flag id + * @returns {!boolean} the feature flag value */ -const readFeatureFlag = key => { - let flagValue = false; +const readFeatureFlag = (context, flagName) => { + const cliValue = cliFeatureFlagOverride(context, flagName); + if (cliValue) { + if (cliValue === 'true' || cliValue === 'True' || cliValue === true) { + return true; + } + if (cliValue === 'false' || cliValue === 'False' || cliValue === false) { + return false; + } + throw new Error(`Feature flag value for parameter ${flagName} could not be marshalled to boolean type, found ${cliValue}`); + } + try { - flagValue = FeatureFlags.getBoolean(key); - } catch (err) { - flagValue = false; + return FeatureFlags.getBoolean(flagName); + } catch (_) { + return false; } - return flagValue; }; /** * Returns feature flag value, default to `1` - * @param {string} key feature flag id - * @returns + * @param {!AmplifyContext} context the amplify runtime context + * @param {!string} flagName feature flag id + * @returns {!number} the feature flag value */ -const readNumericFeatureFlag = key => { +const readNumericFeatureFlag = (context, flagName) => { + const cliValue = cliFeatureFlagOverride(context, flagName); + if (cliValue) { + return Number.parseInt(cliValue, 10); + } + try { - return FeatureFlags.getNumber(key); - } catch (err) { + return FeatureFlags.getNumber(flagName); + } catch (_) { return 1; } }; -// type GenerateModelsOptions = { -// overrideOutputDir?: String; -// isIntrospection: Boolean; -// writeToDisk: Boolean; -// } +/** + * Return the path to the graphql schema. + * @param {!AmplifyContext} context the amplify runtime context + * @returns {!Promise} the api path, if one can be found, else null + */ +const getApiResourcePath = async (context) => { + const modelSchemaPathParam = getModelSchemaPathParam(context); + if (modelSchemaPathParam) { + return modelSchemaPathParam; + } + + try { + const allApiResources = await context.amplify.getResourceStatus('api'); + const apiResource = allApiResources.allResources.find( + resource => resource.service === 'AppSync' && resource.providerPlugin === 'awscloudformation', + ); + if (!apiResource) { + context.print.info('No AppSync API configured. Please add an API'); + return null; + } + + const backendPath = await context.amplify.pathManager.getBackendDirPath(); + return path.join(backendPath, 'api', apiResource.resourceName); + } catch (_) { + throw new Error('Schema resource path not found, if you are running this command from a directory without a local amplify directory, be sure to specify the path to your model schema file or folder via --model-schema.'); + } +}; + +/** + * Return the additional directive definitions requred for graphql parsing and validation. + * @param {!AmplifyContext} context the amplify runtime context + * @param {!string} apiResourcePath the directory to attempt to retrieve amplify compilation in + * @returns {!Promise} the stringified version in the transformer directives + */ +const getDirectives = async (context, apiResourcePath) => { + try { + // Return await is important here, otherwise we will fail to drop into the catch statement + return await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getTransformerDirectives', { + resourceDir: apiResourcePath, + }); + } catch { + return defaultDirectiveDefinitions; + } +}; + +/** + * Retrieve the output directory to write assets into. + * @param {!AmplifyContext} context the amplify runtime context + * @param {!string} projectRoot the project root directory + * @param {string | null} overrideOutputDir the override dir, if one is specified + * @returns {!string} the directory to write output files into + */ +const getOutputDir = (context, projectRoot, overrideOutputDir) => { + if (overrideOutputDir) { + return overrideOutputDir; + } + try { + return path.join(projectRoot, getModelOutputPath(context.amplify.getProjectConfig())); + } catch (_) { + throw new Error('Output directory could not be determined, to specify, set the --output-dir CLI property.') + } +}; + +/** + * Return the frontend to run modelgen for. + * @param {!AmplifyContext} context the amplify runtime context + * @returns {!ModelgenFrontend} the frontend configured in the project + */ +const getFrontend = (context, isIntrospection) => { + if (isIntrospection === true) { + return 'introspection'; + } + const targetParam = context.parameters?.options?.['target']; + if (targetParam) { + if (!modelgenFrontendToTargetMap[targetParam]) { + throw new Error(`Unexpected --target value ${targetParam} provided, expected one of ${JSON.stringify(Object.keys(modelgenFrontendToTargetMap))}`) + } + return targetParam; + } + + try { + return context.amplify.getProjectConfig().frontend; + } catch (_) { + throw new Error('Modelgen target not found, if you are running this command from a directory without a local amplify directory, be sure to specify the modelgen target via --target.'); + } +}; + +/** + * Validate the project for any configuration issues. + * @param {!AmplifyContext} context the amplify runtime context + * @param {!ModelgenFrontend} frontend the frontend used in this project + * @param {!string} projectRoot project root directory for validation + * @returns {!Promise<{validationFailures: Array, validationWarnings: Array}>} an array of any detected validation failures + */ +const validateProject = async (context, frontend, projectRoot) => { + const validationFailures = []; + const validationWarnings = []; + + // Attempt to validate schema compilation, and print any errors if an override schema path was not presented (in which case this will fail) + try { + if (!hasModelSchemaPathParam(context)) { + await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { + noConfig: true, + forceCompile: true, + dryRun: true, + disableResolverOverrides: true, + }); + } + } catch (err) { + validationWarnings.push(err.toString()); + } + + // Flutter version check + if (frontend === 'flutter' && !validateAmplifyFlutterMinSupportedVersion(projectRoot)) { + validationFailures.push(`🚫 Models are not generated! +Amplify Flutter versions prior to 0.6.0 are no longer supported by codegen. Please upgrade to use codegen.`); + } + + return { validationWarnings, validationFailures }; +}; + +/** + * Type for invoking the generateModels method. + * @typedef {Object} GenerateModelsOptions + * @property {string | null} overrideOutputDir override path for the file output + * @property {!boolean} isIntrospection whether or not this is an introspection + * @property {!boolean} writeToDisk whether or not to write the results to the disk + */ + +/** + * @type GenerateModelsOptions + */ const defaultGenerateModelsOption = { overrideOutputDir: null, isIntrospection: false, writeToDisk: true, }; +/** + * Generate the models for client via the following steps. + * 1. Load the schema and validate using transformer + * 2. get all the directives supported by transformer + * 3. Generate code + * @param {!AmplifyContext} context the amplify runtime context + * @param {GenerateModelsOptions | null} generateOptions the generation options + * @returns the generated assets as a map + */ async function generateModels(context, generateOptions = null) { const { overrideOutputDir, isIntrospection, writeToDisk } = generateOptions ? { ...defaultGenerateModelsOption, ...generateOptions } : defaultGenerateModelsOption; - // steps: - // 1. Load the schema and validate using transformer - // 2. get all the directives supported by transformer - // 3. Generate code - let projectRoot; - try { - context.amplify.getProjectMeta(); - projectRoot = context.amplify.getEnvInfo().projectPath; - } catch (e) { - projectRoot = process.cwd(); - } + const frontend = getFrontend(context, isIntrospection); - const allApiResources = await context.amplify.getResourceStatus('api'); - const apiResource = allApiResources.allResources.find( - resource => resource.service === 'AppSync' && resource.providerPlugin === 'awscloudformation', - ); - - if (!apiResource) { - context.print.info('No AppSync API configured. Please add an API'); + const apiResourcePath = await getApiResourcePath(context); + if (!apiResourcePath) { return; } - await validateSchema(context); - const backendPath = await context.amplify.pathManager.getBackendDirPath(); - const apiResourcePath = path.join(backendPath, 'api', apiResource.resourceName); - - const directiveDefinitions = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getTransformerDirectives', { - resourceDir: apiResourcePath, - }); - - const schemaContent = loadSchema(apiResourcePath); - const baseOutputDir = overrideOutputDir || path.join(projectRoot, getModelOutputPath(context)); - const projectConfig = context.amplify.getProjectConfig(); + const projectRoot = getProjectRoot(context); - if (!isIntrospection && projectConfig.frontend === 'flutter' && !validateAmplifyFlutterMinSupportedVersion(projectRoot)) { - context.print.error(`🚫 Models are not generated! -Amplify Flutter versions prior to 0.6.0 are no longer supported by codegen. Please upgrade to use codegen.`); + const { validationFailures, validationWarnings } = await validateProject(context, frontend, projectRoot); + validationWarnings.forEach(context.print.warning); + validationFailures.forEach(context.print.error); + if (validationFailures.length > 0) { return; } - const generateIndexRules = readFeatureFlag('codegen.generateIndexRules'); - const emitAuthProvider = readFeatureFlag('codegen.emitAuthProvider'); - const useExperimentalPipelinedTransformer = readFeatureFlag('graphQLTransformer.useExperimentalPipelinedTransformer'); - const transformerVersion = readNumericFeatureFlag('graphQLTransformer.transformerVersion'); - const respectPrimaryKeyAttributesOnConnectionField = readFeatureFlag('graphQLTransformer.respectPrimaryKeyAttributesOnConnectionField'); - const generateModelsForLazyLoadAndCustomSelectionSet = readFeatureFlag('codegen.generateModelsForLazyLoadAndCustomSelectionSet'); - const improvePluralization = readFeatureFlag('graphQLTransformer.improvePluralization'); - const addTimestampFields = readFeatureFlag('codegen.addTimestampFields'); - - const handleListNullabilityTransparently = readFeatureFlag('codegen.handleListNullabilityTransparently'); - const generatedCode = await generateModelsHelper({ - schema: schemaContent, - directives: directiveDefinitions, - target: isIntrospection ? 'introspection' : platformToLanguageMap[projectConfig.frontend], - generateIndexRules, - emitAuthProvider, - useExperimentalPipelinedTransformer, - transformerVersion, - respectPrimaryKeyAttributesOnConnectionField, - improvePluralization, - generateModelsForLazyLoadAndCustomSelectionSet, - addTimestampFields, - handleListNullabilityTransparently, + schema: loadSchema(apiResourcePath), + directives: await getDirectives(context, apiResourcePath), + target: modelgenFrontendToTargetMap[frontend], + generateIndexRules: readFeatureFlag(context, 'codegen.generateIndexRules'), + emitAuthProvider: readFeatureFlag(context, 'codegen.emitAuthProvider'), + useExperimentalPipelinedTransformer: readFeatureFlag(context, 'graphQLTransformer.useExperimentalPipelinedTransformer'), + transformerVersion: readNumericFeatureFlag(context, 'graphQLTransformer.transformerVersion'), + respectPrimaryKeyAttributesOnConnectionField: readFeatureFlag(context, 'graphQLTransformer.respectPrimaryKeyAttributesOnConnectionField'), + improvePluralization: readFeatureFlag(context, 'graphQLTransformer.improvePluralization'), + generateModelsForLazyLoadAndCustomSelectionSet: readFeatureFlag(context, 'codegen.generateModelsForLazyLoadAndCustomSelectionSet'), + addTimestampFields: readFeatureFlag(context, 'codegen.addTimestampFields'), + handleListNullabilityTransparently: readFeatureFlag(context, 'codegen.handleListNullabilityTransparently'), }); if (writeToDisk) { + const outputDir = getOutputDir(context, projectRoot, overrideOutputDir); Object.entries(generatedCode).forEach(([filepath, contents]) => { - fs.outputFileSync(path.resolve(path.join(baseOutputDir, filepath)), contents); + fs.outputFileSync(path.resolve(path.join(outputDir, filepath)), contents); }); // TODO: move to @aws-amplify/graphql-generator generateEslintIgnore(context); - context.print.info(`Successfully generated models. Generated models can be found in ${baseOutputDir}`); + context.print.info(`Successfully generated models. Generated models can be found in ${outputDir}`); } return Object.values(generatedCode); } -async function validateSchema(context) { - try { - await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { - noConfig: true, - forceCompile: true, - dryRun: true, - disableResolverOverrides: true, - }); - } catch (err) { - context.print.error(err.toString()); - } -} - +/** + * Load the graphql schema definition from a given project directory. + * @param {!string} apiResourcePath the path to the directory containing graphql files. + * @returns {!string} the graphql string for all schema files found + */ function loadSchema(apiResourcePath) { + if (fs.lstatSync(apiResourcePath).isFile()) { + return fs.readFileSync(apiResourcePath, 'utf8'); + } const schemaFilePath = path.join(apiResourcePath, 'schema.graphql'); const schemaDirectory = path.join(apiResourcePath, 'schema'); if (fs.pathExistsSync(schemaFilePath)) { @@ -167,8 +310,12 @@ function loadSchema(apiResourcePath) { throw new Error('Could not load the schema'); } -function getModelOutputPath(context) { - const projectConfig = context.amplify.getProjectConfig(); +/** + * Retrieve the model output path for the given project configuration + * @param {any} projectConfig the amplify runtime context + * @returns the model output path, relative to the project root + */ +function getModelOutputPath(projectConfig) { switch (projectConfig.frontend) { case 'javascript': return path.join( @@ -190,21 +337,32 @@ function getModelOutputPath(context) { } } +/** + * Write the .eslintignore file contents to disk if appropriate for the project + * @param {!AmplifyContext} context the amplify runtime context + * @returns once eslint side effecting is complete + */ function generateEslintIgnore(context) { - const projectConfig = context.amplify.getProjectConfig(); + let projectConfig; + let projectPath; + try { + projectConfig = context.amplify.getProjectConfig(); + projectPath = pathManager.findProjectRoot(); + } catch (_) { + return; + } if (projectConfig.frontend !== 'javascript') { return; } - const projectPath = pathManager.findProjectRoot(); if (!projectPath) { return; } const eslintIgnorePath = path.join(projectPath, '.eslintignore'); - const modelFolder = path.join(getModelOutputPath(context), 'models'); + const modelFolder = path.join(getModelOutputPath(projectConfig), 'models'); if (!fs.existsSync(eslintIgnorePath)) { fs.writeFileSync(eslintIgnorePath, modelFolder); diff --git a/packages/amplify-codegen/src/commands/statements.js b/packages/amplify-codegen/src/commands/statements.js index f87c1be26..86184b91d 100644 --- a/packages/amplify-codegen/src/commands/statements.js +++ b/packages/amplify-codegen/src/commands/statements.js @@ -4,7 +4,13 @@ const Ora = require('ora'); const { loadConfig } = require('../codegen-config'); const constants = require('../constants'); -const { ensureIntrospectionSchema, getFrontEndHandler, getAppSyncAPIDetails, readSchemaFromFile } = require('../utils'); +const { + ensureIntrospectionSchema, + getFrontEndHandler, + getAppSyncAPIDetails, + readSchemaFromFile, + getAppSyncAPIInfoFromProject, +} = require('../utils'); const { generateGraphQLDocuments } = require('@aws-amplify/graphql-docs-generator'); const { generateStatements: generateStatementsHelper } = require('@aws-amplify/graphql-generator'); @@ -16,9 +22,18 @@ async function generateStatements(context, forceDownloadSchema, maxDepth, withou } const config = loadConfig(context, withoutInit); const projects = config.getProjects(); + if (!projects.length && withoutInit) { + context.print.info(constants.ERROR_CODEGEN_NO_API_CONFIGURED); + return; + } let apis = []; if (!withoutInit) { apis = getAppSyncAPIDetails(context); + } else { + const api = await getAppSyncAPIInfoFromProject(context, projects[0]); + if (api) { + apis = [api]; + } } let projectPath = process.cwd(); if (!withoutInit) { @@ -30,10 +45,6 @@ async function generateStatements(context, forceDownloadSchema, maxDepth, withou return; } } - if (!projects.length && withoutInit) { - context.print.info(constants.ERROR_CODEGEN_NO_API_CONFIGURED); - return; - } for (const cfg of projects) { const includeFiles = path.join(projectPath, cfg.includes[0]); @@ -41,15 +52,11 @@ async function generateStatements(context, forceDownloadSchema, maxDepth, withou ? path.join(projectPath, cfg.amplifyExtension.docsFilePath) : path.dirname(path.dirname(includeFiles)); const schemaPath = path.join(projectPath, cfg.schema); - let region; - let frontend; - if (!withoutInit) { - ({ region } = cfg.amplifyExtension); + if (apis.length) { + const { region } = cfg.amplifyExtension; await ensureIntrospectionSchema(context, schemaPath, apis[0], region, forceDownloadSchema); - frontend = getFrontEndHandler(context); - } else { - frontend = decoupleFrontend; } + const frontend = withoutInit ? cfg.amplifyExtension.frontend : getFrontEndHandler(context); const language = frontend === 'javascript' ? cfg.amplifyExtension.codeGenTarget : 'graphql'; const opsGenSpinner = new Ora(constants.INFO_MESSAGE_OPS_GEN); diff --git a/packages/amplify-codegen/src/commands/types.js b/packages/amplify-codegen/src/commands/types.js index c45c3b17c..5da70cddf 100644 --- a/packages/amplify-codegen/src/commands/types.js +++ b/packages/amplify-codegen/src/commands/types.js @@ -5,7 +5,7 @@ const glob = require('glob-all'); const constants = require('../constants'); const { loadConfig } = require('../codegen-config'); -const { ensureIntrospectionSchema, getFrontEndHandler, getAppSyncAPIDetails } = require('../utils'); +const { ensureIntrospectionSchema, getFrontEndHandler, getAppSyncAPIDetails, getAppSyncAPIInfoFromProject } = require('../utils'); const { generateTypes: generateTypesHelper } = require('@aws-amplify/graphql-generator'); const { extractDocumentFromJavascript } = require('@aws-amplify/graphql-types-generator'); @@ -22,9 +22,18 @@ async function generateTypes(context, forceDownloadSchema, withoutInit = false, if (frontend !== 'android') { const config = loadConfig(context, withoutInit); const projects = config.getProjects(); + if (!projects.length && withoutInit) { + context.print.info(constants.ERROR_CODEGEN_NO_API_CONFIGURED); + return; + } let apis = []; if (!withoutInit) { apis = getAppSyncAPIDetails(context); + } else { + const api = await getAppSyncAPIInfoFromProject(context, projects[0]); + if (api) { + apis = [api]; + } } if (!projects.length || !apis.length) { if (!withoutInit) { @@ -48,7 +57,7 @@ async function generateTypes(context, forceDownloadSchema, withoutInit = false, const target = cfg.amplifyExtension.codeGenTarget; const excludes = cfg.excludes.map(pattern => `!${pattern}`); - const queries = glob + const queryFiles = glob .sync([...includeFiles, ...excludes], { cwd: projectPath, absolute: true, @@ -64,14 +73,22 @@ async function generateTypes(context, forceDownloadSchema, withoutInit = false, return extractDocumentFromJavascript(fileContents, ''); } return fileContents; - }) - .join('\n'); + }); + if (queryFiles.length === 0) { + throw new Error('No queries found to generate types for, you may need to run \'codegen statements\' first'); + } + const queries = queryFiles.join('\n'); const schemaPath = path.join(projectPath, cfg.schema); - let region; - if (!withoutInit) { - ({ region } = cfg.amplifyExtension); + + const outputPath = path.join(projectPath, generatedFileName); + if (apis.length) { + const { region } = cfg.amplifyExtension; await ensureIntrospectionSchema(context, schemaPath, apis[0], region, forceDownloadSchema); + } else { + if (!fs.existsSync(schemaPath)) { + throw new Error(`Cannot find GraphQL schema file: ${schemaPath}`); + } } const codeGenSpinner = new Ora(constants.INFO_MESSAGE_CODEGEN_GENERATE_STARTED); codeGenSpinner.start(); diff --git a/packages/amplify-codegen/src/constants.js b/packages/amplify-codegen/src/constants.js index 3a6cb077d..111122e6e 100644 --- a/packages/amplify-codegen/src/constants.js +++ b/packages/amplify-codegen/src/constants.js @@ -19,7 +19,8 @@ module.exports = { PROMPT_MSG_SELECT_PROJECT: 'Choose the AppSync API', PROMPT_MSG_SELECT_REGION: 'Choose AWS Region', ERROR_CODEGEN_TARGET_NOT_SUPPORTED: 'is not supported by codegen plugin', - ERROR_FLUTTER_CODEGEN_NOT_SUPPORTED: 'Flutter only supports the command $amplify codegen models. All the other codegen commands are not supported.', + ERROR_FLUTTER_CODEGEN_NOT_SUPPORTED: + 'Flutter only supports the command $amplify codegen models. All the other codegen commands are not supported.', ERROR_CODEGEN_FRONTEND_NOT_SUPPORTED: 'The project frontend is not supported by codegen', ERROR_MSG_MAX_DEPTH: 'Depth should be a integer greater than 0', ERROR_CODEGEN_NO_API_AVAILABLE: 'There are no GraphQL APIs available.\nAdd by running $amplify api add', @@ -37,7 +38,8 @@ module.exports = { CMD_DESCRIPTION_CONFIGURE: 'Change/Update codegen configuration', ERROR_CODEGEN_NO_API_CONFIGURED: 'code generation is not configured. Configure it by running \n$amplify codegen add', ERROR_CODEGEN_PENDING_API_PUSH: 'AppSync API is not pushed to the cloud. Did you forget to do \n$amplify api push', - ERROR_CODEGEN_NO_API_META: 'Cannot find API metadata. Please reset codegen by running $amplify codegen remove && amplify codegen add --apiId YOUR_API_ID', + ERROR_CODEGEN_NO_API_META: + 'Cannot find API metadata. Please reset codegen by running $amplify codegen remove && amplify codegen add --apiId YOUR_API_ID', WARNING_CODEGEN_PENDING_API_PUSH: 'The APIs listed below are not pushed to the cloud. Run amplify api push', ERROR_APPSYNC_API_NOT_FOUND: 'Could not find the AppSync API. If you have removed the AppSync API in the console run amplify codegen remove', @@ -55,5 +57,6 @@ module.exports = { INFO_MESSAGE_DOWNLOAD_ERROR: 'Downloading schema failed', INFO_MESSAGE_OPS_GEN: 'Generating GraphQL operations', INFO_MESSAGE_OPS_GEN_SUCCESS: 'Generated GraphQL operations successfully and saved at ', - INFO_MESSAGE_ADD_ERROR: 'amplify codegen add takes only apiId as parameter. \n$ amplify codegen add [--apiId ]', + INFO_MESSAGE_ADD_ERROR: + 'amplify codegen add takes only apiId and region as parameters. \n$ amplify codegen add [--apiId ] [--region ]', }; diff --git a/packages/amplify-codegen/src/utils/defaultDirectiveDefinitions.js b/packages/amplify-codegen/src/utils/defaultDirectiveDefinitions.js new file mode 100644 index 000000000..f0254f38f --- /dev/null +++ b/packages/amplify-codegen/src/utils/defaultDirectiveDefinitions.js @@ -0,0 +1,113 @@ +const defaultDirectiveDefinitions = ` +directive @aws_subscribe(mutations: [String!]!) on FIELD_DEFINITION + +directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION + +directive @aws_api_key on FIELD_DEFINITION | OBJECT + +directive @aws_iam on FIELD_DEFINITION | OBJECT + +directive @aws_oidc on FIELD_DEFINITION | OBJECT + +directive @aws_cognito_user_pools(cognito_groups: [String!]) on FIELD_DEFINITION | OBJECT + +directive @aws_lambda on FIELD_DEFINITION | OBJECT + +directive @deprecated(reason: String) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ENUM | ENUM_VALUE + +directive @model(queries: ModelQueryMap, mutations: ModelMutationMap, subscriptions: ModelSubscriptionMap, timestamps: TimestampConfiguration) on OBJECT +input ModelMutationMap { + create: String + update: String + delete: String +} +input ModelQueryMap { + get: String + list: String +} +input ModelSubscriptionMap { + onCreate: [String] + onUpdate: [String] + onDelete: [String] + level: ModelSubscriptionLevel +} +enum ModelSubscriptionLevel { + off + public + on +} +input TimestampConfiguration { + createdAt: String + updatedAt: String +} +directive @function(name: String!, region: String, accountId: String) repeatable on FIELD_DEFINITION +directive @http(method: HttpMethod = GET, url: String!, headers: [HttpHeader] = []) on FIELD_DEFINITION +enum HttpMethod { + GET + POST + PUT + DELETE + PATCH +} +input HttpHeader { + key: String + value: String +} +directive @predictions(actions: [PredictionsActions!]!) on FIELD_DEFINITION +enum PredictionsActions { + identifyText + identifyLabels + convertTextToSpeech + translateText +} +directive @primaryKey(sortKeyFields: [String]) on FIELD_DEFINITION +directive @index(name: String, sortKeyFields: [String], queryField: String) repeatable on FIELD_DEFINITION +directive @hasMany(indexName: String, fields: [String!], limit: Int = 100) on FIELD_DEFINITION +directive @hasOne(fields: [String!]) on FIELD_DEFINITION +directive @manyToMany(relationName: String!, limit: Int = 100) on FIELD_DEFINITION +directive @belongsTo(fields: [String!]) on FIELD_DEFINITION +directive @default(value: String!) on FIELD_DEFINITION +directive @auth(rules: [AuthRule!]!) on OBJECT | FIELD_DEFINITION +input AuthRule { + allow: AuthStrategy! + provider: AuthProvider + identityClaim: String + groupClaim: String + ownerField: String + groupsField: String + groups: [String] + operations: [ModelOperation] +} +enum AuthStrategy { + owner + groups + private + public + custom +} +enum AuthProvider { + apiKey + iam + oidc + userPools + function +} +enum ModelOperation { + create + update + delete + read + list + get + sync + listen + search +} +directive @mapsTo(name: String!) on OBJECT +directive @searchable(queries: SearchableQueryMap) on OBJECT +input SearchableQueryMap { + search: String +} +`; + +module.exports = defaultDirectiveDefinitions; diff --git a/packages/amplify-codegen/src/utils/downloadIntrospectionSchema.js b/packages/amplify-codegen/src/utils/downloadIntrospectionSchema.js index 5fd3bb880..240eeebe7 100644 --- a/packages/amplify-codegen/src/utils/downloadIntrospectionSchema.js +++ b/packages/amplify-codegen/src/utils/downloadIntrospectionSchema.js @@ -16,7 +16,11 @@ async function downloadIntrospectionSchema(context, apiId, downloadLocation, reg const introspectionDir = dirname(downloadLocation); fs.ensureDirSync(introspectionDir); fs.writeFileSync(downloadLocation, schema, 'utf8'); - return relative(amplify.getEnvInfo().projectPath, downloadLocation); + try { + return relative(amplify.getEnvInfo().projectPath, downloadLocation); + } catch { + return downloadLocation; + } } catch (ex) { if (ex.code === 'NotFoundException') { throw new AmplifyCodeGenAPINotFoundError(constants.ERROR_APPSYNC_API_NOT_FOUND); diff --git a/packages/amplify-codegen/src/utils/ensureIntrospectionSchema.js b/packages/amplify-codegen/src/utils/ensureIntrospectionSchema.js index fc74b6633..f765d3f11 100644 --- a/packages/amplify-codegen/src/utils/ensureIntrospectionSchema.js +++ b/packages/amplify-codegen/src/utils/ensureIntrospectionSchema.js @@ -4,9 +4,12 @@ const generateIntrospectionSchema = require('./generateIntrospectionSchema'); const downloadIntrospectionSchemaWithProgress = require('./generateIntrospectionSchemaWithProgress'); async function ensureIntrospectionSchema(context, schemaPath, apiConfig, region, forceDownloadSchema) { - const meta = context.amplify.getProjectMeta(); + let meta; + try { + meta = context.amplify.getProjectMeta(); + } catch {} const { id, name } = apiConfig; - const isTransformedAPI = Object.keys(meta.api || {}).includes(name) && meta.api[name].providerPlugin === 'awscloudformation'; + const isTransformedAPI = meta && Object.keys(meta.api || {}).includes(name) && meta.api[name].providerPlugin === 'awscloudformation'; if (isTransformedAPI && getFrontendHandler(context) === 'android') { generateIntrospectionSchema(context, name); } else if (schemaPath.endsWith('.json')) { diff --git a/packages/amplify-codegen/src/utils/getAppSyncAPIInfoFromProject.js b/packages/amplify-codegen/src/utils/getAppSyncAPIInfoFromProject.js new file mode 100644 index 000000000..083f54941 --- /dev/null +++ b/packages/amplify-codegen/src/utils/getAppSyncAPIInfoFromProject.js @@ -0,0 +1,15 @@ +const getAppSyncAPIInfo = require('./getAppSyncAPIInfo'); + +/* Get AppSync api info if api id and region are avialable. + * Otherwise return undefined. + */ +async function getAppSyncAPIInfoFromProject(context, project) { + if (project.amplifyExtension.apiId && project.amplifyExtension.region) { + const { + amplifyExtension: { apiId, region }, + } = project; + return getAppSyncAPIInfo(context, apiId, region); + } + return undefined; +} +module.exports = getAppSyncAPIInfoFromProject; diff --git a/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js b/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js new file mode 100644 index 000000000..72aa7702f --- /dev/null +++ b/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js @@ -0,0 +1,27 @@ +const path = require('path'); +const getProjectRoot = require('./getProjectRoot'); + +/** + * Retrieve the specified model schema path parameter, returning as an absolute path. + * @param {!import('@aws-amplify/amplify-cli-core').$TSContext} context the CLI invocation context + * @returns {string | null} the absolute path to the model schema path + */ +const getModelSchemaPathParam = (context) => { + const modelSchemaPathParam = context.parameters?.options?.['model-schema']; + if ( !modelSchemaPathParam ) { + return null; + } + return path.isAbsolute(modelSchemaPathParam) ? modelSchemaPathParam : path.join(getProjectRoot(context), modelSchemaPathParam); +}; + +/** + * Retrieve whether or not a model schema path param was specified during invocation. + * @param {!import('@aws-amplify/amplify-cli-core').$TSContext} context the CLI invocation context + * @returns {!boolean} whether or not a model schema path param is specified via the CLI + */ +const hasModelSchemaPathParam = (context) => getModelSchemaPathParam(context) !== null; + +module.exports = { + getModelSchemaPathParam, + hasModelSchemaPathParam, +}; diff --git a/packages/amplify-codegen/src/utils/getOutputDirParam.js b/packages/amplify-codegen/src/utils/getOutputDirParam.js index 3f9f23e2f..8a7577bdf 100644 --- a/packages/amplify-codegen/src/utils/getOutputDirParam.js +++ b/packages/amplify-codegen/src/utils/getOutputDirParam.js @@ -1,11 +1,12 @@ const path = require('path'); +const getProjectRoot = require('./getProjectRoot'); /** * Retrieve the output directory parameter from the command line. Throws on invalid value, * or if isRequired is set and the flag isn't in the options. Returns null on optional and not defined. - * @param context the CLI invocation context - * @param isRequired whether or not the flag is required - * @returns the absolute path to the output directory + * @param {!import('@aws-amplify/amplify-cli-core').$TSContext} context the CLI invocation context + * @param {!boolean} isRequired whether or not the flag is required + * @returns {!string} the absolute path to the output directory */ function getOutputDirParam(context, isRequired) { const outputDirParam = context.parameters?.options?.['output-dir']; @@ -15,7 +16,7 @@ function getOutputDirParam(context, isRequired) { if ( !outputDirParam ) { return null; } - return path.isAbsolute(outputDirParam) ? outputDirParam : path.join(context.amplify.getEnvInfo().projectPath, outputDirParam); + return path.isAbsolute(outputDirParam) ? outputDirParam : path.join(getProjectRoot(context), outputDirParam); } module.exports = getOutputDirParam; diff --git a/packages/amplify-codegen/src/utils/getProjectRoot.js b/packages/amplify-codegen/src/utils/getProjectRoot.js new file mode 100644 index 000000000..c142b5717 --- /dev/null +++ b/packages/amplify-codegen/src/utils/getProjectRoot.js @@ -0,0 +1,14 @@ +/** + * Find the project root. + * @param {!import('@aws-amplify/amplify-cli-core').$TSContext} context the amplify runtime context + * @returns {!string} the project root, or cwd + */ +const getProjectRoot = (context) => { + try { + return context.amplify.getEnvInfo().projectPath; + } catch (_) { + return process.cwd(); + } + }; + +module.exports = getProjectRoot; diff --git a/packages/amplify-codegen/src/utils/index.js b/packages/amplify-codegen/src/utils/index.js index 3843088b8..35dbbbbb8 100644 --- a/packages/amplify-codegen/src/utils/index.js +++ b/packages/amplify-codegen/src/utils/index.js @@ -5,6 +5,7 @@ const downloadIntrospectionSchema = require('./downloadIntrospectionSchema'); const getSchemaDownloadLocation = require('./getSchemaDownloadLocation'); const getIncludePattern = require('./getIncludePattern'); const getAppSyncAPIInfo = require('./getAppSyncAPIInfo'); +const getAppSyncAPIInfoFromProject = require('./getAppSyncAPIInfoFromProject'); const getProjectAwsRegion = require('./getProjectAWSRegion'); const getGraphQLDocPath = require('./getGraphQLDocPath'); const downloadIntrospectionSchemaWithProgress = require('./generateIntrospectionSchemaWithProgress'); @@ -15,6 +16,7 @@ const getSDLSchemaLocation = require('./getSDLSchemaLocation'); const switchToSDLSchema = require('./switchToSDLSchema'); const ensureIntrospectionSchema = require('./ensureIntrospectionSchema'); const { readSchemaFromFile } = require('./readSchemaFromFile'); +const defaultDirectiveDefinitions = require('./defaultDirectiveDefinitions'); module.exports = { getAppSyncAPIDetails, getFrontEndHandler, @@ -24,6 +26,7 @@ module.exports = { downloadIntrospectionSchemaWithProgress, getIncludePattern, getAppSyncAPIInfo, + getAppSyncAPIInfoFromProject, getProjectAwsRegion, getGraphQLDocPath, isAppSyncApiPendingPush, @@ -33,4 +36,5 @@ module.exports = { switchToSDLSchema, ensureIntrospectionSchema, readSchemaFromFile, + defaultDirectiveDefinitions, }; diff --git a/packages/amplify-codegen/tests/cli/add.test.js b/packages/amplify-codegen/tests/cli/add.test.js new file mode 100644 index 000000000..5a460920f --- /dev/null +++ b/packages/amplify-codegen/tests/cli/add.test.js @@ -0,0 +1,144 @@ +const add = require('../../commands/codegen/add'); +const codeGen = require('../../src/index'); + +jest.mock('../../src/index'); + +describe('cli - add', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('feature name', () => { + expect(add.name).toEqual('add'); + }); + + describe('run', () => { + test('executes codegen add', async () => { + const context = { + parameters: {}, + }; + await add.run(context); + expect(codeGen.add).toHaveBeenCalledWith(context, null, undefined); + }); + + test('catches error in codegen add', async () => { + const error = jest.fn(); + const context = { + parameters: {}, + print: { + error, + }, + }; + + const codegenError = new Error('failed to read file'); + codeGen.add.mockRejectedValueOnce(codegenError); + await add.run(context); + expect(error).toHaveBeenCalledWith(codegenError.message); + }); + + test('passes apiId', async () => { + const apiId = 'apiid'; + const context = { + parameters: { + options: { + apiId, + }, + }, + }; + await add.run(context); + expect(codeGen.add).toHaveBeenCalledWith(context, apiId, undefined); + }); + + test('passes region', async () => { + const region = 'region'; + const context = { + parameters: { + options: { + region, + }, + }, + }; + await add.run(context); + expect(codeGen.add).toHaveBeenCalledWith(context, null, region); + }); + + test('throws error on invalid arg', async () => { + const badArg = 'badArg'; + const info = jest.fn(); + const context = { + parameters: { + options: { + badArg, + }, + }, + print: { + info, + }, + }; + await add.run(context); + expect(info).toHaveBeenCalledWith('Invalid parameter badArg'); + + expect(info).toHaveBeenCalledWith( + 'amplify codegen add takes only apiId and region as parameters. \n$ amplify codegen add [--apiId ] [--region ]', + ); + }); + + test('throws error on invalid args', async () => { + const badArgOne = 'badArgOne'; + const badArgTwo = 'badArgTwo'; + const info = jest.fn(); + const context = { + parameters: { + options: { + badArgOne, + badArgTwo, + }, + }, + print: { + info, + }, + }; + await add.run(context); + expect(info).toHaveBeenCalledWith('Invalid parameters badArgOne, badArgTwo'); + + expect(info).toHaveBeenCalledWith( + 'amplify codegen add takes only apiId and region as parameters. \n$ amplify codegen add [--apiId ] [--region ]', + ); + }); + + test('allows undocummented frontend and framework', async () => { + const frontend = 'frontend'; + const framework = 'framework'; + const info = jest.fn(); + const context = { + parameters: { + options: { + frontend, + framework, + }, + }, + print: { + info, + }, + }; + await add.run(context); + expect(info).not.toHaveBeenCalled(); + }); + + test('ignores yes arg', async () => { + const yes = true; + const info = jest.fn(); + const context = { + parameters: { + options: { + yes, + }, + }, + print: { + info, + }, + }; + await add.run(context); + }); + }); +}); diff --git a/packages/amplify-codegen/tests/commands/__snapshots__/add.test.js.snap b/packages/amplify-codegen/tests/commands/__snapshots__/add.test.js.snap new file mode 100644 index 000000000..beb912eb0 --- /dev/null +++ b/packages/amplify-codegen/tests/commands/__snapshots__/add.test.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`command - add without init should download introspection schema when api id 1`] = ` +Object { + "amplifyExtension": Object { + "apiId": "MOCK_API_ID", + "codeGenTarget": "TYPE_SCRIPT_OR_FLOW_OR_ANY_OTHER_LANGUAGE", + "docsFilePath": "MOCK_DOCS_FILE_PATH", + "framework": "react", + "frontend": "javascript", + "generatedFileName": "API.TS", + "region": "us-east-1", + }, + "excludes": "MOCK_EXCLUDE", + "includes": "MOCK_INCLUDE", + "projectName": "Codegen Project", + "schema": "/user/foo/project/schema.json", +} +`; + +exports[`command - add without init should read frontend and framework from options 1`] = ` +Object { + "amplifyExtension": Object { + "apiId": "MOCK_API_ID", + "codeGenTarget": "TYPE_SCRIPT_OR_FLOW_OR_ANY_OTHER_LANGUAGE", + "docsFilePath": "MOCK_DOCS_FILE_PATH", + "framework": "vue", + "frontend": "javascript", + "generatedFileName": "API.TS", + "region": "us-east-1", + }, + "excludes": "MOCK_EXCLUDE", + "includes": "MOCK_INCLUDE", + "projectName": "Codegen Project", + "schema": "/user/foo/project/schema.json", +} +`; + +exports[`command - add without init should use existing schema if no api id 1`] = ` +Object { + "amplifyExtension": Object { + "apiId": null, + "codeGenTarget": "TYPE_SCRIPT_OR_FLOW_OR_ANY_OTHER_LANGUAGE", + "docsFilePath": "MOCK_DOCS_FILE_PATH", + "framework": "react", + "frontend": "javascript", + "generatedFileName": "API.TS", + "region": "us-east-1", + }, + "excludes": "MOCK_EXCLUDE", + "includes": "MOCK_INCLUDE", + "projectName": "Codegen Project", + "schema": "/user/foo/project/schema.graphql", +} +`; + +exports[`command - add without init should use region supplied when without init 1`] = ` +Object { + "amplifyExtension": Object { + "apiId": "MOCK_API_ID", + "codeGenTarget": "TYPE_SCRIPT_OR_FLOW_OR_ANY_OTHER_LANGUAGE", + "docsFilePath": "MOCK_DOCS_FILE_PATH", + "framework": "react", + "frontend": "javascript", + "generatedFileName": "API.TS", + "region": "us-west-2", + }, + "excludes": "MOCK_EXCLUDE", + "includes": "MOCK_INCLUDE", + "projectName": "Codegen Project", + "schema": "/user/foo/project/schema.json", +} +`; diff --git a/packages/amplify-codegen/tests/commands/__snapshots__/models.test.js.snap b/packages/amplify-codegen/tests/commands/__snapshots__/models.test.js.snap index 1ddf205ef..fde125f2d 100644 --- a/packages/amplify-codegen/tests/commands/__snapshots__/models.test.js.snap +++ b/packages/amplify-codegen/tests/commands/__snapshots__/models.test.js.snap @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`command-models-generates models in expected output path android: Should generate for frontend when backend is not initialized locally, and logs no warnings or errors 1`] = ` +Array [ + "AmplifyModelProvider.java", + "SimpleModel.java", +] +`; + exports[`command-models-generates models in expected output path android: Should generate models from a single schema file 1`] = ` Array [ "AmplifyModelProvider.java", @@ -14,6 +21,13 @@ Array [ ] `; +exports[`command-models-generates models in expected output path flutter: Should generate for frontend when backend is not initialized locally, and logs no warnings or errors 1`] = ` +Array [ + "ModelProvider.dart", + "SimpleModel.dart", +] +`; + exports[`command-models-generates models in expected output path flutter: Should generate models from a single schema file 1`] = ` Array [ "ModelProvider.dart", @@ -28,6 +42,14 @@ Array [ ] `; +exports[`command-models-generates models in expected output path ios: Should generate for frontend when backend is not initialized locally, and logs no warnings or errors 1`] = ` +Array [ + "AmplifyModels.swift", + "SimpleModel+Schema.swift", + "SimpleModel.swift", +] +`; + exports[`command-models-generates models in expected output path ios: Should generate models from a single schema file 1`] = ` Array [ "AmplifyModels.swift", @@ -44,6 +66,15 @@ Array [ ] `; +exports[`command-models-generates models in expected output path javascript: Should generate for frontend when backend is not initialized locally, and logs no warnings or errors 1`] = ` +Array [ + "index.d.ts", + "index.js", + "schema.d.ts", + "schema.js", +] +`; + exports[`command-models-generates models in expected output path javascript: Should generate models from a single schema file 1`] = ` Array [ "index.d.ts", @@ -61,3 +92,12 @@ Array [ "schema.js", ] `; + +exports[`command-models-generates models in expected output path should use default directive definitions if getTransformerDirectives fails 1`] = ` +Array [ + "index.d.ts", + "index.js", + "schema.d.ts", + "schema.js", +] +`; diff --git a/packages/amplify-codegen/tests/commands/add.test.js b/packages/amplify-codegen/tests/commands/add.test.js index 5e921f4f3..f353d6548 100644 --- a/packages/amplify-codegen/tests/commands/add.test.js +++ b/packages/amplify-codegen/tests/commands/add.test.js @@ -1,13 +1,23 @@ +const fs = require('fs'); +const path = require('path'); const { loadConfig } = require('../../src/codegen-config'); const generateStatements = require('../../src/commands/statements'); const generateTypes = require('../../src/commands/types'); const addWalkthrough = require('../../src/walkthrough/add'); +const askForFrontend = require('../../src/walkthrough/questions/selectFrontend'); +const askForFramework = require('../../src/walkthrough/questions/selectFramework'); const changeAppSyncRegions = require('../../src/walkthrough/changeAppSyncRegions'); const { AmplifyCodeGenAPINotFoundError } = require('../../src/errors'); const add = require('../../src/commands/add'); -const { getAppSyncAPIDetails, getAppSyncAPIInfo, getProjectAwsRegion, getSDLSchemaLocation } = require('../../src/utils'); +const { + getAppSyncAPIDetails, + getAppSyncAPIInfo, + getProjectAwsRegion, + getSDLSchemaLocation, + downloadIntrospectionSchemaWithProgress, +} = require('../../src/utils'); const MOCK_CONTEXT = { print: { @@ -16,13 +26,23 @@ const MOCK_CONTEXT = { amplify: { getProjectMeta: jest.fn(), }, + parameters: { + options: {}, + }, }; +const mockProjectDir = '/user/foo/project'; +jest.mock('fs'); jest.mock('../../src/walkthrough/add'); +jest.mock('../../src/walkthrough/questions/selectFrontend'); +jest.mock('../../src/walkthrough/questions/selectFramework'); jest.mock('../../src/walkthrough/changeAppSyncRegions'); jest.mock('../../src/commands/types'); jest.mock('../../src/commands/statements'); jest.mock('../../src/codegen-config'); jest.mock('../../src/utils'); +jest.mock('process', () => ({ + cwd: () => mockProjectDir, +})); const MOCK_INCLUDE_PATTERN = 'MOCK_INCLUDE'; const MOCK_EXCLUDE_PATTERN = 'MOCK_EXCLUDE'; @@ -68,6 +88,7 @@ describe('command - add', () => { loadConfig.mockReturnValue(LOAD_CONFIG_METHODS); getProjectAwsRegion.mockReturnValue(MOCK_AWS_REGION); getSDLSchemaLocation.mockReturnValue(MOCK_SCHEMA_FILE_LOCATION); + downloadIntrospectionSchemaWithProgress.mockReturnValue(); }); it('should walkthrough add questions', async () => { @@ -148,4 +169,115 @@ describe('command - add', () => { await add(MOCK_CONTEXT); expect(generateTypes).not.toHaveBeenCalled(); }); + + it('should ignore region supplied when with init', async () => { + const region = 'us-west-2'; + await add(MOCK_CONTEXT, MOCK_API_ID, region); + expect(getProjectAwsRegion).toHaveBeenCalled(); + expect(getAppSyncAPIInfo).toHaveBeenCalledWith(MOCK_CONTEXT, MOCK_API_ID, MOCK_AWS_REGION); + }); + + describe('without init', () => { + const getProjectMeta = jest.fn(); + const schemaPath = path.join(mockProjectDir, 'schema.json'); + beforeEach(() => { + loadConfig.mockReturnValue({ ...LOAD_CONFIG_METHODS, getProjects: jest.fn().mockReturnValue([]) }); + askForFrontend.mockReturnValue('javascript'); + askForFramework.mockReturnValue('react'); + getProjectMeta.mockRejectedValue('no init'); + fs.existsSync.mockReturnValue(false); + getAppSyncAPIInfo.mockReturnValue(MOCK_APPSYNC_API_DETAIL); + downloadIntrospectionSchemaWithProgress.mockReturnValue(schemaPath); + }); + + afterEach(() => { + loadConfig.mockReset(); + askForFrontend.mockReset(); + askForFramework.mockReset(); + getProjectMeta.mockReset(); + fs.existsSync.mockReset(); + getAppSyncAPIInfo.mockReset(); + downloadIntrospectionSchemaWithProgress.mockReset(); + }); + + it('should download introspection schema when api id', async () => { + const context = { ...MOCK_CONTEXT, amplify: { getProjectMeta } }; + const defaultRegion = 'us-east-1'; + await add(context, MOCK_API_ID); + expect(getAppSyncAPIInfo).toHaveBeenCalledWith(context, MOCK_API_ID, defaultRegion); + expect(downloadIntrospectionSchemaWithProgress).toHaveBeenCalledWith(context, MOCK_API_ID, schemaPath, defaultRegion); + expect(LOAD_CONFIG_METHODS.addProject.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should use existing schema if no api id', async () => { + fs.existsSync.mockReturnValue(true); + const context = { ...MOCK_CONTEXT, amplify: { getProjectMeta } }; + const defaultRegion = 'us-east-1'; + await add(context); + expect(getAppSyncAPIInfo).not.toHaveBeenCalled(); + expect(downloadIntrospectionSchemaWithProgress).not.toHaveBeenCalled(); + expect(LOAD_CONFIG_METHODS.addProject.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should read frontend and framework from options', async () => { + const parameters = { + options: { + frontend: 'javascript', + framework: 'vue', + }, + }; + await add({ ...MOCK_CONTEXT, amplify: { getProjectMeta }, parameters }, MOCK_API_ID); + expect(askForFrontend).not.toHaveBeenCalled(); + expect(askForFramework).not.toHaveBeenCalled(); + expect(LOAD_CONFIG_METHODS.addProject).toHaveBeenCalled(); + expect(LOAD_CONFIG_METHODS.addProject.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should use region supplied when without init', async () => { + const region = 'us-west-2'; + const context = { ...MOCK_CONTEXT, amplify: { getProjectMeta } }; + await add(context, MOCK_API_ID, region); + expect(getProjectAwsRegion).not.toHaveBeenCalled(); + expect(getAppSyncAPIInfo).toHaveBeenCalledWith(context, MOCK_API_ID, region); + expect(LOAD_CONFIG_METHODS.addProject).toHaveBeenCalled(); + expect(LOAD_CONFIG_METHODS.addProject.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should error on invalid frontend', () => { + const parameters = { + options: { + frontend: 'foo', + }, + }; + expect(add({ ...MOCK_CONTEXT, amplify: { getProjectMeta }, parameters }, MOCK_API_ID)).rejects.toThrowError( + 'Invalid frontend provided', + ); + }); + + it('should error on invalid framework', () => { + const parameters = { + options: { + frontend: 'javascript', + framework: 'foo', + }, + }; + expect(add({ ...MOCK_CONTEXT, amplify: { getProjectMeta }, parameters }, MOCK_API_ID)).rejects.toThrowError( + 'Invalid framework provided', + ); + }); + + it('should error if codegen project already exists', () => { + loadConfig.mockReturnValue({ ...LOAD_CONFIG_METHODS, getProjects: jest.fn().mockReturnValue(['foo']) }); + expect(add({ ...MOCK_CONTEXT, amplify: { getProjectMeta } }, MOCK_API_ID)).rejects.toThrowError( + 'Codegen support only one GraphQL API per project', + ); + }); + + it('should error if codegen project already exists', () => { + fs.existsSync.mockReturnValue(false); + expect(add({ ...MOCK_CONTEXT, amplify: { getProjectMeta } })).rejects.toThrowError( + 'Provide an AppSync API ID with --apiId or manually download schema.graphql or schema.json and place in /user/foo/project before adding codegen when not in an amplify project', + ); + }); + }); }); diff --git a/packages/amplify-codegen/tests/commands/model-introspection.test.js b/packages/amplify-codegen/tests/commands/model-introspection.test.js index be6f96666..e94b994a6 100644 --- a/packages/amplify-codegen/tests/commands/model-introspection.test.js +++ b/packages/amplify-codegen/tests/commands/model-introspection.test.js @@ -31,6 +31,7 @@ const MOCK_CONTEXT = { print: { info: jest.fn(), warning: jest.fn(), + error: jest.fn(), }, amplify: { getProjectMeta: jest.fn(), diff --git a/packages/amplify-codegen/tests/commands/models.test.js b/packages/amplify-codegen/tests/commands/models.test.js index d2b7be990..844699adf 100644 --- a/packages/amplify-codegen/tests/commands/models.test.js +++ b/packages/amplify-codegen/tests/commands/models.test.js @@ -3,6 +3,7 @@ const { validateAmplifyFlutterMinSupportedVersion, MINIMUM_SUPPORTED_VERSION_CONSTRAINT, } = require('../../src/utils/validateAmplifyFlutterMinSupportedVersion'); +const defaultDirectiveDefinitions = require('../../src/utils/defaultDirectiveDefinitions'); const mockFs = require('mock-fs'); const fs = require('fs'); const path = require('path'); @@ -31,6 +32,9 @@ const MOCK_CONTEXT = { }, getProjectConfig: jest.fn(), }, + parameters: { + options: {}, + }, }; const OUTPUT_PATHS = { javascript: 'src/models', @@ -41,11 +45,11 @@ const OUTPUT_PATHS = { const MOCK_PROJECT_ROOT = 'project'; const MOCK_PROJECT_NAME = 'myapp'; const MOCK_BACKEND_DIRECTORY = 'backend'; -const MOCK_GENERATED_CODE = 'This code is auto-generated!'; describe('command-models-generates models in expected output path', () => { beforeEach(() => { jest.resetAllMocks(); + MOCK_CONTEXT.parameters.options = {}; addMocksToContext(); validateAmplifyFlutterMinSupportedVersion.mockReturnValue(true); }); @@ -56,7 +60,6 @@ describe('command-models-generates models in expected output path', () => { const schemaFilePath = path.join(MOCK_BACKEND_DIRECTORY, 'api', MOCK_PROJECT_NAME); const outputDirectory = path.join(MOCK_PROJECT_ROOT, OUTPUT_PATHS[frontend]); const mockedFiles = {}; - const nodeModules = path.resolve(path.join(__dirname, '../../../../node_modules')); mockedFiles[schemaFilePath] = { 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', }; @@ -141,8 +144,155 @@ describe('command-models-generates models in expected output path', () => { expect(MOCK_CONTEXT.print.error).toBeCalled(); }); } + + it(frontend + ': Should generate for frontend when backend is not initialized locally, and logs no warnings or errors', async () => { + // mock the input and output file structure + const mockedFiles = {}; + mockedFiles[MOCK_PROJECT_ROOT] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + const overrideOutputDir = 'some/other/dir'; + mockedFiles[overrideOutputDir] = {}; + mockFs(mockedFiles); + + // For non-intialized projects, assume the amplify context throws primarily errors, and instead input is provided via options + MOCK_CONTEXT.amplify.getEnvInfo.mockImplementation(() => { throw new Error('getEnvInfo Internal Error') }); + MOCK_CONTEXT.amplify.getProjectConfig.mockImplementation(() => { throw new Error('getProjectConfig Internal Error') }); + MOCK_CONTEXT.amplify.getResourceStatus.mockImplementation(() => { throw new Error('getResourceStatus Internal Error') }); + MOCK_CONTEXT.amplify.executeProviderUtils.mockImplementation(() => { throw new Error('executeProviderUtils Internal Error') }); + MOCK_CONTEXT.parameters.options = { target: frontend, 'model-schema': path.join(MOCK_PROJECT_ROOT, 'schema.graphql') }; + + // assert empty folder before generation + expect(fs.readdirSync(overrideOutputDir).length).toEqual(0); + + await generateModels(MOCK_CONTEXT, { overrideOutputDir }); + + // assert model files are generated in expected output directory + expect(fs.readdirSync(overrideOutputDir).length).not.toEqual(0); + + expect(MOCK_CONTEXT.print.error).not.toBeCalled(); + expect(MOCK_CONTEXT.print.warning).not.toBeCalled(); + + if (frontend === 'android') { + // Android/Java creates a deeply nested structure, which is preserved as a partial in the output files. + expect(fs.readdirSync(path.join(overrideOutputDir, 'com', 'amplifyframework', 'datastore', 'generated', 'model'))).toMatchSnapshot(); + } else { + expect(fs.readdirSync(overrideOutputDir)).toMatchSnapshot(); + } + }); } + it('throws an understandable error on invalid target option', async () => { + // mock the input and output file structure + const mockedFiles = {}; + mockedFiles[MOCK_PROJECT_ROOT] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + const overrideOutputDir = 'some/other/dir'; + mockedFiles[overrideOutputDir] = {}; + mockFs(mockedFiles); + + // For non-intialized projects, assume the amplify context throws primarily errors, and instead input is provided via options + MOCK_CONTEXT.amplify.getEnvInfo.mockImplementation(() => { throw new Error('getEnvInfo Internal Error') }); + MOCK_CONTEXT.amplify.getProjectConfig.mockImplementation(() => { throw new Error('getProjectConfig Internal Error') }); + MOCK_CONTEXT.amplify.getResourceStatus.mockImplementation(() => { throw new Error('getResourceStatus Internal Error') }); + MOCK_CONTEXT.amplify.executeProviderUtils.mockImplementation(() => { throw new Error('executeProviderUtils Internal Error') }); + MOCK_CONTEXT.parameters.options = { target: 'clojure', 'model-schema': path.join(MOCK_PROJECT_ROOT, 'schema.graphql') }; + + await expect(() => generateModels(MOCK_CONTEXT, { overrideOutputDir })) + .rejects + .toThrowErrorMatchingInlineSnapshot('"Unexpected --target value clojure provided, expected one of [\\"android\\",\\"ios\\",\\"flutter\\",\\"javascript\\",\\"typescript\\",\\"introspection\\"]"'); + }); + + it('throws an understandable error on missing model-schema flag and uninitialized backend', async () => { + // mock the input and output file structure + const mockedFiles = {}; + mockedFiles[MOCK_PROJECT_ROOT] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + const overrideOutputDir = 'some/other/dir'; + mockedFiles[overrideOutputDir] = {}; + mockFs(mockedFiles); + + // For non-intialized projects, assume the amplify context throws primarily errors, and instead input is provided via options + MOCK_CONTEXT.amplify.getEnvInfo.mockImplementation(() => { throw new Error('getEnvInfo Internal Error') }); + MOCK_CONTEXT.amplify.getProjectConfig.mockImplementation(() => { throw new Error('getProjectConfig Internal Error') }); + MOCK_CONTEXT.amplify.getResourceStatus.mockImplementation(() => { throw new Error('getResourceStatus Internal Error') }); + MOCK_CONTEXT.amplify.executeProviderUtils.mockImplementation(() => { throw new Error('executeProviderUtils Internal Error') }); + MOCK_CONTEXT.parameters.options = { target: 'javascript' }; + + await expect(() => generateModels(MOCK_CONTEXT, { overrideOutputDir })) + .rejects + .toThrowErrorMatchingInlineSnapshot('"Schema resource path not found, if you are running this command from a directory without a local amplify directory, be sure to specify the path to your model schema file or folder via --model-schema."'); + }); + + it('throws an understandable error on missing target flag and uninitialized backend', async () => { + // mock the input and output file structure + const mockedFiles = {}; + mockedFiles[MOCK_PROJECT_ROOT] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + const overrideOutputDir = 'some/other/dir'; + mockedFiles[overrideOutputDir] = {}; + mockFs(mockedFiles); + + // For non-intialized projects, assume the amplify context throws primarily errors, and instead input is provided via options + MOCK_CONTEXT.amplify.getEnvInfo.mockImplementation(() => { throw new Error('getEnvInfo Internal Error') }); + MOCK_CONTEXT.amplify.getProjectConfig.mockImplementation(() => { throw new Error('getProjectConfig Internal Error') }); + MOCK_CONTEXT.amplify.getResourceStatus.mockImplementation(() => { throw new Error('getResourceStatus Internal Error') }); + MOCK_CONTEXT.amplify.executeProviderUtils.mockImplementation(() => { throw new Error('executeProviderUtils Internal Error') }); + MOCK_CONTEXT.parameters.options = { 'model-schema': path.join(MOCK_PROJECT_ROOT, 'schema.graphql') }; + + await expect(() => generateModels(MOCK_CONTEXT, { overrideOutputDir })) + .rejects + .toThrowErrorMatchingInlineSnapshot('"Modelgen target not found, if you are running this command from a directory without a local amplify directory, be sure to specify the modelgen target via --target."'); + }); + + it('throws an understandable error on missing override output dir and uninitialized backend', async () => { + // mock the input and output file structure + const mockedFiles = {}; + mockedFiles[MOCK_PROJECT_ROOT] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + const overrideOutputDir = 'some/other/dir'; + mockedFiles[overrideOutputDir] = {}; + mockFs(mockedFiles); + + // For non-intialized projects, assume the amplify context throws primarily errors, and instead input is provided via options + MOCK_CONTEXT.amplify.getEnvInfo.mockImplementation(() => { throw new Error('getEnvInfo Internal Error') }); + MOCK_CONTEXT.amplify.getProjectConfig.mockImplementation(() => { throw new Error('getProjectConfig Internal Error') }); + MOCK_CONTEXT.amplify.getResourceStatus.mockImplementation(() => { throw new Error('getResourceStatus Internal Error') }); + MOCK_CONTEXT.amplify.executeProviderUtils.mockImplementation(() => { throw new Error('executeProviderUtils Internal Error') }); + MOCK_CONTEXT.parameters.options = { target: 'javascript', 'model-schema': path.join(MOCK_PROJECT_ROOT, 'schema.graphql') }; + + await expect(() => generateModels(MOCK_CONTEXT)) + .rejects + .toThrowErrorMatchingInlineSnapshot('"Output directory could not be determined, to specify, set the --output-dir CLI property."'); + }); + + it('should use default directive definitions if getTransformerDirectives fails', async () => { + MOCK_CONTEXT.amplify.executeProviderUtils.mockRejectedValue('no amplify project'); + const frontend = 'javascript'; + // mock the input and output file structure + const schemaFilePath = path.join(MOCK_BACKEND_DIRECTORY, 'api', MOCK_PROJECT_NAME); + const outputDirectory = path.join(MOCK_PROJECT_ROOT, OUTPUT_PATHS[frontend]); + const mockedFiles = {}; + mockedFiles[schemaFilePath] = { + 'schema.graphql': ' type SimpleModel @model { id: ID! status: String } ', + }; + mockedFiles[outputDirectory] = {}; + mockFs(mockedFiles); + MOCK_CONTEXT.amplify.getProjectConfig.mockReturnValue({ frontend: frontend }); + + // assert empty folder before generation + expect(fs.readdirSync(outputDirectory).length).toEqual(0); + + await generateModels(MOCK_CONTEXT); + + // assert model files are generated in expected output directory + expect(fs.readdirSync(outputDirectory)).toMatchSnapshot(); + }); + afterEach(mockFs.restore); }); @@ -158,117 +308,6 @@ function addMocksToContext() { }, ], }); - MOCK_CONTEXT.amplify.executeProviderUtils.mockReturnValue(directives); + MOCK_CONTEXT.amplify.executeProviderUtils.mockReturnValue(defaultDirectiveDefinitions); MOCK_CONTEXT.amplify.pathManager.getBackendDirPath.mockReturnValue(MOCK_BACKEND_DIRECTORY); } - -const directives = ` -directive @aws_subscribe(mutations: [String!]!) on FIELD_DEFINITION - -directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION - -directive @aws_api_key on FIELD_DEFINITION | OBJECT - -directive @aws_iam on FIELD_DEFINITION | OBJECT - -directive @aws_oidc on FIELD_DEFINITION | OBJECT - -directive @aws_cognito_user_pools(cognito_groups: [String!]) on FIELD_DEFINITION | OBJECT - -directive @aws_lambda on FIELD_DEFINITION | OBJECT - -directive @deprecated(reason: String) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ENUM | ENUM_VALUE - -directive @model(queries: ModelQueryMap, mutations: ModelMutationMap, subscriptions: ModelSubscriptionMap, timestamps: TimestampConfiguration) on OBJECT -input ModelMutationMap { - create: String - update: String - delete: String -} -input ModelQueryMap { - get: String - list: String -} -input ModelSubscriptionMap { - onCreate: [String] - onUpdate: [String] - onDelete: [String] - level: ModelSubscriptionLevel -} -enum ModelSubscriptionLevel { - off - public - on -} -input TimestampConfiguration { - createdAt: String - updatedAt: String -} -directive @function(name: String!, region: String, accountId: String) repeatable on FIELD_DEFINITION -directive @http(method: HttpMethod = GET, url: String!, headers: [HttpHeader] = []) on FIELD_DEFINITION -enum HttpMethod { - GET - POST - PUT - DELETE - PATCH -} -input HttpHeader { - key: String - value: String -} -directive @predictions(actions: [PredictionsActions!]!) on FIELD_DEFINITION -enum PredictionsActions { - identifyText - identifyLabels - convertTextToSpeech - translateText -} -directive @primaryKey(sortKeyFields: [String]) on FIELD_DEFINITION -directive @index(name: String, sortKeyFields: [String], queryField: String) repeatable on FIELD_DEFINITION -directive @hasMany(indexName: String, fields: [String!], limit: Int = 100) on FIELD_DEFINITION -directive @hasOne(fields: [String!]) on FIELD_DEFINITION -directive @manyToMany(relationName: String!, limit: Int = 100) on FIELD_DEFINITION -directive @belongsTo(fields: [String!]) on FIELD_DEFINITION -directive @default(value: String!) on FIELD_DEFINITION -directive @auth(rules: [AuthRule!]!) on OBJECT | FIELD_DEFINITION -input AuthRule { - allow: AuthStrategy! - provider: AuthProvider - identityClaim: String - groupClaim: String - ownerField: String - groupsField: String - groups: [String] - operations: [ModelOperation] -} -enum AuthStrategy { - owner - groups - private - public - custom -} -enum AuthProvider { - apiKey - iam - oidc - userPools - function -} -enum ModelOperation { - create - update - delete - read - list - get - sync - listen - search -} -directive @mapsTo(name: String!) on OBJECT -directive @searchable(queries: SearchableQueryMap) on OBJECT -input SearchableQueryMap { - search: String -}`; diff --git a/packages/amplify-codegen/tests/utils/getModelSchemaPathParam.test.js b/packages/amplify-codegen/tests/utils/getModelSchemaPathParam.test.js new file mode 100644 index 000000000..327fe1095 --- /dev/null +++ b/packages/amplify-codegen/tests/utils/getModelSchemaPathParam.test.js @@ -0,0 +1,39 @@ +const { getModelSchemaPathParam, hasModelSchemaPathParam } = require('../../src/utils/getModelSchemaPathParam'); +const path = require('path'); + +const PROJECT_PATH = path.join(__dirname, 'project'); + +const createContextWithOptions = (options) => ({ + amplify: { + getEnvInfo: () => ({ + projectPath: PROJECT_PATH + }), + }, + ...(options ? { parameters: { options } } : {}) +}); + +describe('getModelSchemaPathParam', () => { + it('should return null when no flag is set', () => { + expect(getModelSchemaPathParam(createContextWithOptions(null))).toBeNull(); + }); + + it('should return for relative path', () => { + const context = createContextWithOptions({ 'model-schema': path.join('src', 'models') }); + expect(getModelSchemaPathParam(context)).toEqual(path.join(PROJECT_PATH, 'src', 'models')); + }); + + it('should return for absolute path', () => { + const context = createContextWithOptions({ 'model-schema': path.join(PROJECT_PATH, 'src', 'models') }); + expect(getModelSchemaPathParam(context)).toEqual(path.join(PROJECT_PATH, 'src', 'models')); + }); +}); + +describe('hasModelSchemaPathParam', () => { + it('returns true when a path is set', () => { + expect(hasModelSchemaPathParam(createContextWithOptions({ 'model-schema': 'path' }))).toEqual(true); + }); + + it('returns false when no path is set', () => { + expect(hasModelSchemaPathParam(createContextWithOptions(null))).toEqual(false); + }); +}); diff --git a/packages/amplify-codegen/tests/utils/getProjectRoot.test.js b/packages/amplify-codegen/tests/utils/getProjectRoot.test.js new file mode 100644 index 000000000..885fe64e1 --- /dev/null +++ b/packages/amplify-codegen/tests/utils/getProjectRoot.test.js @@ -0,0 +1,25 @@ +const getProjectRoot = require('../../src/utils/getProjectRoot'); +const path = require('path'); +const process = require('process'); + +const PROJECT_PATH = path.join(__dirname, 'project'); + +describe('getProjectRoot', () => { + it('returns project path from context when it returns', () => { + expect(getProjectRoot(({ + amplify: { + getEnvInfo: () => ({ projectPath: PROJECT_PATH }), + }, + }))).toEqual(PROJECT_PATH); + }); + + it('returns os.cwd when context throws', () => { + expect(getProjectRoot(({ + amplify: { + getEnvInfo: () => { + throw new Error(); + }, + }, + }))).toEqual(process.cwd()); + }); +}); diff --git a/packages/appsync-modelgen-plugin/src/__tests__/visitors/__snapshots__/appsync-java-visitor.test.ts.snap b/packages/appsync-modelgen-plugin/src/__tests__/visitors/__snapshots__/appsync-java-visitor.test.ts.snap index f4a9a0cf8..f27aab9b0 100644 --- a/packages/appsync-modelgen-plugin/src/__tests__/visitors/__snapshots__/appsync-java-visitor.test.ts.snap +++ b/packages/appsync-modelgen-plugin/src/__tests__/visitors/__snapshots__/appsync-java-visitor.test.ts.snap @@ -7494,6 +7494,206 @@ public final class Todo implements Model { " `; +exports[`AppSyncModelVisitor should avoid name collision on builder step 1`] = ` +"package com.amplifyframework.datastore.generated.model; + +import com.amplifyframework.core.model.temporal.Temporal; + +import java.util.List; +import java.util.UUID; +import java.util.Objects; + +import androidx.core.util.ObjectsCompat; + +import com.amplifyframework.core.model.Model; +import com.amplifyframework.core.model.annotations.Index; +import com.amplifyframework.core.model.annotations.ModelConfig; +import com.amplifyframework.core.model.annotations.ModelField; +import com.amplifyframework.core.model.query.predicate.QueryField; + +import static com.amplifyframework.core.model.query.predicate.QueryField.field; + +/** This is an auto generated class representing the MyObject type in your schema. */ +@SuppressWarnings(\\"all\\") +@ModelConfig(pluralName = \\"MyObjects\\") +public final class MyObject implements Model { + public static final QueryField ID = field(\\"MyObject\\", \\"id\\"); + public static final QueryField TUTORIAL = field(\\"MyObject\\", \\"tutorial\\"); + public static final QueryField FORM_CUES = field(\\"MyObject\\", \\"formCues\\"); + private final @ModelField(targetType=\\"ID\\", isRequired = true) String id; + private final @ModelField(targetType=\\"TutorialStep\\", isRequired = true) List tutorial; + private final @ModelField(targetType=\\"FormCue\\", isRequired = true) List formCues; + private @ModelField(targetType=\\"AWSDateTime\\", isReadOnly = true) Temporal.DateTime createdAt; + private @ModelField(targetType=\\"AWSDateTime\\", isReadOnly = true) Temporal.DateTime updatedAt; + public String getId() { + return id; + } + + public List getTutorial() { + return tutorial; + } + + public List getFormCues() { + return formCues; + } + + public Temporal.DateTime getCreatedAt() { + return createdAt; + } + + public Temporal.DateTime getUpdatedAt() { + return updatedAt; + } + + private MyObject(String id, List tutorial, List formCues) { + this.id = id; + this.tutorial = tutorial; + this.formCues = formCues; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if(obj == null || getClass() != obj.getClass()) { + return false; + } else { + MyObject myObject = (MyObject) obj; + return ObjectsCompat.equals(getId(), myObject.getId()) && + ObjectsCompat.equals(getTutorial(), myObject.getTutorial()) && + ObjectsCompat.equals(getFormCues(), myObject.getFormCues()) && + ObjectsCompat.equals(getCreatedAt(), myObject.getCreatedAt()) && + ObjectsCompat.equals(getUpdatedAt(), myObject.getUpdatedAt()); + } + } + + @Override + public int hashCode() { + return new StringBuilder() + .append(getId()) + .append(getTutorial()) + .append(getFormCues()) + .append(getCreatedAt()) + .append(getUpdatedAt()) + .toString() + .hashCode(); + } + + @Override + public String toString() { + return new StringBuilder() + .append(\\"MyObject {\\") + .append(\\"id=\\" + String.valueOf(getId()) + \\", \\") + .append(\\"tutorial=\\" + String.valueOf(getTutorial()) + \\", \\") + .append(\\"formCues=\\" + String.valueOf(getFormCues()) + \\", \\") + .append(\\"createdAt=\\" + String.valueOf(getCreatedAt()) + \\", \\") + .append(\\"updatedAt=\\" + String.valueOf(getUpdatedAt())) + .append(\\"}\\") + .toString(); + } + + public static TutorialBuildStep builder() { + return new Builder(); + } + + /** + * WARNING: This method should not be used to build an instance of this object for a CREATE mutation. + * This is a convenience method to return an instance of the object with only its ID populated + * to be used in the context of a parameter in a delete mutation or referencing a foreign key + * in a relationship. + * @param id the id of the existing item this instance will represent + * @return an instance of this model with only ID populated + */ + public static MyObject justId(String id) { + return new MyObject( + id, + null, + null + ); + } + + public CopyOfBuilder copyOfBuilder() { + return new CopyOfBuilder(id, + tutorial, + formCues); + } + public interface TutorialBuildStep { + FormCuesStep tutorial(List tutorial); + } + + + public interface FormCuesStep { + BuildStep formCues(List formCues); + } + + + public interface BuildStep { + MyObject build(); + BuildStep id(String id); + } + + + public static class Builder implements TutorialBuildStep, FormCuesStep, BuildStep { + private String id; + private List tutorial; + private List formCues; + @Override + public MyObject build() { + String id = this.id != null ? this.id : UUID.randomUUID().toString(); + + return new MyObject( + id, + tutorial, + formCues); + } + + @Override + public FormCuesStep tutorial(List tutorial) { + Objects.requireNonNull(tutorial); + this.tutorial = tutorial; + return this; + } + + @Override + public BuildStep formCues(List formCues) { + Objects.requireNonNull(formCues); + this.formCues = formCues; + return this; + } + + /** + * @param id id + * @return Current Builder instance, for fluent method chaining + */ + public BuildStep id(String id) { + this.id = id; + return this; + } + } + + + public final class CopyOfBuilder extends Builder { + private CopyOfBuilder(String id, List tutorial, List formCues) { + super.id(id); + super.tutorial(tutorial) + .formCues(formCues); + } + + @Override + public CopyOfBuilder tutorial(List tutorial) { + return (CopyOfBuilder) super.tutorial(tutorial); + } + + @Override + public CopyOfBuilder formCues(List formCues) { + return (CopyOfBuilder) super.formCues(formCues); + } + } + +} +" +`; + exports[`AppSyncModelVisitor should generate Temporal type for AWSDate* scalars 1`] = ` "package com.amplifyframework.datastore.generated.model; diff --git a/packages/appsync-modelgen-plugin/src/__tests__/visitors/appsync-java-visitor.test.ts b/packages/appsync-modelgen-plugin/src/__tests__/visitors/appsync-java-visitor.test.ts index c9959a159..cf748940f 100644 --- a/packages/appsync-modelgen-plugin/src/__tests__/visitors/appsync-java-visitor.test.ts +++ b/packages/appsync-modelgen-plugin/src/__tests__/visitors/appsync-java-visitor.test.ts @@ -11,17 +11,12 @@ const defaultJavaVisitorSettings = { transformerVersion: 1, generate: CodeGenGenerateEnum.code, respectPrimaryKeyAttributesOnConnectionField: false, -} +}; const buildSchemaWithDirectives = (schema: String): GraphQLSchema => { return buildSchema([schema, directives, scalars].join('\n')); }; - -const getVisitor = ( - schema: string, - selectedType?: string, - settings: any = {} -) => { +const getVisitor = (schema: string, selectedType?: string, settings: any = {}) => { const visitorConfig = { ...defaultJavaVisitorSettings, ...settings }; const ast = parse(schema); const builtSchema = buildSchemaWithDirectives(schema); @@ -31,7 +26,7 @@ const getVisitor = ( directives, target: 'java', scalars: JAVA_SCALAR_MAP, - ...visitorConfig + ...visitorConfig, }, { selectedType }, ); @@ -39,11 +34,7 @@ const getVisitor = ( return visitor; }; -const getVisitorPipelinedTransformer = ( - schema: string, - selectedType?: string, - settings: any = {} -) => { +const getVisitorPipelinedTransformer = (schema: string, selectedType?: string, settings: any = {}) => { return getVisitor(schema, selectedType, { ...settings, transformerVersion: 2 }); }; @@ -215,6 +206,30 @@ describe('AppSyncModelVisitor', () => { expect(generatedCode).toMatchSnapshot(); }); + it('should avoid name collision on builder step', () => { + const schema = /* GraphQL */ ` + type TutorialStep { + position: Int! + text: String! + } + + type FormCue { + position: Int! + text: String! + } + + type MyObject @model { + id: ID! + tutorial: [TutorialStep!]! + formCues: [FormCue!]! + } + `; + const visitor = getVisitor(schema, 'MyObject'); + const generatedCode = visitor.generate(); + expect(() => validateJava(generatedCode)).not.toThrow(); + expect(generatedCode).toMatchSnapshot(); + }); + describe('vNext transformer feature parity tests', () => { it('should produce the same result for @primaryKey as the primary key variant of @key', async () => { const schemaV1 = /* GraphQL */ ` @@ -596,7 +611,7 @@ describe('AppSyncModelVisitor', () => { content: String tags: [Tag] @manyToMany(relationName: "PostTags") } - + type Tag @model { id: ID! label: String! @@ -614,17 +629,17 @@ describe('AppSyncModelVisitor', () => { type Blog @model { id: ID! name: String! - blogOwner: BlogOwnerWithCustomPKS!@belongsTo + blogOwner: BlogOwnerWithCustomPKS! @belongsTo posts: [Post] @hasMany } - + type BlogOwnerWithCustomPKS @model { id: ID! - name: String!@primaryKey(sortKeyFields: ["wea"]) + name: String! @primaryKey(sortKeyFields: ["wea"]) wea: String! blogs: [Blog] @hasMany } - + type Post @model { postId: ID! @primaryKey title: String! @@ -637,32 +652,40 @@ describe('AppSyncModelVisitor', () => { type Comment @model { post: Post @belongsTo - title: String! @primaryKey(sortKeyFields: ["content","likes"]) + title: String! @primaryKey(sortKeyFields: ["content", "likes"]) content: String! likes: Int! description: String } `; it('Should generate correct model file for default id as primary key type', () => { - const generatedCode = getVisitorPipelinedTransformer(schema, `Blog`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCode = getVisitorPipelinedTransformer(schema, `Blog`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(() => validateJava(generatedCode)).not.toThrow(); expect(generatedCode).toMatchSnapshot(); }); it('Should generate correct model file for custom primary key type', () => { - const generatedCode = getVisitorPipelinedTransformer(schema, `Post`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCode = getVisitorPipelinedTransformer(schema, `Post`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(() => validateJava(generatedCode)).not.toThrow(); expect(generatedCode).toMatchSnapshot(); }); it('Should generate correct model file for composite key type without id field defined', () => { - const generatedCode = getVisitorPipelinedTransformer(schema, `Comment`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCode = getVisitorPipelinedTransformer(schema, `Comment`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(generatedCode).toMatchSnapshot(); }); it('Should generate correct model file for composite key type with id field defined', () => { - const generatedCode = getVisitorPipelinedTransformer(schema, `BlogOwnerWithCustomPKS`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCode = getVisitorPipelinedTransformer(schema, `BlogOwnerWithCustomPKS`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(generatedCode).toMatchSnapshot(); }); }); - + describe('Custom primary key for connected model tests', () => { it('Should generate correct model file for hasOne & belongsTo relation with composite primary key when CPK is enabled', () => { const schema = /* GraphQL */ ` @@ -678,8 +701,12 @@ describe('AppSyncModelVisitor', () => { project: Project @belongsTo } `; - const generatedCodeProject = getVisitorPipelinedTransformer(schema, `Project`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); - const generatedCodeTeam = getVisitorPipelinedTransformer(schema, `Team`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCodeProject = getVisitorPipelinedTransformer(schema, `Project`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); + const generatedCodeTeam = getVisitorPipelinedTransformer(schema, `Team`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(generatedCodeProject).toMatchSnapshot(); expect(generatedCodeTeam).toMatchSnapshot(); }); @@ -690,14 +717,18 @@ describe('AppSyncModelVisitor', () => { title: String! comments: [Comment] @hasMany } - + type Comment @model { id: ID! @primaryKey(sortKeyFields: ["content"]) content: String! } `; - const generatedCodePost = getVisitorPipelinedTransformer(schema, 'Post', { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); - const generatedCodeComment = getVisitorPipelinedTransformer(schema, 'Comment', { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCodePost = getVisitorPipelinedTransformer(schema, 'Post', { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); + const generatedCodeComment = getVisitorPipelinedTransformer(schema, 'Comment', { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(generatedCodePost).toMatchSnapshot(); expect(generatedCodeComment).toMatchSnapshot(); }); @@ -713,7 +744,9 @@ describe('AppSyncModelVisitor', () => { rating: Float! } `; - const generatedCodeMyPost = getVisitorPipelinedTransformer(schema, `MyPost`, { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); + const generatedCodeMyPost = getVisitorPipelinedTransformer(schema, `MyPost`, { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); expect(generatedCodeMyPost).toMatchSnapshot(); }); it('Should generate ModelIdentifier factory with resolveIdentifier returning Java types matching graphql scalar conversion', () => { @@ -725,15 +758,21 @@ describe('AppSyncModelVisitor', () => { type IdModel @model { customKey: ID! @primaryKey } - + type IntModel @model { customKey: Int! @primaryKey } `; - const generatedCodeStringModel= getVisitorPipelinedTransformer(schema, 'StringModel', { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); - const generatedCodeIdModel = getVisitorPipelinedTransformer(schema, 'IdModel', { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); - const generatedCodeIntModel = getVisitorPipelinedTransformer(schema, 'IntModel', { respectPrimaryKeyAttributesOnConnectionField: true }).generate(); - + const generatedCodeStringModel = getVisitorPipelinedTransformer(schema, 'StringModel', { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); + const generatedCodeIdModel = getVisitorPipelinedTransformer(schema, 'IdModel', { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); + const generatedCodeIntModel = getVisitorPipelinedTransformer(schema, 'IntModel', { + respectPrimaryKeyAttributesOnConnectionField: true, + }).generate(); + expect(generatedCodeStringModel).toMatchSnapshot(); expect(generatedCodeIdModel).toMatchSnapshot(); expect(generatedCodeIntModel).toMatchSnapshot(); diff --git a/packages/appsync-modelgen-plugin/src/visitors/appsync-java-visitor.ts b/packages/appsync-modelgen-plugin/src/visitors/appsync-java-visitor.ts index d9d5541ee..97f20ce69 100644 --- a/packages/appsync-modelgen-plugin/src/visitors/appsync-java-visitor.ts +++ b/packages/appsync-modelgen-plugin/src/visitors/appsync-java-visitor.ts @@ -13,7 +13,14 @@ import { } from '../configs/java-config'; import { JAVA_TYPE_IMPORT_MAP } from '../scalars'; import { JavaDeclarationBlock } from '../languages/java-declaration-block'; -import { AppSyncModelVisitor, CodeGenField, CodeGenModel, CodeGenPrimaryKeyType, ParsedAppSyncModelConfig, RawAppSyncModelConfig } from './appsync-visitor'; +import { + AppSyncModelVisitor, + CodeGenField, + CodeGenModel, + CodeGenPrimaryKeyType, + ParsedAppSyncModelConfig, + RawAppSyncModelConfig, +} from './appsync-visitor'; import { CodeGenConnectionType } from '../utils/process-connections'; import { AuthDirective, AuthStrategy } from '../utils/process-auth'; import { printWarning } from '../utils/warn'; @@ -31,10 +38,7 @@ export class AppSyncModelJavaVisitor< const shouldUseModelNameFieldInHasManyAndBelongsTo = true; // This flag is going to be used to tight-trigger on JS implementations only. const shouldImputeKeyForUniDirectionalHasMany = false; - this.processDirectives( - shouldUseModelNameFieldInHasManyAndBelongsTo, - shouldImputeKeyForUniDirectionalHasMany - ); + this.processDirectives(shouldUseModelNameFieldInHasManyAndBelongsTo, shouldImputeKeyForUniDirectionalHasMany); if (this._parsedConfig.generate === 'loader') { return this.generateClassLoader(); @@ -377,7 +381,13 @@ export class AppSyncModelJavaVisitor< * @param classDeclaration */ protected generateIdentifierClassField(model: CodeGenModel, classDeclaration: JavaDeclarationBlock): void { - classDeclaration.addClassMember(this.getModelIdentifierClassFieldName(model), this.getModelIdentifierClassName(model), '', undefined, 'private'); + classDeclaration.addClassMember( + this.getModelIdentifierClassFieldName(model), + this.getModelIdentifierClassName(model), + '', + undefined, + 'private', + ); } /** * Generate step builder interfaces for each non-null field in the model @@ -386,11 +396,15 @@ export class AppSyncModelJavaVisitor< protected generateStepBuilderInterfaces(model: CodeGenModel, isIdAsModelPrimaryKey: boolean = true): JavaDeclarationBlock[] { const nonNullableFields = this.getWritableFields(model).filter(field => this.isRequiredField(field)); const nullableFields = this.getWritableFields(model).filter(field => !this.isRequiredField(field)); - const requiredInterfaces = nonNullableFields.filter((field: CodeGenField) => !(isIdAsModelPrimaryKey && this.READ_ONLY_FIELDS.includes(field.name))); + const requiredInterfaces = nonNullableFields.filter( + (field: CodeGenField) => !(isIdAsModelPrimaryKey && this.READ_ONLY_FIELDS.includes(field.name)), + ); + const types = this.getTypesUsedByModel(model); + const interfaces = requiredInterfaces.map((field, idx) => { const isLastField = requiredInterfaces.length - 1 === idx ? true : false; const returnType = isLastField ? 'Build' : requiredInterfaces[idx + 1].name; - const interfaceName = this.getStepInterfaceName(field.name); + const interfaceName = this.getStepInterfaceName(field.name, types); const methodName = this.getStepFunctionName(field); const argumentType = this.getNativeType(field); const argumentName = this.getStepFunctionArgumentName(field); @@ -398,14 +412,16 @@ export class AppSyncModelJavaVisitor< .asKind('interface') .withName(interfaceName) .access('public'); - interfaceDeclaration.withBlock(indent(`${this.getStepInterfaceName(returnType)} ${methodName}(${argumentType} ${argumentName});`)); + interfaceDeclaration.withBlock( + indent(`${this.getStepInterfaceName(returnType, types)} ${methodName}(${argumentType} ${argumentName});`), + ); return interfaceDeclaration; }); // Builder const builder = new JavaDeclarationBlock() .asKind('interface') - .withName(this.getStepInterfaceName('Build')) + .withName(this.getStepInterfaceName('Build', types)) .access('public'); const builderBody = []; // build method @@ -413,13 +429,13 @@ export class AppSyncModelJavaVisitor< if (isIdAsModelPrimaryKey) { // id method. Special case as this can throw exception - builderBody.push(`${this.getStepInterfaceName('Build')} id(String id);`); + builderBody.push(`${this.getStepInterfaceName('Build', types)} id(String id);`); } nullableFields.forEach(field => { const fieldName = this.getStepFunctionArgumentName(field); const methodName = this.getStepFunctionName(field); - builderBody.push(`${this.getStepInterfaceName('Build')} ${methodName}(${this.getNativeType(field)} ${fieldName});`); + builderBody.push(`${this.getStepInterfaceName('Build', types)} ${methodName}(${this.getNativeType(field)} ${fieldName});`); }); builder.withBlock(indentMultiline(builderBody.join('\n'))); @@ -434,15 +450,18 @@ export class AppSyncModelJavaVisitor< protected generateBuilderClass(model: CodeGenModel, classDeclaration: JavaDeclarationBlock, isIdAsModelPrimaryKey: boolean = true): void { const nonNullableFields = this.getWritableFields(model).filter(field => this.isRequiredField(field)); const nullableFields = this.getWritableFields(model).filter(field => !this.isRequiredField(field)); - const stepFields = nonNullableFields.filter((field: CodeGenField) => !(isIdAsModelPrimaryKey && this.READ_ONLY_FIELDS.includes(field.name))); - const stepInterfaces = stepFields.map((field: CodeGenField) => this.getStepInterfaceName(field.name)); + const stepFields = nonNullableFields.filter( + (field: CodeGenField) => !(isIdAsModelPrimaryKey && this.READ_ONLY_FIELDS.includes(field.name)), + ); + const types = this.getTypesUsedByModel(model); + const stepInterfaces = stepFields.map((field: CodeGenField) => this.getStepInterfaceName(field.name, types)); const builderClassDeclaration = new JavaDeclarationBlock() .access('public') .static() .asKind('class') .withName('Builder') - .implements([...stepInterfaces, this.getStepInterfaceName('Build')]); + .implements([...stepInterfaces, this.getStepInterfaceName('Build', types)]); // Add private instance fields [...nonNullableFields, ...nullableFields].forEach((field: CodeGenField) => { @@ -452,7 +471,9 @@ export class AppSyncModelJavaVisitor< // methods // build(); - const buildImplementation = isIdAsModelPrimaryKey ? [`String id = this.id != null ? this.id : UUID.randomUUID().toString();`, ''] : ['']; + const buildImplementation = isIdAsModelPrimaryKey + ? [`String id = this.id != null ? this.id : UUID.randomUUID().toString();`, ''] + : ['']; const buildParams = this.getWritableFields(model) .map(field => this.getFieldName(field)) .join(',\n'); @@ -473,7 +494,7 @@ export class AppSyncModelJavaVisitor< const isLastStep = idx === fields.length - 1; const fieldName = this.getFieldName(field); const methodName = this.getStepFunctionName(field); - const returnType = isLastStep ? this.getStepInterfaceName('Build') : this.getStepInterfaceName(fields[idx + 1].name); + const returnType = isLastStep ? this.getStepInterfaceName('Build', types) : this.getStepInterfaceName(fields[idx + 1].name, types); const argumentType = this.getNativeType(field); const argumentName = this.getStepFunctionArgumentName(field); const body = [`Objects.requireNonNull(${argumentName});`, `this.${fieldName} = ${argumentName};`, `return this;`].join('\n'); @@ -493,7 +514,7 @@ export class AppSyncModelJavaVisitor< nullableFields.forEach((field: CodeGenField) => { const fieldName = this.getFieldName(field); const methodName = this.getStepFunctionName(field); - const returnType = this.getStepInterfaceName('Build'); + const returnType = this.getStepInterfaceName('Build', types); const argumentType = this.getNativeType(field); const argumentName = this.getStepFunctionArgumentName(field); const body = [`this.${fieldName} = ${argumentName};`, `return this;`].join('\n'); @@ -520,7 +541,7 @@ export class AppSyncModelJavaVisitor< builderClassDeclaration.addClassMethod( 'id', - this.getStepInterfaceName('Build'), + this.getStepInterfaceName('Build', types), indentMultiline(idBuildStepBody), [{ name: 'id', type: 'String' }], [], @@ -541,7 +562,11 @@ export class AppSyncModelJavaVisitor< * @param model * @param classDeclaration */ - protected generateCopyOfBuilderClass(model: CodeGenModel, classDeclaration: JavaDeclarationBlock, isIdAsModelPrimaryKey: boolean = true): void { + protected generateCopyOfBuilderClass( + model: CodeGenModel, + classDeclaration: JavaDeclarationBlock, + isIdAsModelPrimaryKey: boolean = true, + ): void { const builderName = 'CopyOfBuilder'; const copyOfBuilderClassDeclaration = new JavaDeclarationBlock() .access('public') @@ -616,11 +641,11 @@ export class AppSyncModelJavaVisitor< primaryKeyClassDeclaration.addClassMember('serialVersionUID', 'long', '1L', [], 'private', { static: true, final: true }); // constructor const primaryKeyComponentFields: CodeGenField[] = [primaryKeyField, ...sortKeyFields]; - const constructorParams = primaryKeyComponentFields.map(field => ({name: this.getFieldName(field), type: this.getNativeType(field)})); + const constructorParams = primaryKeyComponentFields.map(field => ({ name: this.getFieldName(field), type: this.getNativeType(field) })); const constructorImpl = `super(${primaryKeyComponentFields.map(field => this.getFieldName(field)).join(', ')});`; primaryKeyClassDeclaration.addClassMethod(modelPrimaryKeyClassName, null, constructorImpl, constructorParams, [], 'public'); classDeclaration.nestedClass(primaryKeyClassDeclaration); -} + } /** * adds a copyOfBuilder method to the Model class. This method is used to create a copy of the model to mutate it @@ -647,12 +672,27 @@ export class AppSyncModelJavaVisitor< const body = isCompositeKey ? [ `if (${modelIdentifierClassFieldName} == null) {`, - indent(`this.${modelIdentifierClassFieldName} = new ${this.getModelIdentifierClassName(model)}(${[primaryKeyField, ...sortKeyFields].map(f => this.getFieldName(f)).join(', ')});`), + indent( + `this.${modelIdentifierClassFieldName} = new ${this.getModelIdentifierClassName(model)}(${[primaryKeyField, ...sortKeyFields] + .map(f => this.getFieldName(f)) + .join(', ')});`, + ), '}', - `return ${modelIdentifierClassFieldName};` + `return ${modelIdentifierClassFieldName};`, ].join('\n') : `return ${this.getFieldName(primaryKeyField)};`; - declarationsBlock.addClassMethod('resolveIdentifier', returnType, body, [], [], 'public', {}, ["Deprecated"], [], "@deprecated This API is internal to Amplify and should not be used."); + declarationsBlock.addClassMethod( + 'resolveIdentifier', + returnType, + body, + [], + [], + 'public', + {}, + ['Deprecated'], + [], + '@deprecated This API is internal to Amplify and should not be used.', + ); } /** @@ -823,11 +863,18 @@ export class AppSyncModelJavaVisitor< * @param model * @param classDeclaration */ - protected generateBuilderMethod(model: CodeGenModel, classDeclaration: JavaDeclarationBlock, isIdAsModelPrimaryKey: boolean = true): void { + protected generateBuilderMethod( + model: CodeGenModel, + classDeclaration: JavaDeclarationBlock, + isIdAsModelPrimaryKey: boolean = true, + ): void { const requiredFields = this.getWritableFields(model).filter( field => !field.isNullable && !(isIdAsModelPrimaryKey && this.READ_ONLY_FIELDS.includes(field.name)), ); - const returnType = requiredFields.length ? this.getStepInterfaceName(requiredFields[0].name) : this.getStepInterfaceName('Build'); + const types = this.getTypesUsedByModel(model); + const returnType = requiredFields.length + ? this.getStepInterfaceName(requiredFields[0].name, types) + : this.getStepInterfaceName('Build', types); classDeclaration.addClassMethod( 'builder', returnType, @@ -843,10 +890,27 @@ export class AppSyncModelJavaVisitor< /** * Generate the name of the step builder interface * @param nextFieldName: string + * @param types: string - set of types for all fields on model * @returns string */ - private getStepInterfaceName(nextFieldName: string): string { - return `${pascalCase(nextFieldName)}Step`; + private getStepInterfaceName(nextFieldName: string, types: Set): string { + const pascalCaseFieldName = pascalCase(nextFieldName); + const stepInterfaceName = `${pascalCaseFieldName}Step`; + + if (types.has(stepInterfaceName)) { + return `${pascalCaseFieldName}BuildStep`; + } + + return stepInterfaceName; + } + + /** + * Get set of all types used by a model + * @param model + * @return Set + */ + private getTypesUsedByModel(model: CodeGenModel): Set { + return new Set(model.fields.map(field => field.type)); } protected generateModelAnnotations(model: CodeGenModel): string[] {