Open13

新しいAmplify CLIを試す

Nobuyuki ItoNobuyuki Ito

Schemaメモ
input AMPLIFY { globalAuthRule: AuthRule = { allow: public } }
これはAPI Key onlyっぽい。これが書かれている限りkeyを作るか?と聞かれてしまう。
providerとかを足してもエラーにはならないが無視される

consoleページで試せない(IAM認証でpublicにしていてもApp Sync Consoleで確認ができない)
https://docs.amplify.aws/cli/graphql/authorization-rules/#use-iam-authorization-within-the-appsync-console
この設定が要る
custom-roles.json を作成
RoleもしくはUsernameをかくっぽい

Nobuyuki ItoNobuyuki Ito


custom-roles.jsonに書いた内容はすべてのvtlにチェックを入れるという形で入るようです。

Nobuyuki ItoNobuyuki Ito

新しいリレーションについて
@hasMany + @belongsToの場合

type Blog @model 
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  posts: [Post] @hasMany
}

type Post @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  title: String!
  blog: Blog @belongsTo
}

デフォルトのBlogに@authだけ足したschema

query MyQuery {
  getBlog(id: "772460da-6d7a-4167-ad48-4fbb99d5a9ee") {
    createdAt
    id
    name
    updatedAt
    posts {
      items {
        blog {
          id
        }
        blogPostsId
        id
        title
        updatedAt
        createdAt
      }
    }
  }
}
{
  "data": {
    "getBlog": {
      "createdAt": "2022-01-14T09:26:46.428Z",
      "id": "772460da-6d7a-4167-ad48-4fbb99d5a9ee",
      "name": "testBlog",
      "updatedAt": "2022-01-14T09:26:46.428Z",
      "posts": {
        "items": [
          {
            "blog": {
              "id": "772460da-6d7a-4167-ad48-4fbb99d5a9ee"
            },
            "blogPostsId": "772460da-6d7a-4167-ad48-4fbb99d5a9ee",
            "id": "1b23932c-661d-4895-8f2e-df995765153c",
            "title": "testBlog-testPost1",
            "updatedAt": "2022-01-14T10:10:38.390Z",
            "createdAt": "2022-01-14T10:10:38.390Z"
          }
        ]
      }
    }
  }
}
query MyQuery {
  getPost(id: "1b23932c-661d-4895-8f2e-df995765153c") {
    blog {
      createdAt
      id
      name
      updatedAt
    }
    blogPostsId
    createdAt
    id
    title
    updatedAt
  }
}
{
  "data": {
    "getPost": {
      "blog": {
        "createdAt": "2022-01-14T09:26:46.428Z",
        "id": "772460da-6d7a-4167-ad48-4fbb99d5a9ee",
        "name": "testBlog",
        "updatedAt": "2022-01-14T09:26:46.428Z"
      },
      "blogPostsId": "772460da-6d7a-4167-ad48-4fbb99d5a9ee",
      "createdAt": "2022-01-14T10:10:38.390Z",
      "id": "1b23932c-661d-4895-8f2e-df995765153c",
      "title": "testBlog-testPost1",
      "updatedAt": "2022-01-14T10:10:38.390Z"
    }
  }
}

今回の場合だとblogPostsIdというIdがPostに追加され、getQueryを使うとPostからBlog、BlogからPostが引けるようになる

DynamoDBの生データはこんな感じ

Blog
{
  "id": {
    "S": "772460da-6d7a-4167-ad48-4fbb99d5a9ee"
  },
  "__typename": {
    "S": "Blog"
  },
  "createdAt": {
    "S": "2022-01-14T09:26:46.428Z"
  },
  "name": {
    "S": "testBlog"
  },
  "updatedAt": {
    "S": "2022-01-14T09:26:46.428Z"
  }
}
Post
{
  "id": {
    "S": "1b23932c-661d-4895-8f2e-df995765153c"
  },
  "__typename": {
    "S": "Post"
  },
  "updatedAt": {
    "S": "2022-01-14T10:10:38.390Z"
  },
  "createdAt": {
    "S": "2022-01-14T10:10:38.390Z"
  },
  "blogPostsId": {
    "S": "772460da-6d7a-4167-ad48-4fbb99d5a9ee"
  },
  "title": {
    "S": "testBlog-testPost1"
  }
}

__typenameが追加されるのはGraphQL Transformerの旧バージョンと同じ、blogPostsIdは前述のとおり自動で追加されている

Nobuyuki ItoNobuyuki Ito

@belongsTo を消してみても特にidは変化なし。
単純にgetPost側からBlogが引けなくなる。

↑で書き忘れたけど、blogPostsIdはGSIのパーティションキーになる。
このデフォルト設定だとソートキーがないので、Blog側からPostを時系列でとってこれないので不便そう・・・

Nobuyuki ItoNobuyuki Ito

DynamoDB的に正しいかはおいておいて、hasOneを試す

schema
type Blog @model 
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  posts: [Post] @hasMany
  user: User @belongsTo
}

type Post @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  title: String!
  # blog: Blog @belongsTo
}

type User @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  blog: Blog @hasOne
}
User
{
  "id": {
    "S": "e377dc1e-429f-4cdc-a7a3-9cca42813633"
  },
  "__typename": {
    "S": "User"
  },
  "updatedAt": {
    "S": "2022-01-14T11:22:52.834Z"
  },
  "createdAt": {
    "S": "2022-01-14T11:22:52.834Z"
  },
  "name": {
    "S": "testUser"
  },
  "userBlogId": {
    "S": "772460da-6d7a-4167-ad48-4fbb99d5a9ee"
  }
}
Blog
{
  "id": {
    "S": "772460da-6d7a-4167-ad48-4fbb99d5a9ee"
  },
  "__typename": {
    "S": "Blog"
  },
  "updatedAt": {
    "S": "2022-01-14T11:24:11.729Z"
  },
  "createdAt": {
    "S": "2022-01-14T09:26:46.428Z"
  },
  "blogUserId": {
    "S": "e377dc1e-429f-4cdc-a7a3-9cca42813633"
  },
  "name": {
    "S": "testBlog"
  }
}

それぞれblogUserIdとuserBlogIdが出現する。

Nobuyuki ItoNobuyuki Ito

manyTomany

・Followerみたいなテーブルは作れない
https://amplify-sns.workshop.aws/ja/50_follow_timeline/00_follow_back_end.html

type FollowRelationship
	@model(
    mutations: {create: "createFollowRelationship", delete: "deleteFollowRelationship", update: null}
    timestamps: null
  )
	@auth(rules: [
		{allow: owner, ownerField:"followerId", provider: userPools, operations:[read, create, delete]},
		{allow: private, provider: userPools, operations:[read]}
	])
	@key(fields: ["followeeId", "followerId"])
{
	followeeId: ID!
	followerId: ID!
	timestamp: Int!
}

これを

type User @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  blog: Blog @hasOne
  follower: [User] @manyToMany (relationName: "follower")
}

としても

× An error occurred when pushing the resources to the cloud
🛑 An error occurred during the push operation: @manyToMany relation 'Follower' must be used in exactly two locations.

となってNG

下記のように2二種類のモデルの間でだけ作れるリレーションになっている。

type Post @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  title: String!
  # blog: Blog @belongsTo
  tags: [Tags] @manyToMany(relationName: "PostTags")
}

type Tags @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  label: String!
  posts: [Post] @manyToMany(relationName: "PostTags")
}

自動でPostTagsというテーブルが間にできる

二者間の関係を作る場合は別途

  createPostTags(input: {postID: "12fce11d-e5c0-4c83-b879-06121fff3a4b", tagsID: "5e7a83ee-fc10-468f-9744-a2d080efcc4b"}) {
    id
  }

みたいなcreate Mutationで書き込む。
テーブルの実体がschemaに出現しないので、authまわりとかを設定できない気がする。

リレーションを削除したときのテーブル削除については、ちゃんと警告が出る
🛑 An error occurred during the push operation: Removing a model from the GraphQL schema will also remove the underlying DynamoDB table.
This update will remove table(s) [PostTags]
ALL EXISTING DATA IN THESE TABLES WILL BE LOST!
If this is intended, rerun the command with '--allow-destructive-graphql-schema-updates'.

Nobuyuki ItoNobuyuki Ito

さてじゃあアクセス権限どうなってるのか、と思ったのだけれどもどうもこのあたりが全部マッピングテンプレートに入っているらしい。
IAMにもarn:aws:iam:::policy/-UnauthRolePolicy* というのができるが、こちらはすべてアクセス権がありになる。
パイプラインレゾルバーのMutationcreatePostauth0Function の中に

## [Start] Authorization Steps. **
$util.qr($ctx.stash.put("hasAuth", true))
#set( $inputFields = $util.parseJson($util.toJson($ctx.args.input.keySet())) )
#set( $isAuthorized = false )
#set( $allowedFields = [] )
#if( $util.authType() == "IAM Authorization" )
  #set( $adminRoles = ["***"] )
  #foreach( $adminRole in $adminRoles )
    #if( $ctx.identity.userArn.contains($adminRole) && $ctx.identity.userArn != $ctx.stash.authRole && $ctx.identity.userArn != $ctx.stash.unauthRole )
      #return($util.toJson({}))
    #end
  #end
  #if( $ctx.identity.userArn == $ctx.stash.unauthRole )
    #set( $isAuthorized = true )
  #end
#end
#if( !$isAuthorized && $allowedFields.isEmpty() )
$util.unauthorized()
#end
#if( !$isAuthorized )
  #set( $deniedFields = $util.list.copyAndRemoveAll($inputFields, $allowedFields) )
  #if( $deniedFields.size() > 0 )
    $util.error("Unauthorized on ${deniedFields}", "Unauthorized")
  #end
#end
$util.toJson({"version":"2018-05-29","payload":{}})
## [End] Authorization Steps. **

といった感じでuserArnをチェックしているらしい。

authを {allow: private, provider: iam, operations: [read, create, update, delete]}
に変えると

  #if( $ctx.identity.userArn == $ctx.stash.unauthRole )
    #set( $isAuthorized = true )
  #end

  #if( ($ctx.identity.userArn == $ctx.stash.authRole) || ($ctx.identity.cognitoIdentityPoolId == "***" && $ctx.identity.cognitoIdentityAuthType == "authenticated") )
    #set( $isAuthorized = true )
  #end

へ変化。

Nobuyuki ItoNobuyuki Ito

SortKeyを利用できるようにするhasManyリレーション
上記にも書いたように、hasManyのリレーションでは新しくGSIは貼られるが、ソートしても時間順にはできない。(ソートキーが設定されていないため。)

query MyQuery {
  getBlog(id: "772460da-6d7a-4167-ad48-4fbb99d5a9ee") {
    posts(sortDirection: ASC) {
      items {
        id
        title
        createdAt
        updatedAt
      }
    }
  }
}
{
  "data": {
    "getBlog": {
      "posts": {
        "items": [
          {
            "id": "1b23932c-661d-4895-8f2e-df995765153c",
            "title": "testBlog-testPost1",
            "createdAt": "2022-01-14T10:10:38.390Z",
            "updatedAt": "2022-01-14T10:10:38.390Z"
          },
          {
            "id": "3b190941-2ec4-49d5-914a-312b7a5903d2",
            "title": "BBB",
            "createdAt": "2022-01-17T03:36:23.794Z",
            "updatedAt": "2022-01-17T03:36:23.794Z"
          },
          {
            "id": "84b42f46-1c80-4959-a789-94404b784d93",
            "title": "testpost2",
            "createdAt": "2022-01-15T08:30:08.024Z",
            "updatedAt": "2022-01-15T08:30:08.024Z"
          },
          {
            "id": "1ed5557b-e7bd-4b79-9eee-c302fa89e12e",
            "title": "testpost3",
            "createdAt": "2022-01-15T08:30:14.395Z",
            "updatedAt": "2022-01-15T08:30:14.395Z"
          },
          {
            "id": "f57dc0f7-b30b-4fdf-9d69-fdbc74aa89a1",
            "title": "testpost4",
            "createdAt": "2022-01-15T08:30:20.025Z",
            "updatedAt": "2022-01-15T08:30:20.025Z"
          },
          {
            "id": "09f5456b-1133-4a1c-b83d-e2a8e15352d8",
            "title": "AAA",
            "createdAt": "2022-01-17T03:36:11.559Z",
            "updatedAt": "2022-01-17T03:36:11.559Z"
          },
          {
            "id": "282e4347-cba2-4d57-9429-9ef8a0c78348",
            "title": "CCC",
            "createdAt": "2022-01-17T03:35:39.582Z",
            "updatedAt": "2022-01-17T03:35:39.582Z"
          }
        ]
      }
    }
  }
}
Nobuyuki ItoNobuyuki Ito
type Blog @model 
@auth(rules: [
  {allow: public, provider: iam, operations: [read]},
  {allow: private, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  posts: [Post] @hasMany (indexName: "byBlogPostsId", fields:["id"])
  user: User @belongsTo
}

type Post @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read]},
  {allow: private, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID! @primaryKey
  title: String!
  blogPostsId: ID @index(name: "byBlogPostsId", sortKeyFields: ["updatedAt"])
  # blog: Blog @belongsTo
  tags: [Tags] @manyToMany(relationName: "PostTags")
  createdAt: AWSDateTime
  updatedAt: AWSDateTime
}

というわけで、こんな感じにPostに明示的にblogPostsIdを追加、さらにindexを貼る(sortkeyをupdatedAtに)
BlogのhasManyにindexNameとfieldsを指定すると・・・

query MyQuery {
  getBlog(id: "772460da-6d7a-4167-ad48-4fbb99d5a9ee") {
    posts(sortDirection: ASC) {
      items {
        id
        title
        createdAt
        updatedAt
      }
    }
  }
}
{
  "data": {
    "getBlog": {
      "posts": {
        "items": [
          {
            "id": "1b23932c-661d-4895-8f2e-df995765153c",
            "title": "testBlog-testPost1",
            "createdAt": "2022-01-14T10:10:38.390Z",
            "updatedAt": "2022-01-14T10:10:38.390Z"
          },
          {
            "id": "84b42f46-1c80-4959-a789-94404b784d93",
            "title": "testpost2",
            "createdAt": "2022-01-15T08:30:08.024Z",
            "updatedAt": "2022-01-15T08:30:08.024Z"
          },
          {
            "id": "1ed5557b-e7bd-4b79-9eee-c302fa89e12e",
            "title": "testpost3",
            "createdAt": "2022-01-15T08:30:14.395Z",
            "updatedAt": "2022-01-15T08:30:14.395Z"
          },
          {
            "id": "f57dc0f7-b30b-4fdf-9d69-fdbc74aa89a1",
            "title": "testpost4",
            "createdAt": "2022-01-15T08:30:20.025Z",
            "updatedAt": "2022-01-15T08:30:20.025Z"
          },
          {
            "id": "282e4347-cba2-4d57-9429-9ef8a0c78348",
            "title": "CCC",
            "createdAt": "2022-01-17T03:35:39.582Z",
            "updatedAt": "2022-01-17T03:35:39.582Z"
          },
          {
            "id": "09f5456b-1133-4a1c-b83d-e2a8e15352d8",
            "title": "AAA",
            "createdAt": "2022-01-17T03:36:11.559Z",
            "updatedAt": "2022-01-17T03:36:11.559Z"
          },
          {
            "id": "3b190941-2ec4-49d5-914a-312b7a5903d2",
            "title": "BBB",
            "createdAt": "2022-01-17T03:36:23.794Z",
            "updatedAt": "2022-01-17T03:36:23.794Z"
          }
        ]
      }
    }
  }
}

無事、時間でソートできるようになりました!
DynamoDBテーブルのGSIは新しく作り替わっています。
(ちなみに今回の変更ではCLI上で警告が出ることはありませんでした)

GSIのキー名はblogPostsIdのままにしたため、データについては特にいじることなく変更が適用されています。

Nobuyuki ItoNobuyuki Ito

ちなみに

type Post @model
@auth(rules: [
  {allow: public, provider: iam, operations: [read]},
  {allow: private, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  id2: ID! @primaryKey
  title: String!
  blogPostsId: ID @index(name: "byBlogPostsId", sortKeyFields: ["updatedAt"])
  # blog: Blog @belongsTo
  tags: [Tags] @manyToMany(relationName: "PostTags")
  createdAt: AWSDateTime
  updatedAt: AWSDateTime
}

誤ってprimaryKeyを違うカラムにした場合は
× An error occurred when pushing the resources to the cloud
🛑 An error occurred during the push operation: Editing the primary key of a model requires replacement of the underlying DynamoDB table.
This update will replace table(s) [PostTable]
ALL EXISTING DATA IN THESE TABLES WILL BE LOST!
If this is intended, rerun the command with '--allow-destructive-graphql-schema-updates'.
とエラーになってくれます。

ここまでの操作でうっかりデータが消える操作はこの警告が出てくれるように思います。

Nobuyuki ItoNobuyuki Ito

自動生成のQuery/Mutaion/Subscriptionの名前変更・抑制
https://docs.amplify.aws/cli/graphql/data-modeling/#rename-generated-queries-mutations-and-subscriptions
こちらに機能解説があります。
特にlistクエリは不要だけれど、アクセス権としてはreadという形でgetクエリと同時に管理することになってしまう、というのを回避することができるようになる、というのが魅力だなと思いました。

例えば

type User @model (queries: {list: null, get: "getUser"}  subscriptions:null)
@auth(rules: [
  {allow: public, provider: iam, operations: [read]},
  {allow: private, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID!
  name: String!
  blog: Blog @hasOne
}

とすると、subscriptionは存在しなくなり、queryもgetUserクエリのみがデプロイ(生成)されます。
注意ポイントがあって

  1. 片方をnullにした際に何も記載しないと別のクエリも消えてしまう

@model (queries: {list: null })
とするとgetクエリも消えてしまいました

  1. 名前はプレフィックス指定ではなく全体の名前を指定する

@model (queries: {list: null, get: "get"})
とすると、getというクエリができてしまいます。
getUserとしないといけません。
こちらはdocsからは読み取りづらかったです。

Nobuyuki ItoNobuyuki Ito

Queryの生成制御について調べてあれ、とおもったので追記。
GSIのフィールドをクエリするためには・・・

type Post @model (queries: {list: null, get: "getPost"}  subscriptions:null)
@auth(rules: [
  {allow: public, provider: iam, operations: [read]},
  {allow: private, provider: iam, operations: [read, create, update, delete]}
])
{
  id: ID! @primaryKey
  title: String!
  blogPostsId: ID @index(name: "byBlogPostsId", sortKeyFields: ["updatedAt"], queryField: "getPostsByBlogPostsId")
  # blog: Blog @belongsTo
  tags: [Tags] @manyToMany(relationName: "PostTags")
  createdAt: AWSDateTime
  updatedAt: AWSDateTime
}

blogPostsId: ID @index(name: "byBlogPostsId", sortKeyFields: ["updatedAt"], queryField: "getPostsByBlogPostsId")
こんな感じでqueryFieldを足せばOK
上記の例のようにqueryの生成制御と同時に設定できる。

Nobuyuki ItoNobuyuki Ito

https://github.com/aws-amplify/amplify-ui/discussions/1124
これを私も踏んでしまった。

こんな感じで行けそうだけど、回答ついてないんだよな~

  const cognitoSetting  = amplifyConfiguration.aws_cognito_password_protection_settings as {
    passwordPolicyMinLength: number;
    passwordPolicyCharacters: ("REQUIRES_LOWERCASE" | "REQUIRES_NUMBERS" | "REQUIRES_SYMBOLS" | "REQUIRES_UPPERCASE")[];
  }
  const errorKeyList = ["confirm_password",
                        "is_password_shorter",
                        "is_password_longer",
                        "require_lowercase",
                        "require_numbers",
                        "require_symbols",
                        "require_uppercase"]

return(
      <Authenticator
        initialState="signUp"
        components={{
          SignUp: {
            FormFields() {
              const {validationErrors}= useAuthenticator()

              return (
                <>
                  <Authenticator.SignUp.FormFields />
                  {validationErrors && errorKeyList.map((key) => {
                    if (validationErrors[key]) {
                      return (
                        <div key={key} style={{color: "red"}}>
                          {validationErrors[key]}
                        </div>
                      )
                    }
                  })
                  }
                </>
              )
            },
          },
        }}
        services={{
          async validateConfirmPassword(
            formData: { password: string; confirm_password: string },
            touchData: { password: boolean; confirm_password: boolean }
          ) {
            if (!formData.password && !formData.confirm_password) {
              return null
            } else if (touchData.password && touchData.confirm_password) {
              const errorObj: {
                confirm_password?: string,
                is_password_shorter?: string,
                is_password_longer?: string,
                require_lowercase?: string,
                require_numbers?: string,
                require_symbols?: string,
                require_uppercase?: string,
              } = {}
              if (formData.password !== formData.confirm_password) {
                errorObj.confirm_password = 'Your passwords must match'
              } else {
                if (formData.password.length < cognitoSetting.passwordPolicyMinLength) {
                  errorObj.is_password_shorter = `Your passwords' length should be greater than ${cognitoSetting.passwordPolicyMinLength}`
                } else if  (formData.password.length > 99) {
                  errorObj.is_password_longer = `Your passwords' length should be shorter than 99`
                }
                for (const policy of cognitoSetting.passwordPolicyCharacters) {
                  switch(policy) {
                    case "REQUIRES_LOWERCASE":
                      if (!/[a-z]/.test(formData.password)){
                        errorObj.require_lowercase = 'Your passwords should contain lower case alphabet'
                      }
                      break
                    case "REQUIRES_SYMBOLS":
                      if (!/[0-9]/.test(formData.password)){
                        errorObj.require_lowercase = 'Your passwords should contain number'
                      }
                      break
                    case "REQUIRES_NUMBERS":
                      if (!/[\^\$\*\.\[\]\{\}\(\)\?\"\!\@\#\%\&\/\\\,\>\<\'\:\;\|\_\~\`]/.test(formData.password)){
                        errorObj.require_numbers = 'Your passwords should contain special character'
                      }
                      break
                    case "REQUIRES_UPPERCASE":
                      if (!/[A-Z]/.test(formData.password)){
                        errorObj.require_uppercase = 'Your passwords should contain upper case alphabet'
                      }
                      break
                  }
                }
              }
              return errorObj
            }
          },
        }}
      >
        {({ signOut, user }) => (
          <div>
            <h1>Hello {user.username}</h1>
            <button onClick={signOut}>Sign out</button>
          </div>
        )}
      </Authenticator>
)