diff --git a/amplify-migration-apps/product-catalog/Query.listProducts.postDataLoad.1.res.vtl b/amplify-migration-apps/product-catalog/Query.listProducts.postDataLoad.1.res.vtl new file mode 100644 index 00000000000..fa9ee985c6e --- /dev/null +++ b/amplify-migration-apps/product-catalog/Query.listProducts.postDataLoad.1.res.vtl @@ -0,0 +1,14 @@ +#set($items = $ctx.prev.result.items) +#foreach($item in $items) + #set($stock = 0) + #if($item.stock) + #set($stock = $item.stock) + #end + #set($price = 0) + #if($item.price) + #set($price = $item.price) + #end + #set($total = $price * $stock) + $util.qr($item.put("totalValue", $total)) +#end +$util.toJson($ctx.prev.result) diff --git a/amplify-migration-apps/product-catalog/Query.listProducts.res.vtl b/amplify-migration-apps/product-catalog/Query.listProducts.res.vtl new file mode 100644 index 00000000000..d1cbb2ec701 --- /dev/null +++ b/amplify-migration-apps/product-catalog/Query.listProducts.res.vtl @@ -0,0 +1,11 @@ +## Add 10% discount to all products +#foreach($item in $ctx.result.items) + #if($item.price) + #set($discount = $item.price * 0.10) + #set($discountedPrice = $item.price - $discount) + $util.qr($item.put("discountedPrice", $discountedPrice)) + $util.qr($item.put("savings", $discount)) + #end +#end + +$util.toJson($ctx.result) diff --git a/amplify-migration-apps/product-catalog/README.md b/amplify-migration-apps/product-catalog/README.md index 7e42f22d01a..e6fa684409e 100644 --- a/amplify-migration-apps/product-catalog/README.md +++ b/amplify-migration-apps/product-catalog/README.md @@ -344,6 +344,18 @@ On the AppSync AWS Console, locate the ID of Gen1 API, it will be named `product + export async function handler(event) { ``` +```diff +- const crypto = require('@aws-crypto/sha256-js'); +- const { defaultProvider } = require('@aws-sdk/credential-provider-node'); +- const { SignatureV4 } = require('@aws-sdk/signature-v4'); +- const { HttpRequest } = require('@aws-sdk/protocol-http'); +- const Sha256 = crypto.Sha256; ++ import { Sha256 } from '@aws-crypto/sha256-js'; ++ import { defaultProvider } from '@aws-sdk/credential-provider-node'; ++ import { SignatureV4 } from '@aws-sdk/signature-v4'; ++ import { HttpRequest } from '@aws-sdk/protocol-http'; +``` + **Edit in `./src/main.tsx`:** ```diff diff --git a/amplify-migration-apps/product-catalog/configure.sh b/amplify-migration-apps/product-catalog/configure.sh index b4f6510f709..44ae517146f 100755 --- a/amplify-migration-apps/product-catalog/configure.sh +++ b/amplify-migration-apps/product-catalog/configure.sh @@ -9,4 +9,6 @@ cp -f lowstockproducts.js ./amplify/backend/function/lowstockproducts/src/index. cp -f lowstockproducts.package.json ./amplify/backend/function/lowstockproducts/src/package.json cp -f onimageuploaded.js ./amplify/backend/function/${s3_trigger_function_name}/src/index.js cp -f onimageuploaded.package.json ./amplify/backend/function/${s3_trigger_function_name}/src/package.json -cp -f custom-roles.json ./amplify/backend/api/productcatalog/custom-roles.json \ No newline at end of file +cp -f custom-roles.json ./amplify/backend/api/productcatalog/custom-roles.json +cp -f Query.listProducts.res.vtl ./amplify/backend/api/productcatalog/resolvers/Query.listProducts.res.vtl +cp -f Query.listProducts.postDataLoad.1.res.vtl ./amplify/backend/api/productcatalog/resolvers/Query.listProducts.postDataLoad.1.res.vtl diff --git a/amplify-migration-apps/product-catalog/schema.graphql b/amplify-migration-apps/product-catalog/schema.graphql index 68493ba9e31..a77f0d27cd0 100644 --- a/amplify-migration-apps/product-catalog/schema.graphql +++ b/amplify-migration-apps/product-catalog/schema.graphql @@ -4,10 +4,7 @@ enum UserRole { VIEWER } -type User @model @auth(rules: [ - { allow: private, provider: iam }, - { allow: owner, ownerField: "id" } -]) { +type User @model @auth(rules: [{ allow: private, provider: iam }, { allow: owner, ownerField: "id" }]) { id: ID! email: String! name: String! @@ -33,12 +30,10 @@ type Product @model @auth(rules: [{ allow: private, provider: iam }]) { createdAt: AWSDateTime! updatedAt: AWSDateTime! comments: [Comment] @hasMany(indexName: "byProduct", fields: ["id"]) + totalValue: Float } -type Comment @model @auth(rules: [ - { allow: private, provider: iam }, - { allow: owner, ownerField: "authorId" } -]) { +type Comment @model @auth(rules: [{ allow: private, provider: iam }, { allow: owner, ownerField: "authorId" }]) { id: ID! productId: ID! @index(name: "byProduct") authorId: String! @@ -59,8 +54,7 @@ type LowStockResponse { } type Query { - checkLowStock: LowStockResponse @function(name: "lowstockproducts-${env}") @auth(rules: [ - { allow: private, provider: iam }, - { allow: public, provider: apiKey } - ]) + checkLowStock: LowStockResponse + @function(name: "lowstockproducts-${env}") + @auth(rules: [{ allow: private, provider: iam }, { allow: public, provider: apiKey }]) } diff --git a/amplify-migration-apps/product-catalog/src/App.tsx b/amplify-migration-apps/product-catalog/src/App.tsx index c3a0d58606e..c62c0cf2989 100644 --- a/amplify-migration-apps/product-catalog/src/App.tsx +++ b/amplify-migration-apps/product-catalog/src/App.tsx @@ -1208,17 +1208,40 @@ function App({ signOut, user }: AppProps) { {product.brand} )} - {product.stock !== undefined && product.stock !== null && ( + {(product as any).stockStatus && ( 0 ? '#dcfce7' : '#fef3c7', - color: (product.stock || 0) > 0 ? '#166534' : '#92400e', + backgroundColor: + (product as any).stockStatus === 'OUT_OF_STOCK' + ? '#fef2f2' + : (product as any).stockStatus === 'LOW_STOCK' + ? '#fef3c7' + : '#dcfce7', + color: + (product as any).stockStatus === 'OUT_OF_STOCK' + ? '#dc2626' + : (product as any).stockStatus === 'LOW_STOCK' + ? '#92400e' + : '#166534', fontWeight: '600', borderRadius: '6px', padding: '0.25rem 0.75rem', }} > - {(product.stock || 0) > 0 ? `${product.stock} in stock` : 'Out of stock'} + {(product as any).stockStatus.replace('_', ' ')} + + )} + {(product as any).totalValue && ( + + Total: ${(product as any).totalValue} )} diff --git a/amplify-migration-apps/product-catalog/src/graphql/queries.ts b/amplify-migration-apps/product-catalog/src/graphql/queries.ts index 7537a612694..d3f731014ab 100644 --- a/amplify-migration-apps/product-catalog/src/graphql/queries.ts +++ b/amplify-migration-apps/product-catalog/src/graphql/queries.ts @@ -53,8 +53,11 @@ export const listProducts = /* GraphQL */ `query ListProducts( images createdBy updatedBy + discountedPrice + savings createdAt updatedAt + totalValue __typename } nextToken diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/backend/synthesizer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/backend/synthesizer.ts index 61c78629844..e147a5453bf 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/backend/synthesizer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/backend/synthesizer.ts @@ -28,6 +28,7 @@ export interface BackendRenderParameters { importFrom: string; additionalAuthProviders?: AdditionalAuthProvider[]; restApis?: RestApiDefinition[]; + hasResolvers?: boolean; }; auth?: { importFrom: string; @@ -2415,6 +2416,258 @@ export class BackendSynthesizer { }); } + // Override resolver templates from data/resolvers folder + if (renderArgs.data?.hasResolvers) { + // Add required imports for resolver overrides + imports.push(this.createImportStatement([factory.createIdentifier('readdirSync'), factory.createIdentifier('readFileSync')], 'fs')); + imports.push(this.createImportStatement([factory.createIdentifier('join'), factory.createIdentifier('dirname')], 'path')); + imports.push(this.createImportStatement([factory.createIdentifier('fileURLToPath')], 'url')); + + // Generate __dirname equivalent for ES modules + const dirnameStatement = factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + '__dirname', + undefined, + undefined, + factory.createCallExpression(factory.createIdentifier('dirname'), undefined, [ + factory.createCallExpression(factory.createIdentifier('fileURLToPath'), undefined, [ + factory.createPropertyAccessExpression(factory.createIdentifier('import'), factory.createIdentifier('meta.url')), + ]), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ); + nodes.push(dirnameStatement); + + // Get resolvers directory path + const resolversDirStatement = factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'resolversDir', + undefined, + undefined, + factory.createCallExpression(factory.createIdentifier('join'), undefined, [ + factory.createIdentifier('__dirname'), + factory.createStringLiteral('data/resolvers'), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ); + nodes.push(resolversDirStatement); + + // Filter for .res.vtl files + const resolverFilesStatement = factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'resolverFiles', + undefined, + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createCallExpression(factory.createIdentifier('readdirSync'), undefined, [ + factory.createIdentifier('resolversDir'), + ]), + factory.createIdentifier('filter'), + ), + undefined, + [ + factory.createArrowFunction( + undefined, + undefined, + [factory.createParameterDeclaration(undefined, undefined, factory.createIdentifier('f'))], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('f'), factory.createIdentifier('endsWith')), + undefined, + [factory.createStringLiteral('.res.vtl')], + ), + ), + ], + ), + ), + ], + ts.NodeFlags.Const, + ), + ); + nodes.push(resolverFilesStatement); + + // Process each resolver file + const forOfStatement = factory.createForOfStatement( + undefined, + factory.createVariableDeclarationList( + [factory.createVariableDeclaration('file', undefined, undefined, undefined)], + ts.NodeFlags.Const, + ), + factory.createIdentifier('resolverFiles'), + factory.createBlock( + [ + // Extract type and field names from filename (e.g., Query.listProducts.res.vtl) + factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createArrayBindingPattern([ + factory.createBindingElement(undefined, undefined, 'typeName'), + factory.createBindingElement(undefined, undefined, 'fieldName'), + ]), + undefined, + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('file'), factory.createIdentifier('replace')), + undefined, + [factory.createStringLiteral('.res.vtl'), factory.createStringLiteral('')], + ), + factory.createIdentifier('split'), + ), + undefined, + [factory.createStringLiteral('.')], + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + // Build pipeline function ID (e.g., QueryListProductsDataResolverFn) + factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'functionId', + undefined, + undefined, + factory.createTemplateExpression(factory.createTemplateHead(''), [ + factory.createTemplateSpan(factory.createIdentifier('typeName'), factory.createTemplateMiddle('')), + factory.createTemplateSpan( + factory.createBinaryExpression( + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('fieldName'), + factory.createIdentifier('charAt'), + ), + undefined, + [factory.createNumericLiteral('0')], + ), + factory.createIdentifier('toUpperCase'), + ), + undefined, + [], + ), + factory.createToken(ts.SyntaxKind.PlusToken), + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('fieldName'), + factory.createIdentifier('slice'), + ), + undefined, + [factory.createNumericLiteral('1')], + ), + ), + factory.createTemplateTail('DataResolverFn'), + ), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + // Get the pipeline function configuration + factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'pipelineFunction', + undefined, + undefined, + factory.createElementAccessExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('backend.data.resources.cfnResources'), + factory.createIdentifier('cfnFunctionConfigurations'), + ), + factory.createIdentifier('functionId'), + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + // Override the response mapping template if pipeline function exists + factory.createIfStatement( + factory.createIdentifier('pipelineFunction'), + factory.createBlock( + [ + // Read the VTL template content + factory.createVariableStatement( + [], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + 'template', + undefined, + undefined, + factory.createCallExpression(factory.createIdentifier('readFileSync'), undefined, [ + factory.createCallExpression(factory.createIdentifier('join'), undefined, [ + factory.createIdentifier('resolversDir'), + factory.createIdentifier('file'), + ]), + factory.createStringLiteral('utf8'), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + // Clear the S3 template location to use inline template + factory.createExpressionStatement( + factory.createBinaryExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('pipelineFunction'), + factory.createIdentifier('responseMappingTemplateS3Location'), + ), + factory.createToken(ts.SyntaxKind.EqualsToken), + factory.createIdentifier('undefined'), + ), + ), + // Set the inline response mapping template + factory.createExpressionStatement( + factory.createBinaryExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('pipelineFunction'), + factory.createIdentifier('responseMappingTemplate'), + ), + factory.createToken(ts.SyntaxKind.EqualsToken), + factory.createIdentifier('template'), + ), + ), + ], + true, + ), + ), + ], + true, + ), + ); + nodes.push(forOfStatement); + } + // returns backend.ts file return factory.createNodeArray([...imports, newLineIdentifier, ...errors, newLineIdentifier, backendStatement, ...nodes], true); } diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/codegen-head/data_definition_fetcher.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/codegen-head/data_definition_fetcher.ts index e261c0ca2c0..becd04e43c5 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/codegen-head/data_definition_fetcher.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/codegen-head/data_definition_fetcher.ts @@ -4,8 +4,12 @@ import glob from 'glob'; import assert from 'node:assert'; import { DataDefinition } from '../core/migration-pipeline'; -import { AdditionalAuthProvider } from '../generators/data'; +import { AdditionalAuthProvider, getProjectName } from '../generators/data'; import { pathManager } from '@aws-amplify/amplify-cli-core'; +import { BackendEnvironmentResolver } from './backend_environment_selector'; +import { BackendDownloader } from './backend_downloader'; +import { fileOrDirectoryExists } from './directory_exists'; +import { AppSyncClient, GetGraphqlApiCommand } from '@aws-sdk/client-appsync'; // Source - amplify-category-api/packages/amplify-graphql-transformer-core/src/graphql-api.ts interface Gen1AuthConfig { @@ -103,10 +107,10 @@ export interface CorsConfiguration { maxAge?: number; } -import { BackendEnvironmentResolver } from './backend_environment_selector'; -import { BackendDownloader } from './backend_downloader'; -import { fileOrDirectoryExists } from './directory_exists'; -import { AppSyncClient, GetGraphqlApiCommand } from '@aws-sdk/client-appsync'; +// Add locally in the fetcher +export interface ResolverConfig { + hasResolvers: boolean; +} /** * Fetches and processes data definitions from Amplify Gen1 projects for migration to Gen2. @@ -130,6 +134,22 @@ export class DataDefinitionFetcher { */ constructor(private backendEnvironmentResolver: BackendEnvironmentResolver, private ccbFetcher: BackendDownloader) {} + /** + * Checks if GraphQL API has resolvers directory with VTL files + */ + private hasResolvers = (): boolean => { + const rootDir = pathManager.findProjectRoot(); + const projectName = getProjectName(); + + const resolversPath = path.join(rootDir, 'amplify', 'backend', 'api', projectName, 'resolvers'); + + if (!require('fs').existsSync(resolversPath)) return false; + + const files = require('fs').readdirSync(resolversPath); + + return files.some((file: string) => file.endsWith('.vtl')); + }; + /** * Reads and parses a JSON file. * @@ -462,6 +482,10 @@ export class DataDefinitionFetcher { const additionalAuthProviders = apiId ? await this.getAdditionalAuthProvidersFromConsole(apiId) : []; const logging = apiId ? await this.getLoggingConfigFromConsole(apiId) : undefined; + // Handle resolver checking + const hasResolvers = this.hasResolvers(); + const resolvers: ResolverConfig | undefined = hasResolvers ? { hasResolvers: true } : undefined; + return { tableMappings: undefined, schema, @@ -469,6 +493,7 @@ export class DataDefinitionFetcher { additionalAuthProviders: additionalAuthProviders.length > 0 ? additionalAuthProviders : undefined, logging, restApis: restApis.length > 0 ? restApis : undefined, + resolvers, }; } diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/core/migration-pipeline.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/core/migration-pipeline.ts index eb1c24b33c5..c2d0206b5d6 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/core/migration-pipeline.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/core/migration-pipeline.ts @@ -58,8 +58,10 @@ import { } from '../generators/storage'; import { DataDefinition, DataTableMapping, generateDataSource } from '../generators/data/index'; +import { getProjectName } from '../generators/data'; import { DataModelTableAccess } from '../codegen-head/data_model_access_parser'; import { ApiTriggerDetector } from '../adapters/functions/api-trigger-detector'; +import { pathManager } from '@aws-amplify/amplify-cli-core'; import { FunctionDefinition, renderFunctions } from '../generators/functions/index'; import assert from 'assert'; @@ -116,6 +118,29 @@ export interface Gen2RenderingOptions { /** Custom file writer function for testing or alternative output methods */ fileWriter?: (content: string, path: string) => Promise; } +/** + * Copies resolver files from Gen1 to Gen2 structure + */ +const copyResolverFiles = (outputDir: string): Renderer => ({ + render: async () => { + const rootDir = pathManager.findProjectRoot(); + const projectName = getProjectName(); + const resolversPath = path.join(rootDir, 'amplify', 'backend', 'api', projectName, 'resolvers'); + const targetPath = path.join(outputDir, 'amplify', 'data', 'resolvers'); + + if (require('fs').existsSync(resolversPath)) { + const files = require('fs').readdirSync(resolversPath); + const vtlFiles = files.filter((file: string) => file.endsWith('.vtl')); + + for (const file of vtlFiles) { + const srcFile = path.join(resolversPath, file); + const destFile = path.join(targetPath, file); + require('fs').copyFileSync(srcFile, destFile); + } + } + }, +}); + /** * Creates a file writer function for the specified path * @param path - File path to write to @@ -487,6 +512,10 @@ export const createGen2Renderer = ({ // Process data (GraphQL/DynamoDB) configuration - only if table mappings exist for the environment if (data) { renderers.push(new EnsureDirectory(path.join(outputDir, 'amplify', 'data'))); + if (data.resolvers?.hasResolvers) { + renderers.push(new EnsureDirectory(path.join(outputDir, 'amplify', 'data', 'resolvers'))); + renderers.push(copyResolverFiles(outputDir)); + } renderers.push( new TypescriptNodeArrayRenderer( async () => generateDataSource(backendEnvironmentName, data), @@ -497,6 +526,7 @@ export const createGen2Renderer = ({ importFrom: './data/resource', additionalAuthProviders: data.additionalAuthProviders, restApis: data.restApis, + hasResolvers: data.resolvers?.hasResolvers, }; } diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/generators/data/index.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/generators/data/index.ts index 6473d3f0686..e9a4d954833 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/generators/data/index.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/generators/data/index.ts @@ -3,7 +3,8 @@ import { renderResourceTsFile } from '../../resource/resource'; import { AppSyncClient, paginateListGraphqlApis } from '@aws-sdk/client-appsync'; import type { ConstructFactory, AmplifyFunction } from '@aws-amplify/plugin-types'; import type { AuthorizationModes, DataLoggingOptions } from '@aws-amplify/backend-data'; -import { RestApiDefinition } from '../../codegen-head/data_definition_fetcher'; +import { RestApiDefinition, ResolverConfig } from '../../codegen-head/data_definition_fetcher'; + export interface AdditionalAuthProvider { authenticationType: 'API_KEY' | 'AWS_IAM' | 'OPENID_CONNECT' | 'AMAZON_COGNITO_USER_POOLS' | 'AWS_LAMBDA'; userPoolConfig?: { @@ -46,7 +47,7 @@ const extractModelsFromSchema = (schema: string): string[] => { return models; }; -const getProjectName = (): string | undefined => { +export const getProjectName = (): string | undefined => { try { const fs = require('fs'); const path = require('path'); @@ -102,6 +103,8 @@ export type DataDefinition = { logging?: DataLoggingOptions; /* REST API definitions */ restApis?: RestApiDefinition[]; + /* Resolver configuration */ + resolvers?: ResolverConfig; }; /** Key name for the migrated table mappings property in the generated data resource */