【Amplify api】Mutationフィールドに@authディレクティブをつけるとonly one resolver is allowed per fieldエラー

この記事は「Amplify x Next.jsハマりどころ」シリーズ第3弾です。
もともとは[note](https://note.com/knagata/n/n7d8de94b622f)に書いていたのですが、あまり見てもらえないのとコード記述が読みにくいのでqiitaにせっせと移植中です。

## ハマりどころ一覧
1. [postConfirmation lambda triggerでstorage(dynamoDB)にアクセスしようとすると、Circular　dependency between resourcesでpushできない](https://qiita.com/kazukinagata/items/664bf168b229c987dee0)
2. [type Query fieldに@aws_iamディレクティブをつけても、authRole（またはunauthRole）にポリシーがアタッチされない](https://qiita.com/kazukinagata/items/3bc3f309af751c6cae75)
)
3. type Mutation filedに@authディレクティブをつけるとOnly one resolver is allowed per fieldエラー <- 今回！
4. redirect_urlを環境ごとに分けたいんですが
5. S3の画像URLを署名なしで取得したいんですが
6. aws-exports.jsが.gitignoreの対象になっているためビルド時にエラーになるんですが
7. モノレポでamplifyバックエンドを共有したいんですが
8. サブドメインでも認証を維持したいんですが

今回はこのNo.3について書いていきたいと思います。

※このシリーズでは、まずはエラーを再現し、そのエラーを修正するというチュートリアル的な流れで書いていきます。結論だけ知りたい方は「こいつの倒し方」まで読み飛ばしてください。

## Versions
@aws-amplify/cli: 4.43.0

## To Reproduce

今回もエラーを再現してからこいつの倒し方をご紹介したいと思います。

まず、Mutationを追加するには

1. stacksを登録する
2. resolverを作成する
3. schema.graphqlにtype Mutationフィールドを追加する

という3つのステップが必要になります。

今回はCounterというモデルがあり、Counter.countをインクリメントするincrementCountというMutationを作成してみることにします。

```graphql

type Counter @model @auth(rules: [{ allow: owner }, { allow: private, provider: iam }]){
 id: ID!
 count: Int!
}

type Mutation {
 incrementCount(id: String): Int
   @auth(rules: [{ allow: private, provider: iam }])
}
```

### 1. stacksを追加する

cloudFormationでAppSyncにMutationとcustom resolverを登録させるため、./amplify/backend/api/[:apiname]/stacksというディレクトリの中のCustomResources.jsonというファイルを開き、"Resources"フィールドに以下を追記します。

```json
"MutateIncrementCount": {
  "Type": "AWS::AppSync::Resolver",
  "Properties": {
    "ApiId": {
      "Ref": "AppSyncApiId"
    },
    "DataSourceName": "CounterTable",
    "TypeName": "Mutation",
    "FieldName": "incrementCount",
    "RequestMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [
              ".",
              [
                "Mutation",
                "incrementCount",
                "req",
                "vtl"
              ]
            ]
          }
        }
      ]
    },
    "ResponseMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [
              ".",
              [
                "Mutation",
                "incrementCount",
                "res",
                "vtl"
              ]
            ]
          }
        }
      ]
    }
  }
}
```

### 2. resolverを作成する

1で指定したファイル（今回はMutation.incrementCount.req.vtlとMutation.incrementCount.res.vtl）を、resolversディレクトリ内に作成して以下のように書きます。基本的に、buildディレクトリに自動で作成されたresolverをコピーして、必要な処理だけ書き換えればいいと思います。

```
## Mutation.incrementCount.req.vtl

## [Start] Determine request authentication mode **
#if( $util.isNullOrEmpty($authMode) && !$util.isNull($ctx.identity) && !$util.isNull($ctx.identity.sub) && !$util.isNull($ctx.identity.issuer) && !$util.isNull($ctx.identity.username) && !$util.isNull($ctx.identity.claims) && !$util.isNull($ctx.identity.sourceIp) )
 #set( $authMode = "userPools" )
#end
## [End] Determine request authentication mode **
## [Start] Check authMode and execute owner/group checks **
#if( $authMode == "userPools" )
 ## No Static Group Authorization Rules **


 #if( ! $isStaticGroupAuthorized )
   ## No dynamic group authorization rules **


   ## [Start] Owner Authorization Checks **
   #set( $ownerAuthExpressions = [] )
   #set( $ownerAuthExpressionValues = {} )
   #set( $ownerAuthExpressionNames = {} )
   ## Authorization rule: { allow: owner, ownerField: "owner", identityClaim: "cognito:username" } **
   $util.qr($ownerAuthExpressions.add("#owner0 = :identity0"))
   $util.qr($ownerAuthExpressionNames.put("#owner0", "owner"))
   $util.qr($ownerAuthExpressionValues.put(":identity0", $util.dynamodb.toDynamoDB($util.defaultIfNull($ctx.identity.claims.get("username"), $util.defaultIfNull($ctx.identity.claims.get("cognito:username"), "___xamznone____")))))
   ## [End] Owner Authorization Checks **


   ## [Start] Collect Auth Condition **
   #set( $authCondition = $util.defaultIfNull($authCondition, {
 "expression": "",
 "expressionNames": {},
 "expressionValues": {}
}) )
   #set( $totalAuthExpression = "" )
   ## Add dynamic group auth conditions if they exist **
   #if( $groupAuthExpressions )
     #foreach( $authExpr in $groupAuthExpressions )
       #set( $totalAuthExpression = "$totalAuthExpression $authExpr" )
       #if( $foreach.hasNext )
         #set( $totalAuthExpression = "$totalAuthExpression OR" )
       #end
     #end
   #end
   #if( $groupAuthExpressionNames )
     $util.qr($authCondition.expressionNames.putAll($groupAuthExpressionNames))
   #end
   #if( $groupAuthExpressionValues )
     $util.qr($authCondition.expressionValues.putAll($groupAuthExpressionValues))
   #end
   ## Add owner auth conditions if they exist **
   #if( $totalAuthExpression != "" && $ownerAuthExpressions && $ownerAuthExpressions.size() > 0 )
     #set( $totalAuthExpression = "$totalAuthExpression OR" )
   #end
   #if( $ownerAuthExpressions )
     #foreach( $authExpr in $ownerAuthExpressions )
       #set( $totalAuthExpression = "$totalAuthExpression $authExpr" )
       #if( $foreach.hasNext )
         #set( $totalAuthExpression = "$totalAuthExpression OR" )
       #end
     #end
   #end
   #if( $ownerAuthExpressionNames )
     $util.qr($authCondition.expressionNames.putAll($ownerAuthExpressionNames))
   #end
   #if( $ownerAuthExpressionValues )
     $util.qr($authCondition.expressionValues.putAll($ownerAuthExpressionValues))
   #end
   ## Set final expression if it has changed. **
   #if( $totalAuthExpression != "" )
     #if( $util.isNullOrEmpty($authCondition.expression) )
       #set( $authCondition.expression = "($totalAuthExpression)" )
     #else
       #set( $authCondition.expression = "$authCondition.expression AND ($totalAuthExpression)" )
     #end
   #end
   ## [End] Collect Auth Condition **
 #end


 ## [Start] Throw if unauthorized **
 #if( !($isStaticGroupAuthorized == true || ($totalAuthExpression != "")) )
   $util.unauthorized()
 #end
 ## [End] Throw if unauthorized **
#end
## [End] Check authMode and execute owner/group checks **

#if( $authCondition && $authCondition.expression != "" )
 #set( $condition = $authCondition )
 #if( $modelObjectKey )
   #foreach( $entry in $modelObjectKey.entrySet() )
     $util.qr($condition.put("expression", "$condition.expression AND attribute_exists(#keyCondition$velocityCount)"))
     $util.qr($condition.expressionNames.put("#keyCondition$velocityCount", "$entry.key"))
   #end
 #else
   $util.qr($condition.put("expression", "$condition.expression AND attribute_exists(#id)"))
   $util.qr($condition.expressionNames.put("#id", "id"))
 #end
#else
 #if( $modelObjectKey )
   #set( $condition = {
 "expression": "",
 "expressionNames": {},
 "expressionValues": {}
} )
   #foreach( $entry in $modelObjectKey.entrySet() )
     #if( $velocityCount == 1 )
       $util.qr($condition.put("expression", "attribute_exists(#keyCondition$velocityCount)"))
     #else
       $util.qr($condition.put("expression", "$condition.expression AND attribute_exists(#keyCondition$velocityCount)"))
     #end
     $util.qr($condition.expressionNames.put("#keyCondition$velocityCount", "$entry.key"))
   #end
 #else
   #set( $condition = {
 "expression": "attribute_exists(#id)",
 "expressionNames": {
     "#id": "id"
 },
 "expressionValues": {}
} )
 #end
#end

#set( $expNames = {} )
#set( $expValues = {} )
#set( $expSet = {} )
## ここでインクリメントの処理を記述してます
$util.qr($expSet.put("#count", "#count + :x"))
$util.qr($expNames.put("#count", "count"))
$util.qr($expValues.put(":x", $util.dynamodb.toDynamoDB(1)))

#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

#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
{
 "version": "2018-05-29",
 "operation": "UpdateItem",
 "key": #if( $modelObjectKey ) $util.toJson($modelObjectKey) #else {
 "id": {
     "S": $util.toJson($context.args.id)
 }
} #end,
 "update": $util.toJson($update),
 "condition": $util.toJson($condition)
}
```

```
## Mutation.incrementCount.res.vtl

#if( $ctx.error )
$util.error($ctx.error.message, $ctx.error.type)
#else
$util.toJson($ctx.result.count)
#end
```

### 3. schema.graphqlにtype Mutationフィールドを追加する

```graphql
# 再掲
type Counter @model @auth(rules: [{ allow: owner }, { allow: private, provider: iam }]){
 id: ID!
 count: Int!
}

type Mutation {
 incrementCount(id: String): Int
   @auth(rules: [{ allow: private, provider: iam }])
}
```

以上でエラーを再現する準備が出来ました。`amplify push --y`を実行して確認してください。（もしかすると、Mutation.incrementCountの作成と@auth追加を同時に実行すると、AuthRolePolicy追加時にincrementCountなんて見付からない、的な別のエラーが出るかもしれません。その場合、一旦@authディレクティブをコメントアウトしてからpushし、その後@authディレクティブを加えて再pushしてみてください。）


## こいつの倒し方

前回の復習ですが、@authディレクティブは、@aws_iamなどAppSyncで提供されるディレクティブを自動で付与してくれるのと同時に、IAM認証で必要なAuthRole（やUnauthRole）にQueryやMutationを実行するためのpolicyを追加してくれるのでした。なので@authを付けたかったんですね。でもエラーになっちゃうというのが今回のテーマです。

この問題についてStackoverflowで探してみると以下のようdiscussionがありました。

[https://github.com/aws-amplify/amplify-cli/issues/5362#issuecomment-756340169](https://github.com/aws-amplify/amplify-cli/issues/5362#issuecomment-756340169)

> The comment by attilah implies that this should be supported, but currently it only seems to work for a Query field backed by @function at this time according to this comment by yuth

これとその参照先のコメントによると、トップレベルのQueryやMutationフィールドに@authを付与してCLIが良しなにやってくれるのは@functionを使う場合だけで、今回のようにそれ以外のcustom resolverを使うケースだと、CLIがnullを返すresolverを勝手に作成してincrementCountに割り当てようとするため、マニュアルで作ったresolverと衝突して今回のようなエラーが発生するとのことです。

ただし、実際に確認してみたところ、@functionを使わないフィールドの場合でも、type Queryなら問題なく動くようでした。CLIのバージョンの差異かもしれません。

さて、type Mutationフィールドに@authが使えないようなので、@authがやってくれていた二つのこと、つまり、@aws_iamディレクティブを@authの代わりに使い、custom CloudFormation templateでAuthRoleにincrementCountを実行するpolicyを書き足してやる必要があります。

順番に、まずschema.graphqlを以下のように直します。

```graphql
type Counter @model @auth(rules: [{ allow: owner }, { allow: private, provider: iam }]){
 id: ID!
 count: Int!
}

type Mutation {
 incrementCount(id: String): Int
   @aws_iam
}
```

そして、上述したCustomResources.jsonの"Resources"フィールドに以下を足します。

```json
"AuthRolePolicy": {
  "Type": "AWS::IAM::Policy",
  "Properties": {
    "PolicyName": {
      "Fn::Sub": [
        "amplify-${AppSyncApiName}-${env}-AuthCustomPolicy",
        {
          "AppSyncApiName": {
            "Ref": "AppSyncApiName"
          },
          "env": {
            "Ref": "env"
          }
        }
      ]
    },
    "Roles": [
      {
        "Ref": "authRoleName"
      }
    ],
    "PolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": [
            "appsync:GraphQL"
          ],
          "Resource": [
            {
              "Fn::Sub": [
                "arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/${apiId}/types/${typeName}/fields/${fieldName}",
                {
                  "apiId": {
                    "Ref": "AppSyncApiId"
                  },
                  "typeName": "Mutation",
                  "fieldName": "incrementCount"
                }
              ]
            }
          ]
        }
      ]
    }
  }
}
```

今回はAuthRoleのみを対象にしましたが、UnauthRoleでもincrementCount実行したい場合は同様に書き加えてください。

最後にamplify pushをして通ることを確認しましょう。