From 69171cb6b92ac95ea0d1835d7df3cea4e11e78f2 Mon Sep 17 00:00:00 2001 From: Ian Saultz <52051793+atierian@users.noreply.github.com> Date: Tue, 27 Aug 2024 17:55:00 -0400 Subject: [PATCH] some cleanup and inline docs --- .../src/grapqhl-generation-transformer.ts | 229 +++++++++------- .../src/utils/graphql-json-schema-type.ts | 247 ++++++++++++------ .../graphql-scalar-json-schema-definitions.ts | 2 + 3 files changed, 314 insertions(+), 164 deletions(-) diff --git a/packages/amplify-graphql-generation-transformer/src/grapqhl-generation-transformer.ts b/packages/amplify-graphql-generation-transformer/src/grapqhl-generation-transformer.ts index bed013e08e..da94d05a88 100644 --- a/packages/amplify-graphql-generation-transformer/src/grapqhl-generation-transformer.ts +++ b/packages/amplify-graphql-generation-transformer/src/grapqhl-generation-transformer.ts @@ -6,7 +6,11 @@ import { generateGetArgumentsInput, TransformerResolver, } from '@aws-amplify/graphql-transformer-core'; -import { TransformerContextProvider, TransformerSchemaVisitStepContextProvider } from '@aws-amplify/graphql-transformer-interfaces'; +import { + MappingTemplateProvider, + TransformerContextProvider, + TransformerSchemaVisitStepContextProvider, +} from '@aws-amplify/graphql-transformer-interfaces'; import { DirectiveNode, FieldDefinitionNode, InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode } from 'graphql'; import { HttpResourceIDs, ResolverResourceIDs } from 'graphql-transformer-common'; import { ToolConfig, createResponseTypeTool } from './utils/tools'; @@ -65,47 +69,151 @@ export class GenerationTransformer extends TransformerPluginBase { }; generateResolvers = (ctx: TransformerContextProvider): void => { - // If there are no directives, bail out to prevent creating an empty stack - if (this.directives.length === 0) { - return; - } + if (this.directives.length === 0) return; - for (const directive of this.directives) { + this.directives.forEach((directive) => { const { parent, field } = directive; const fieldName = field.name.value; const parentName = parent.name.value; - // We're doing this here (as opposed to in the field method) to access generated queries - // and input definitions for @model queries. - directive.toolConfig = createResponseTypeTool(field, ctx); - - const capitalizedFieldName = fieldName.charAt(0).toUpperCase() + fieldName.slice(1); - const stackName = `Generation${capitalizedFieldName}BedrockDataSourceStack`; - const stack: cdk.Stack = ctx.stackManager.createStack(stackName); - stack.templateOptions.templateFormatVersion = '2010-09-09'; - stack.templateOptions.description = 'An auto-generated nested stack for the @generation directive.'; + directive.toolConfig = createResponseTypeTool(field, ctx); + const stackName = `Generation${this.capitalizeFirstLetter(fieldName)}BedrockDataSourceStack`; + const stack = this.createStack(ctx, stackName); const resolverResourceId = ResolverResourceIDs.ResolverResourceID(parentName, fieldName); const httpDataSourceId = HttpResourceIDs.HttpDataSourceID(`GenerationBedrockDataSource-${fieldName}`); - const dataSource = createBedrockDataSource(ctx, directive, stack.region, stackName, httpDataSourceId); + const dataSource = this.createBedrockDataSource(ctx, directive, stack.region, stackName, httpDataSourceId); const invokeBedrockFunction = invokeBedrockResolver(directive); - // pipeline resolver - const conversationPipelineResolver = new TransformerResolver( - parentName, - fieldName, - resolverResourceId, - invokeBedrockFunction.req, - invokeBedrockFunction.res, - ['auth'], - [], - dataSource as any, - { name: 'APPSYNC_JS', runtimeVersion: '1.0.0' }, - ); - - ctx.resolvers.addResolver(parentName, fieldName, conversationPipelineResolver); - } + + this.createPipelineResolver(ctx, parentName, fieldName, resolverResourceId, invokeBedrockFunction, dataSource); + }); }; + + private capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Creates a new CDK stack for the Generation transformer. + * @param {TransformerContextProvider} ctx - The transformer context provider. + * @param {string} stackName - The name of the stack to create. + * @returns {cdk.Stack} The created CDK stack. + */ + private createStack(ctx: TransformerContextProvider, stackName: string): cdk.Stack { + const stack = ctx.stackManager.createStack(stackName); + stack.templateOptions.templateFormatVersion = '2010-09-09'; + stack.templateOptions.description = 'An auto-generated nested stack for the @generation directive.'; + return stack; + } + + /** + * Creates a pipeline resolver for the Generation transformer. + * @param {TransformerContextProvider} ctx - The transformer context provider. + * @param {string} parentName - The name of the parent resolver. + * @param {string} fieldName - The name of the field. + * @param {string} resolverResourceId - The ID for the resolver resource. + * @param {MappingTemplateProvider} invokeBedrockFunction - The invoke Bedrock function. + */ + private createPipelineResolver( + ctx: TransformerContextProvider, + parentName: string, + fieldName: string, + resolverResourceId: string, + invokeBedrockFunction: { req: MappingTemplateProvider; res: MappingTemplateProvider }, + dataSource: cdk.aws_appsync.HttpDataSource, + ): void { + const conversationPipelineResolver = new TransformerResolver( + parentName, + fieldName, + resolverResourceId, + invokeBedrockFunction.req, + invokeBedrockFunction.res, + ['auth'], + [], + dataSource as any, + { name: 'APPSYNC_JS', runtimeVersion: '1.0.0' }, + ); + + ctx.resolvers.addResolver(parentName, fieldName, conversationPipelineResolver); + } + + /** + * Creates a Bedrock data source for the Generation transformer. + * @param {TransformerContextProvider} ctx - The transformer context provider. + * @param {GenerationDirectiveConfiguration} directive - The directive configuration. + * @param {string} region - The AWS region for the Bedrock service. + * @param {string} stackName - The name of the stack. + * @param {string} httpDataSourceId - The ID for the HTTP data source. + * @returns {MappingTemplateProvider} The created Bedrock data source. + */ + private createBedrockDataSource( + ctx: TransformerContextProvider, + directive: GenerationDirectiveConfiguration, + region: string, + stackName: string, + httpDataSourceId: string, + ): cdk.aws_appsync.HttpDataSource { + const { + field: { + name: { value: fieldName }, + }, + aiModel, + } = directive; + + const bedrockUrl = `https://bedrock-runtime.${region}.amazonaws.com`; + + const dataSourceScope = ctx.stackManager.getScopeFor(httpDataSourceId, stackName); + const dataSource = ctx.api.host.addHttpDataSource( + httpDataSourceId, + bedrockUrl, + { + authorizationConfig: { + signingRegion: region, + signingServiceName: 'bedrock', + }, + }, + dataSourceScope, + ); + + const roleName = ctx.resourceHelper.generateIAMRoleName(`GenerationBedrockDataSourceRole${fieldName}`); + const role = this.createBedrockDataSourceRole(dataSourceScope, fieldName, roleName, region, aiModel); + dataSource.ds.serviceRoleArn = role.roleArn; + return dataSource; + } + + /** + * Creates an IAM role for the Bedrock service. + * @param {Construct} dataSourceScope - The construct scope for the IAM role. + * @param {string} fieldName - The name of the field. + * @param {string} roleName - The name of the IAM role. + * @param {string} region - The AWS region for the Bedrock service. + * @param {string} bedrockModelId - The ID for the Bedrock model. + * @returns {iam.Role} The created IAM role. + */ + private createBedrockDataSourceRole( + dataSourceScope: Construct, + fieldName: string, + roleName: string, + region: string, + bedrockModelId: string, + ): cdk.aws_iam.Role { + return new iam.Role(dataSourceScope, `GenerationBedrockDataSourceRole${fieldName}`, { + roleName, + assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'), + inlinePolicies: { + BedrockRuntimeAccess: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['bedrock:InvokeModel'], + resources: [`arn:aws:bedrock:${region}::foundation-model/${bedrockModelId}`], + }), + ], + }), + }, + }); + } } const validate = (config: GenerationDirectiveConfiguration, ctx: TransformerContextProvider): void => { @@ -139,62 +247,3 @@ const validateInferenceConfig = (config: GenerationDirectiveConfiguration): void throw new InvalidDirectiveError(`@generation directive topP valid range: Minimum value of 0. Maximum value of 1. Provided: ${topP}`); } }; - -const createBedrockDataSource = ( - ctx: TransformerContextProvider, - directive: GenerationDirectiveConfiguration, - region: string, - stackName: string, - httpDataSourceId: string, -): cdk.aws_appsync.HttpDataSource => { - const { - field: { - name: { value: fieldName }, - }, - aiModel, - } = directive; - - const bedrockUrl = `https://bedrock-runtime.${region}.amazonaws.com`; - - const dataSourceScope = ctx.stackManager.getScopeFor(httpDataSourceId, stackName); - const dataSource = ctx.api.host.addHttpDataSource( - httpDataSourceId, - bedrockUrl, - { - authorizationConfig: { - signingRegion: region, - signingServiceName: 'bedrock', - }, - }, - dataSourceScope, - ); - - const roleName = ctx.resourceHelper.generateIAMRoleName(`GenerationBedrockDataSourceRole${fieldName}`); - const role = createBedrockDataSourceRole(dataSourceScope, fieldName, roleName, region, aiModel); - dataSource.ds.serviceRoleArn = role.roleArn; - return dataSource; -}; - -const createBedrockDataSourceRole = ( - dataSourceScope: Construct, - fieldName: string, - roleName: string, - region: string, - bedrockModelId: string, -): cdk.aws_iam.Role => { - return new iam.Role(dataSourceScope, `GenerationBedrockDataSourceRole${fieldName}`, { - roleName, - assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'), - inlinePolicies: { - BedrockRuntimeAccess: new iam.PolicyDocument({ - statements: [ - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['bedrock:InvokeModel'], - resources: [`arn:aws:bedrock:${region}::foundation-model/${bedrockModelId}`], - }), - ], - }), - }, - }); -}; diff --git a/packages/amplify-graphql-generation-transformer/src/utils/graphql-json-schema-type.ts b/packages/amplify-graphql-generation-transformer/src/utils/graphql-json-schema-type.ts index 7986f8d5e5..ab7e703236 100644 --- a/packages/amplify-graphql-generation-transformer/src/utils/graphql-json-schema-type.ts +++ b/packages/amplify-graphql-generation-transformer/src/utils/graphql-json-schema-type.ts @@ -1,71 +1,189 @@ import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces'; -import { Kind, NamedTypeNode, TypeNode, TypeSystemDefinitionNode } from 'graphql'; +import { + EnumTypeDefinitionNode, + Kind, + ListTypeNode, + NamedTypeNode, + NonNullTypeNode, + ObjectTypeDefinitionNode, + TypeNode, + TypeSystemDefinitionNode, +} from 'graphql'; import { getBaseType, isScalar } from 'graphql-transformer-common'; import { GraphQLScalarJSONSchemaDefinition, isDisallowedScalarType, supportedScalarTypes } from './graphql-scalar-json-schema-definitions'; -// TODO: Make this legible -export function generateJSONSchemaFromTypeNode( +export type JSONLike = string | number | boolean | null | { [key: string]: JSONLike } | JSONLike[]; + +export type JSONSchema = { + type: string; + properties?: Record; + required?: string[]; + items?: JSONSchema; + enum?: (string | number | boolean | null)[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + description?: string; + default?: JSONLike; + additionalProperties?: boolean | JSONSchema; +}; + +/** + * Generates a JSON Schema from a GraphQL TypeNode. + * @param {TypeNode} typeNode - The GraphQL TypeNode to convert. + * @param {TransformerContextProvider} ctx - The transformer context. + * @param {JSONSchema} [schema={ type: '' }] - The initial schema object. + * @returns {JSONSchema} The generated JSON Schema. + */ +export const generateJSONSchemaFromTypeNode = ( typeNode: TypeNode, ctx: TransformerContextProvider, schema: JSONSchema = { type: '' }, -): JSONSchema { - const generateJSONSchemaForDef = (def: TypeSystemDefinitionNode, schema: JSONSchema): Record => { - if (def.kind === 'ObjectTypeDefinition') { - const properties = def.fields?.reduce((acc: Record, field) => { - acc[field.name.value] = generateJSONSchemaFromTypeNode(field.type, ctx, { type: '' }); - if (field.type.kind === Kind.NON_NULL_TYPE) { - schema.required = [...(schema.required || []), field.name.value]; - } - return acc; - }, {}); - if (!properties) { - throw new Error(`Object type ${def.name.value} has no fields`); - } - return properties; - } else if (def.kind === 'EnumTypeDefinition') { - return { - [def.name.value]: { - type: 'string', - enum: def.values?.map((value) => value.name.value), - }, - }; - } else { - throw new Error(`Unsupported type definition: ${def.kind}`); - } - }; - +): JSONSchema => { switch (typeNode.kind) { case Kind.NAMED_TYPE: - const namedTypeSchema = processNamedType(typeNode, ctx); - Object.assign(schema, namedTypeSchema); - if (isScalar(typeNode)) { - return schema; - } - - const baseTypeName = getBaseType(typeNode); - const typeDef = ctx.output.getType(baseTypeName); - if (!typeDef) { - throw new Error(`Type ${baseTypeName} not found`); - } - - schema.properties = generateJSONSchemaForDef(typeDef, schema); - return schema; + return handleNamedType(typeNode, ctx, schema); case Kind.NON_NULL_TYPE: - if (isDisallowedScalarType(getBaseType(typeNode))) { - throw new Error(` - Disallowed required field type ${getBaseType(typeNode)} without a default value. - Use one of the supported scalar types for generation routes: [${supportedScalarTypes.join(', ')}]`); - } - return generateJSONSchemaFromTypeNode(typeNode.type, ctx, schema); + return handleNonNullType(typeNode, ctx, schema); case Kind.LIST_TYPE: - return { - type: 'array', - items: generateJSONSchemaFromTypeNode(typeNode.type, ctx), - }; + return handleListType(typeNode, ctx); } -} +}; + +/** + * Handles the conversion of a NamedTypeNode to JSON Schema. + * @param {NamedTypeNode} typeNode - The NamedTypeNode to process. + * @param {TransformerContextProvider} ctx - The transformer context. + * @param {JSONSchema} schema - The current schema object. + * @returns {JSONSchema} The updated JSON Schema. + */ +const handleNamedType = (typeNode: NamedTypeNode, ctx: TransformerContextProvider, schema: JSONSchema): JSONSchema => { + const namedTypeSchema = processNamedType(typeNode); + Object.assign(schema, namedTypeSchema); -function processNamedType(namedType: NamedTypeNode, ctx: TransformerContextProvider): JSONSchema { + if (isScalar(typeNode)) { + return schema; + } + + const baseTypeName = getBaseType(typeNode); + const typeDef = ctx.output.getType(baseTypeName); + if (!typeDef) { + throw new Error(`Type ${baseTypeName} not found`); + } + + schema.properties = generateJSONSchemaForDef(typeDef, ctx, schema); + return schema; +}; + +/** + * Handles the conversion of a NonNullTypeNode to JSON Schema. + * @param {NonNullTypeNode} typeNode - The NonNullTypeNode to process. + * @param {TransformerContextProvider} ctx - The transformer context. + * @param {JSONSchema} schema - The current schema object. + * @returns {JSONSchema} The updated JSON Schema. + * @throws {Error} If the field type is disallowed for required fields without a default value. + */ +const handleNonNullType = (typeNode: NonNullTypeNode, ctx: TransformerContextProvider, schema: JSONSchema): JSONSchema => { + const baseType = getBaseType(typeNode); + if (isDisallowedScalarType(baseType)) { + throw new Error(` + Disallowed required field type ${baseType} without a default value. + Use one of the supported scalar types for generation routes: [${supportedScalarTypes.join(', ')}] + `); + } + return generateJSONSchemaFromTypeNode(typeNode.type, ctx, schema); +}; + +/** + * Handles the conversion of a ListTypeNode to JSON Schema. + * @param {ListTypeNode} typeNode - The ListTypeNode to process. + * @param {TransformerContextProvider} ctx - The transformer context. + * @returns {JSONSchema} The JSON Schema representing the list type. + */ +const handleListType = (typeNode: ListTypeNode, ctx: TransformerContextProvider): JSONSchema => { + return { + type: 'array', + items: generateJSONSchemaFromTypeNode(typeNode.type, ctx), + }; +}; + +/** + * Generates JSON Schema for a GraphQL type definition. + * @param {TypeSystemDefinitionNode} def - The GraphQL type definition node. + * @param {TransformerContextProvider} ctx - The transformer context. + * @param {JSONSchema} schema - The current schema object. + * @returns {Record} A record of field names to their JSON Schema representations. + * @throws {Error} If an unsupported type definition is encountered. + */ +const generateJSONSchemaForDef = ( + def: TypeSystemDefinitionNode, + ctx: TransformerContextProvider, + schema: JSONSchema, +): Record => { + switch (def.kind) { + case 'ObjectTypeDefinition': + return handleObjectTypeDefinition(def, ctx, schema); + case 'EnumTypeDefinition': + return handleEnumTypeDefinition(def); + default: + throw new Error(`Unsupported type definition: ${def.kind}`); + } +}; + +/** + * Handles the conversion of an ObjectTypeDefinition to JSON Schema. + * @param {ObjectTypeDefinitionNode} def - The ObjectTypeDefinition node to process. + * @param {TransformerContextProvider} ctx - The transformer context. + * @param {JSONSchema} schema - The current schema object. + * @returns {Record} A record of field names to their JSON Schema representations. + * @throws {Error} If the object type has no fields. + */ +const handleObjectTypeDefinition = ( + def: ObjectTypeDefinitionNode, + ctx: TransformerContextProvider, + schema: JSONSchema, +): Record => { + const properties = def.fields?.reduce((acc: Record, field) => { + acc[field.name.value] = generateJSONSchemaFromTypeNode(field.type, ctx, { type: '' }); + + // Add required fields to the schema + if (field.type.kind === Kind.NON_NULL_TYPE) { + schema.required = [...(schema.required || []), field.name.value]; + } + + return acc; + }, {}); + + if (!properties) { + throw new Error(`Object type ${def.name.value} has no fields`); + } + + return properties; +}; + +/** + * Handles the conversion of an EnumTypeDefinition to JSON Schema. + * @param {EnumTypeDefinitionNode} def - The EnumTypeDefinition node to process. + * @returns {Record} A record containing the enum name and its JSON Schema representation. + */ +const handleEnumTypeDefinition = (def: EnumTypeDefinitionNode): Record => { + return { + [def.name.value]: { + type: 'string', + enum: def.values?.map((value) => value.name.value), + }, + }; +}; + +/** + * Processes a NamedTypeNode and returns the corresponding JSON Schema. + * @param {NamedTypeNode} namedType - The NamedTypeNode to process. + * @returns {JSONSchema} The JSON Schema representation of the named type. + */ +function processNamedType(namedType: NamedTypeNode): JSONSchema { switch (namedType.name.value) { case 'Int': return GraphQLScalarJSONSchemaDefinition.Int; @@ -103,22 +221,3 @@ function processNamedType(namedType: NamedTypeNode, ctx: TransformerContextProvi }; } } - -export type JSONLike = string | number | boolean | null | { [key: string]: JSONLike } | JSONLike[]; - -export type JSONSchema = { - type: string; - properties?: Record; - required?: string[]; - items?: JSONSchema; - enum?: (string | number | boolean | null)[]; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; - pattern?: string; - format?: string; - description?: string; - default?: JSONLike; - additionalProperties?: boolean | JSONSchema; -}; diff --git a/packages/amplify-graphql-generation-transformer/src/utils/graphql-scalar-json-schema-definitions.ts b/packages/amplify-graphql-generation-transformer/src/utils/graphql-scalar-json-schema-definitions.ts index 6d236f6472..c24c252308 100644 --- a/packages/amplify-graphql-generation-transformer/src/utils/graphql-scalar-json-schema-definitions.ts +++ b/packages/amplify-graphql-generation-transformer/src/utils/graphql-scalar-json-schema-definitions.ts @@ -1,5 +1,7 @@ import { JSONSchema } from './graphql-json-schema-type'; +// TODO: Add regex pattern for applicable scalar types + const Boolean: JSONSchema = { type: 'boolean', description: 'A boolean value.',