AWS Amplify GraphQL Transform can automatically create the backend API according to your data model schema. With built-in Directives, such as @model, @connection, @key, and @searchable, Amplify further facilitates the connection between your data models with other Amazon Web services including DynamoDB and ElasticSearch.
For example, with the data models, Collection and Archive, our initial amplify/backend/api/api_name/schema.graphql might look like:
type Collection
@model
@searchable
@key(
fields: ["identifier"]
name: "Identifier"
queryField: "collectionByIdentifier"
) {
id: ID!
title: String!
identifier: String!
description: String
date: String
collection_category: String!
visibility: Boolean!
parent_collection: [String!]
archives: [Archive] @connection(name: "CollectionArchives")
}
type Archive
@model
@searchable
@key(
fields: ["identifier"]
name: "Identifier"
queryField: "archiveByIdentifier"
) {
id: ID!
title: String!
identifier: String!
description: String
date: String
parent_collection: [String!]
item_category: String!
visibility: Boolean!
collection: Collection @connection(name: "CollectionArchives")
}
By running “amplify push”, a full CloudFormation template will be set up for you with your new API. In the graphql/queries.js, you can see the automatically generated queries. In particular, @searchable directive streams your data into the ElasticSearch engine with searchArchives and searchCollections queries that are ready at your disposal.
/* eslint-disable */
// this is an auto generated file. This will be overwritten
export const getCollection = /* GraphQL */ `
query GetCollection($id: ID!) {
getCollection(id: $id) {
id
title
identifier
description
creator
source
circa
date
collection_category
visibility
parent_collection
archives {
items {
id
title
identifier
description
date
parent_collection
item_category
visibility
}
nextToken
}
}
}
`;
export const listCollections = /* GraphQL */ `
query ListCollections(
$filter: ModelCollectionFilterInput
$limit: Int
$nextToken: String
) {
listCollections(filter: $filter, limit: $limit, nextToken: $nextToken) {
items {
id
title
identifier
description
date
collection_category
visibility
parent_collection
archives {
nextToken
}
}
nextToken
}
}
`;
export const getArchive = /* GraphQL */ `
query GetArchive($id: ID!) {
getArchive(id: $id) {
id
title
identifier
description
date
parent_collection
item_category
visibility
collection {
id
title
identifier
description
date
collection_category
visibility
parent_collection
archives {
nextToken
}
}
}
}
`;
export const listArchives = /* GraphQL */ `
query ListArchives(
$filter: ModelArchiveFilterInput
$limit: Int
$nextToken: String
) {
listArchives(filter: $filter, limit: $limit, nextToken: $nextToken) {
items {
id
title
identifier
description
date
parent_collection
item_category
visibility
collection {
id
title
identifier
description
date
collection_category
visibility
parent_collection
}
}
nextToken
}
}
`;
export const collectionByIdentifier = /* GraphQL */ `
query CollectionByIdentifier(
$identifier: String
$sortDirection: ModelSortDirection
$filter: ModelCollectionFilterInput
$limit: Int
$nextToken: String
) {
collectionByIdentifier(
identifier: $identifier
sortDirection: $sortDirection
filter: $filter
limit: $limit
nextToken: $nextToken
) {
items {
id
title
identifier
description
date
collection_category
visibility
parent_collection
archives {
nextToken
}
}
nextToken
}
}
`;
export const archiveByIdentifier = /* GraphQL */ `
query ArchiveByIdentifier(
$identifier: String
$sortDirection: ModelSortDirection
$filter: ModelArchiveFilterInput
$limit: Int
$nextToken: String
) {
archiveByIdentifier(
identifier: $identifier
sortDirection: $sortDirection
filter: $filter
limit: $limit
nextToken: $nextToken
) {
items {
id
title
identifier
description
date
parent_collection
item_category
visibility
collection {
id
title
identifier
description
date
collection_category
visibility
parent_collection
}
}
nextToken
}
}
`;
export const searchCollections = /* GraphQL */ `
query SearchCollections(
$filter: SearchableCollectionFilterInput
$sort: SearchableCollectionSortInput
$limit: Int
$nextToken: String
) {
searchCollections(
filter: $filter
sort: $sort
limit: $limit
nextToken: $nextToken
) {
items {
id
title
identifier
description
date
collection_category
visibility
parent_collection
archives {
nextToken
}
}
nextToken
total
}
}
`;
export const searchArchives = /* GraphQL */ `
query SearchArchives(
$filter: SearchableArchiveFilterInput
$sort: SearchableArchiveSortInput
$limit: Int
$nextToken: String
) {
searchArchives(
filter: $filter
sort: $sort
limit: $limit
nextToken: $nextToken
) {
items {
id
title
identifier
description
date
parent_collection
item_category
visibility
collection {
id
title
identifier
description
date
collection_category
visibility
parent_collection
}
}
nextToken
total
}
}
`;
```
Now what if the application requires to search for both Collection and Archive records with sorting capability by some common field such as “title”? This can be achieved by using GraphQL’s interface or union type as it represents the generalization of multiple types. Unfortunately, Amplify GraphQL Transform does not have @searchable directive support for either of them. This comes with our problem to solve:
Create custom resolvers for Interface/Union type to search across multiple indices
To provide this customized search query, we need to add corresponding types into the schema and then attach request/response resolvers to the query. It is much more intuitive to iterate the process of modifying the schema, implementing the resolvers, and testing the query by working directly in the AWS AppSync console.
- Update $indexPath to perform multi-document search or wildcard search
- The Utility helper $util.transform.toElasticSearchQueryDSL defaults by AND operation. If your Query logic involves other complex operations, write your own ElasticSearch Query DSL. For example, the OR operation can be achieved by using the “should” clause and “minimum_should_match” parameter.
- Make sure your query accepts external filter inputs, e.g., by using the "must" clause
- Update schemal.graphql. Because GraphQL Transform does not support @searchable for interface, you will need to add some extra inputs in order to make the schema compile successfully.
``` interface Object { id: ID! title: String! identifier: String! description: String date: String visibility: Boolean! parent_collection: [String!] } type Collection implements Object @model @searchable @key( fields: ["identifier"] name: "Identifier" queryField: "collectionByIdentifier" ) { id: ID! title: String! identifier: String! description: String date: String collection_category: String! visibility: Boolean! parent_collection: [String!] archives: [Archive] @connection(name: "CollectionArchives") } type Archive implements Object @model @searchable @key( fields: ["identifier"] name: "Identifier" queryField: "archiveByIdentifier" ) { id: ID! title: String! identifier: String! description: String date: String parent_collection: [String!] item_category: String! visibility: Boolean! collection: Collection @connection(name: "CollectionArchives") } type Query { searchObjects( sort: SearchableObjectSortInput filter: SearchableObjectFilterInput limit: Int nextToken: String category: String ): SearchableObjectConnection } type SearchableObjectConnection { items: [Object] nextToken: String total: Int } input SearchableObjectFilterInput { id: SearchableIDFilterInput title: SearchableStringFilterInput identifier: SearchableStringFilterInput description: SearchableStringFilterInput date: SearchableStringFilterInput visibility: SearchableBooleanFilterInput parent_collection: SearchableStringFilterInput and: [SearchableObjectFilterInput] or: [SearchableObjectFilterInput] not: SearchableObjectFilterInput } input SearchableBooleanFilterInput { eq: Boolean ne: Boolean } input SearchableObjectSortInput { field: SearchableObjectSortableFields direction: SearchableSortDirection } enum SearchableObjectSortableFields { id title identifier description date } input SearchableIDFilterInput { ne: ID gt: ID lt: ID gte: ID lte: ID eq: ID match: ID matchPhrase: ID matchPhrasePrefix: ID multiMatch: ID exists: Boolean wildcard: ID regexp: ID } enum SearchableSortDirection { asc desc } input SearchableStringFilterInput { ne: String gt: String lt: String gte: String lte: String eq: String match: String matchPhrase: String matchPhrasePrefix: String multiMatch: String exists: Boolean wildcard: String regexp: String } ```
- Copy and paste the resolvers into resolvers directory with file names: Query.searchObjects.req.vtl and Query.searchObjects.res.vtl.
- Update amplify/backend/api/api_name/stacks/CustomResouces.json as:
{//Anything before "Resouces" remains the same "Resources": { "QuerySearchObjectResolver": { "Type": "AWS::AppSync::Resolver", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "DataSourceName": "ElasticSearchDomain", "TypeName": "Query", "FieldName": "searchObjects", "RequestMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchObjects.req.vtl", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" } } ] }, "ResponseMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchObjects.res.vtl", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" } } ] } } } }, //Anything after "Resources" remains the same ```
- Step 1. Modify your schema in AppSync console and click "Save Schema"
Based on your modeling logic, if you chose interface type, you might modify your schema like this:
interface Object {
id: ID!
title: String!
identifier: String!
description: String
date: String
visibility: Boolean!
parent_collection: [String!]
}
type Collection implements Object
# the rest is unchanged
type Archive implements Object
# the rest is unchanged
type Query {
searchObjects(
sort: SearchableCollectionSortInput
filter: SearchableCollectionFilterInput
limit: Int
nextToken: String
category: String
): SearchableObjectConnection
# the rest is unchanged
}
type SearchableObjectConnection {
items: [Object]
nextToken: String
total: Int
}
The union type follows a similar schema change with the absence of implementing the union type. Simply create the union type like this:
union Object = Collection | Archive
- Step 2. Attach resolvers to the custom search Query
- Step 3. Choose "ElasticSearchDomain" as data source and write request/response resolvers:
To provide a starting point, GraphQL Transform auto-generated resolvers (via @searchable in the original schema) can be served as a template for your custom resolvers. They are Query.searchModels.req.vtl and Query.searchModels.res.vtl, which can be found at amplify/backend/api/your_api_name/build/resolvers.
When implementing the request resolver, there are certain points worth noting:Here is an example of a request resolver:
#set( $indexPath = "/*/doc/_search" )
#set( $nonKeywordFields = ["visibility"] )
#if( $util.isNullOrEmpty($context.args.sort) )
#set( $sortDirection = "desc" )
#set( $sortField = "id" )
#else
#set( $sortDirection = $util.defaultIfNull($context.args.sort.direction, "desc") )
#set( $sortField = $util.defaultIfNull($context.args.sort.field, "id") )
#end
#if( $nonKeywordFields.contains($sortField) )
#set( $sortField0 = $util.toJson($sortField) )
#else
#set( $sortField0 = $util.toJson("${sortField}.keyword") )
#end
{
"version": "2018-05-29",
"operation":"GET",
"path":"$indexPath",
"params":{
"body":{
#if( $context.args.nextToken ) "search_after": [$util.toJson($context.args.nextToken)], #end
"size": #if( $context.args.limit ) $context.args.limit #else 10 #end,
"sort": [{$sortField0: { "order" : $util.toJson($sortDirection) }}],
"query": {
"bool": {
#if( $context.args.filter ) "must": $util.transform.toElasticsearchQueryDSL($ctx.args.filter), #end
"filter": {
"term": {
"visibility": "true"
}
},
"should": [
{
"bool": {
"must": {
"match": {
"collection_category": "$context.arguments.category"
}
},
"must_not": {
"exists": {
"field": "parent_collection"
}
}
}
},
{
"bool": {
"must": {
"match": {
"item_category": "$context.arguments.category"
}
}
}
}
],
"minimum_should_match": 1
}
}
}
}
}
For a response resolver, most GraphQL query results are extracted from the _source field in ElasticSearch response. However, to be able to identify different model types, __typename meta field is required for interface/union type. Thus, make sure to add __typename into _source for returned interface/union type.
This is an example of a response resolver:
#set( $es_items = [] )
#foreach( $entry in $context.result.hits.hits )
#if( !$foreach.hasNext )
#set( $nextToken = $entry.sort.get(0) )
#end
#foreach ( $mapEntry in $entry.entrySet() )
#if( $mapEntry.key == "_source" )
#if( $mapEntry.value.get("collection_category") )
$util.qr( $mapEntry.value.put("__typename", "Collection") )
#else
$util.qr( $mapEntry.value.put("__typename", "Archive") )
#end
#end
#end
$util.qr( $es_items.add($entry.get("_source")) )
#end
$util.toJson({
"items": $es_items,
"total": $ctx.result.hits.total,
"nextToken": $nextToken
})
- Step 4. Test out custom resolvers in AppSync queries
- Step 5. Add resolvers to your API and attach custom resources
After the search query in AppSync works as expected, we need to update the API in Amplify. Running "amplify pull" will not update the API for you. Everything needs to be changed in amplify/backend/api/api_name; be careful not to touch anything in the amplify/backend/api/api_name/build directory.
- Step 6. Push API to AWS Cloud
To avoid the "Only one resolver is allowed per field" error, make sure to delete the resolvers attached to the custom search query (in AWS AppSync console) before running "amplify push."
With that, your custom search query is ready to use!
Top comments (1)
This is incredible! I’ve seen several people asking about this lately, searching for connected types in elasticSearch.
Thanks for sharing!