aws-amplify / amplify-category-api

The AWS Amplify CLI is a toolchain for simplifying serverless web and mobile development. This plugin provides functionality for the API category, allowing for the creation and management of GraphQL and REST based backends for your amplify project.
https://docs.amplify.aws/
Apache License 2.0
89 stars 77 forks source link

How to override Data Resolver functions in AmplifyGraphqlApi ? #2467

Open harshit9715 opened 6 months ago

harshit9715 commented 6 months ago

Amplify CLI Version

12.11.0

Question

I am using the CDK construct for building the graphql schema.

const graphApi = new AmplifyGraphqlApi(stack, "GraphqlApi", {
    functionSlots: [...getCustomSlots],
    definition: graphQLDefinition,
    apiName: "GraphqlApi",
    translationBehavior: {},
    transformerPlugins: [],
    authorizationModes: {
      apiKeyConfig: {
        expires: Duration.days(365),
        description: "API Key for GraphQL API - " + stack.stage,
      },
      defaultAuthorizationMode: "OPENID_CONNECT",
      oidcConfig: {
        oidcIssuerUrl: process.env.CLERK_ISSUER_BASE_URL!,
        oidcProviderName: "Clerk",
        tokenExpiryFromAuth: Duration.millis(0),
        tokenExpiryFromIssue: Duration.millis(0),
      },
    },
  });

I want to know if there is a way of overriding some of the auto-generated resolvers?

This option is available through amplify cli. we just need to place the edited resolvers in the amplify/backend/api//resolvers folder with the same name.

harshit9715 commented 6 months ago
const graphApi = new AmplifyGraphqlApi(stack, "GraphqlApi", {
    functionSlots: [...getCreateSlots],
    definition: graphQLDefinition,
    apiName: "GraphqlApi",
    translationBehavior: {},
    transformerPlugins: [],
    authorizationModes: {
      apiKeyConfig: {
        expires: Duration.days(365),
        description: "API Key for GraphQL API - " + stack.stage,
      },
      defaultAuthorizationMode: "OPENID_CONNECT",
      oidcConfig: {
        oidcIssuerUrl: process.env.CLERK_ISSUER_BASE_URL!,
        oidcProviderName: "Clerk",
        tokenExpiryFromAuth: Duration.millis(0),
        tokenExpiryFromIssue: Duration.millis(0),
      },
    },
  });

  const MutationUpdateQuestionDataResolverFn = new Asset(
    stack,
    "ModifiedResolvers",
    {
      path: path.join("stacks/resolvers/Mutation.updateQuestion.req.vtl"),
    }
  );

  graphApi.resources.cfnResources.cfnFunctionConfigurations[
    "MutationUpdateQuestionDataResolverFn"
  ].requestMappingTemplateS3Location =
    MutationUpdateQuestionDataResolverFn.s3ObjectUrl;

This is what I am using but it requires management overhead. It would be great if we can use the same slot behavior to override the data resolvers too.

renebrandel commented 6 months ago

Hi @harshit9715 - can you provide more context on the type of updates you'd like to override?

harshit9715 commented 6 months ago

Sure!

I am trying to implement atomic counters for certain fields.

type Question @model {
id: ID!
content: String!
upvoteCount: Int! @default("0") 
downvoteCount: Int! @default("0") 
viewCount: Int! @default("0") 
}

Now the vtl template does not support atomic counters out of the box but then I noticed the generated code is very close to what I need.

Basically, if there is a fieldName ending with Count, i am overriding its behaviour on update mutation.

Instead of using $expSet.put i am using $expAdd.put. This way I can simply add the value entered to the existing value. and I believe it would be write consistent. work with subscriptions and also return right value incase the value changes in the backend.

TLDR;

The changed lines

from this

#if( $util.isNull($entry.value) )
    #set( $discard = $expRemove.add("#$entryKeyAttributeName") )
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
  #else
    $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
    $util.qr($expValues.put(":$entryKeyAttributeName", $util.dynamodb.toDynamoDB($entry.value)))
  #end

To this

#if( $util.isNull($entry.value) )
    #set( $discard = $expRemove.add("#$entryKeyAttributeName") )
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
  #else
    #if ( $entry.key.endsWith("Count"))
      #if ( $entry.value != 1 && $entry.value != -1) ## this condition makes sure that if the client sent anything other than +1 or -1, then no change on the value will occur.
        #set( $entry.value = 0 )
      #end
      $util.qr($expAdd.put("#$entryKeyAttributeName", ":$entryKeyAttributeName")) ## NOTICE THE expAdd USED ON THIS LINE.
    #else
      $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    #end
    ## $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
    $util.qr($expValues.put(":$entryKeyAttributeName", $util.dynamodb.toDynamoDB($entry.value)))
  #end

Full code

Here is what was generated for Mutation.updateQuestion.req.vtl

## [Start] Mutation Update resolver. **
#set( $args = $util.defaultIfNull($ctx.stash.transformedArgs, $ctx.args) )
## Set the default values to put request **
#set( $mergedValues = $util.defaultIfNull($ctx.stash.defaultValues, {}) )
## copy the values from input **
$util.qr($mergedValues.putAll($util.defaultIfNull($args.input, {})))
## set the typename **
## Initialize the vars for creating ddb expression **
#set( $expNames = {} )
#set( $expValues = {} )
#set( $expSet = {} )
#set( $expAdd = {} )
#set( $expRemove = [] )
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $Key = $ctx.stash.metadata.modelObjectKey )
#else
  #set( $Key = {
  "id":   $util.dynamodb.toDynamoDB($args.input.id)
} )
#end
## Model key **
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $keyFields = [] )
  #foreach( $entry in $ctx.stash.metadata.modelObjectKey.entrySet() )
    $util.qr($keyFields.add("$entry.key"))
  #end
#else
  #set( $keyFields = ["id"] )
#end
#foreach( $entry in $util.map.copyAndRemoveAllKeys($mergedValues, $keyFields).entrySet() )
  #if( !$util.isNull($ctx.stash.metadata.dynamodbNameOverrideMap) && $ctx.stash.metadata.dynamodbNameOverrideMap.containsKey("$entry.key") )
    #set( $entryKeyAttributeName = $ctx.stash.metadata.dynamodbNameOverrideMap.get("$entry.key") )
  #else
    #set( $entryKeyAttributeName = $entry.key )
  #end
  #if( $util.isNull($entry.value) )
    #set( $discard = $expRemove.add("#$entryKeyAttributeName") )
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
  #else
    $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
    $util.qr($expValues.put(":$entryKeyAttributeName", $util.dynamodb.toDynamoDB($entry.value)))
  #end
#end
#set( $expression = "" )
#if( !$expSet.isEmpty() )
  #set( $expression = "SET" )
  #foreach( $entry in $expSet.entrySet() )
    #set( $expression = "$expression $entry.key = $entry.value" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#if( !$expAdd.isEmpty() )
  #set( $expression = "$expression ADD" )
  #foreach( $entry in $expAdd.entrySet() )
    #set( $expression = "$expression $entry.key $entry.value" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#if( !$expRemove.isEmpty() )
  #set( $expression = "$expression REMOVE" )
  #foreach( $entry in $expRemove )
    #set( $expression = "$expression $entry" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#set( $update = {} )
$util.qr($update.put("expression", "$expression"))
#if( !$expNames.isEmpty() )
  $util.qr($update.put("expressionNames", $expNames))
#end
#if( !$expValues.isEmpty() )
  $util.qr($update.put("expressionValues", $expValues))
#end
## Begin - key condition **
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $keyConditionExpr = {} )
  #set( $keyConditionExprNames = {} )
  #foreach( $entry in $ctx.stash.metadata.modelObjectKey.entrySet() )
    $util.qr($keyConditionExpr.put("keyCondition$velocityCount", {
  "attributeExists": true
}))
    $util.qr($keyConditionExprNames.put("#keyCondition$velocityCount", "$entry.key"))
  #end
  $util.qr($ctx.stash.conditions.add($keyConditionExpr))
#else
  $util.qr($ctx.stash.conditions.add({
  "id": {
      "attributeExists": true
  }
}))
#end
## End - key condition **
#if( $args.condition )
  $util.qr($ctx.stash.conditions.add($args.condition))
#end
## Start condition block **
#if( $ctx.stash.conditions && $ctx.stash.conditions.size() != 0 )
  #set( $mergedConditions = {
  "and": $ctx.stash.conditions
} )
  #set( $Conditions = $util.parseJson($util.transform.toDynamoDBConditionExpression($mergedConditions)) )
  #if( $Conditions.expressionValues && $Conditions.expressionValues.size() == 0 )
    #set( $Conditions = {
  "expression": $Conditions.expression,
  "expressionNames": $Conditions.expressionNames
} )
  #end
  ## End condition block **
#end
#set( $UpdateItem = {
  "version": "2018-05-29",
  "operation": "UpdateItem",
  "key": $Key,
  "update": $update
} )
#if( $Conditions )
  #if( $keyConditionExprNames )
    $util.qr($Conditions.expressionNames.putAll($keyConditionExprNames))
  #end
  $util.qr($UpdateItem.put("condition", $Conditions))
#end
$util.toJson($UpdateItem)
## [End] Mutation Update resolver. **

and here is what I override the template with.

## [Start] Mutation Update resolver. **
#set( $args = $util.defaultIfNull($ctx.stash.transformedArgs, $ctx.args) )
## Set the default values to put request **
#set( $mergedValues = $util.defaultIfNull($ctx.stash.defaultValues, {}) )
## copy the values from input **
$util.qr($mergedValues.putAll($util.defaultIfNull($args.input, {})))
## set the typename **
## Initialize the vars for creating ddb expression **
#set( $expNames = {} )
#set( $expValues = {} )
#set( $expSet = {} )
#set( $expAdd = {} )
#set( $expRemove = [] )
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $Key = $ctx.stash.metadata.modelObjectKey )
#else
  #set( $Key = {
  "id":   $util.dynamodb.toDynamoDB($args.input.id)
} )
#end
## Model key **
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $keyFields = [] )
  #foreach( $entry in $ctx.stash.metadata.modelObjectKey.entrySet() )
    $util.qr($keyFields.add("$entry.key"))
  #end
#else
  #set( $keyFields = ["id"] )
#end
#foreach( $entry in $util.map.copyAndRemoveAllKeys($mergedValues, $keyFields).entrySet() )
  #if( !$util.isNull($ctx.stash.metadata.dynamodbNameOverrideMap) && $ctx.stash.metadata.dynamodbNameOverrideMap.containsKey("$entry.key") )
    #set( $entryKeyAttributeName = $ctx.stash.metadata.dynamodbNameOverrideMap.get("$entry.key") )
  #else
    #set( $entryKeyAttributeName = $entry.key )
  #end
  #if( $util.isNull($entry.value) )
    #set( $discard = $expRemove.add("#$entryKeyAttributeName") )
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
  #else
    #if ( $entry.key.endsWith("Count"))
      #if ( $entry.value != 1 && $entry.value != -1)
        #set( $entry.value = 0 )
      #end
      $util.qr($expAdd.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    #else
      $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    #end
    ## $util.qr($expSet.put("#$entryKeyAttributeName", ":$entryKeyAttributeName"))
    $util.qr($expNames.put("#$entryKeyAttributeName", "$entry.key"))
    $util.qr($expValues.put(":$entryKeyAttributeName", $util.dynamodb.toDynamoDB($entry.value)))
  #end
#end
#set( $expression = "" )
#if( !$expSet.isEmpty() )
  #set( $expression = "SET" )
  #foreach( $entry in $expSet.entrySet() )
    #set( $expression = "$expression $entry.key = $entry.value" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#if( !$expAdd.isEmpty() )
  #set( $expression = "$expression ADD" )
  #foreach( $entry in $expAdd.entrySet() )
    #set( $expression = "$expression $entry.key $entry.value" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#if( !$expRemove.isEmpty() )
  #set( $expression = "$expression REMOVE" )
  #foreach( $entry in $expRemove )
    #set( $expression = "$expression $entry" )
    #if( $foreach.hasNext() )
      #set( $expression = "$expression," )
    #end
  #end
#end
#set( $update = {} )
$util.qr($update.put("expression", "$expression"))
#if( !$expNames.isEmpty() )
  $util.qr($update.put("expressionNames", $expNames))
#end
#if( !$expValues.isEmpty() )
  $util.qr($update.put("expressionValues", $expValues))
#end
## Begin - key condition **
#if( $ctx.stash.metadata.modelObjectKey )
  #set( $keyConditionExpr = {} )
  #set( $keyConditionExprNames = {} )
  #foreach( $entry in $ctx.stash.metadata.modelObjectKey.entrySet() )
    $util.qr($keyConditionExpr.put("keyCondition$velocityCount", {
  "attributeExists": true
}))
    $util.qr($keyConditionExprNames.put("#keyCondition$velocityCount", "$entry.key"))
  #end
  $util.qr($ctx.stash.conditions.add($keyConditionExpr))
#else
  $util.qr($ctx.stash.conditions.add({
  "id": {
      "attributeExists": true
  }
}))
#end
## End - key condition **
#if( $args.condition )
  $util.qr($ctx.stash.conditions.add($args.condition))
#end
## Start condition block **
#if( $ctx.stash.conditions && $ctx.stash.conditions.size() != 0 )
  #set( $mergedConditions = {
  "and": $ctx.stash.conditions
} )
  #set( $Conditions = $util.parseJson($util.transform.toDynamoDBConditionExpression($mergedConditions)) )
  #if( $Conditions.expressionValues && $Conditions.expressionValues.size() == 0 )
    #set( $Conditions = {
  "expression": $Conditions.expression,
  "expressionNames": $Conditions.expressionNames
} )
  #end
  ## End condition block **
#end
#set( $UpdateItem = {
  "version": "2018-05-29",
  "operation": "UpdateItem",
  "key": $Key,
  "update": $update
} )
#if( $Conditions )
  #if( $keyConditionExprNames )
    $util.qr($Conditions.expressionNames.putAll($keyConditionExprNames))
  #end
  $util.qr($UpdateItem.put("condition", $Conditions))
#end
$util.toJson($UpdateItem)
## [End] Mutation Update resolver. **
harshit9715 commented 6 months ago

Here is a quick video.

https://github.com/aws-amplify/amplify-category-api/assets/55243567/5b41a335-9eb7-463f-a55a-771ea36af50b

harshit9715 commented 6 months ago
const graphApi = new AmplifyGraphqlApi(stack, "GraphqlApi", {
    functionSlots: [...getCreateSlots],
    definition: graphQLDefinition,
    apiName: "GraphqlApi",
    translationBehavior: {},
    transformerPlugins: [],
    authorizationModes: {
      apiKeyConfig: {
        expires: Duration.days(365),
        description: "API Key for GraphQL API - " + stack.stage,
      },
      defaultAuthorizationMode: "OPENID_CONNECT",
      oidcConfig: {
        oidcIssuerUrl: process.env.CLERK_ISSUER_BASE_URL!,
        oidcProviderName: "Clerk",
        tokenExpiryFromAuth: Duration.millis(0),
        tokenExpiryFromIssue: Duration.millis(0),
      },
    },
  });

  const MutationUpdateQuestionDataResolverFn = new Asset(
    stack,
    "ModifiedResolvers",
    {
      path: path.join("stacks/resolvers/Mutation.updateQuestion.req.vtl"),
    }
  );

  graphApi.resources.cfnResources.cfnFunctionConfigurations[
    "MutationUpdateQuestionDataResolverFn"
  ].requestMappingTemplateS3Location =
    MutationUpdateQuestionDataResolverFn.s3ObjectUrl;

This is what I am using but it requires management overhead. It would be great if we can use the same slot behavior to override the data resolvers too.

And I realised that the construct can use the local template if it is available. Here is the updated code.

const resolverConfig = graphApi.resources.cfnResources.cfnFunctionConfigurations[functionName];
  if (!resolverConfig) return;
  resolverConfig.requestMappingTemplateS3Location = undefined;
  resolverConfig.requestMappingTemplate = fs.readFileSync(
    path.join("stacks/resolvers", filePath),
    "utf8"
  );
dpilch commented 5 months ago

Hi @harshit9715, this is intended workflow for overriding when using the construct directly. I'll leave this open as a feature request.