aws-amplify / amplify-swift

A declarative library for application development using cloud services.
Apache License 2.0
447 stars 193 forks source link

Amplify.API.query(request: ) + nextToken? #3371

Open agrabz opened 10 months ago

agrabz commented 10 months ago

Describe the bug

I'll need to pull my data from different backends (the goal on the long term is to use Amplify only, but that's going to be a longer migration process), so I need to make sure that I'm really not dependent on any backend.

It's anyway a common concept that the app code should not depend on any SDK- or backend-signatures, therefore when I retrieve an Amplify.List of MyStuff objects (where MyStuff was generated with codegen), I convert them to an (Swift)Foundation.Array of MyStuffDTO (where MyStuffDTO was coded manually and is more or less a copy of MyStuff). With this conversion I can ensure that my application doesn't depend on one single SDK and backend and I can add any additional datasources in the future with ease, i.e. following the same conversion and always use MyStuffDTO in the app, independently from the backend (Amplify or 3rd party).

The way how I resolved pagination in my other apps is either to store the offset/skip+top pair, or the nextToken as part of a technical object called for example Pagination and send them with every request. However I see that Amplify took a really different approach and does not expose the nextToken as a public result of the function Amplify.API.query(request: ). This makes my usual conversion approach really hard. I can come up with a workaround, but it's hard to resolve this problem by not violating the principle of "client should not depend on backend technology".

I found that there's a similar function that does return the nextToken: Amplify.API.query(request: .syncQuery(modelType: ) However I'm not sure how to use it. Is it documented somewhere maybe? I couldn't find its docs. When I use it as any of the below ways, it fails with the same error: Amplify.API.query(request: .syncQuery(modelType: MyStuff.self)) Amplify.API.query(request: .syncQuery(modelSchema: MyStuff.schema))

Error: GraphQLResponseError<PaginatedList<AnyModel>>: GraphQL service returned a successful response containing errors: [Amplify.GraphQLError(message: "Validation error of type FieldUndefined: Field \'syncMyStuffs\' in type \'Query\' is undefined @ \'syncMyStuffs\'", locations: Optional([Amplify.GraphQLError.Location(line: 2, column: 3)]), path: nil, extensions: nil)]

Reading this might sound at this point a feature request, BUT I found the JavaScript docs, I also checked the Walkthrough (the expandable section in the page): Paginate list queries, which in my understanding states that the same function in JS does return the nextToken and should work out of the box. This is to me a major discrepancy between the Amplify JS and Swift libraries, hence the bug type. And of course because of the above mentioned "client should not depend on backend tech" principle, that I have to follow.

I have the below questions?

  1. How to use any of the below functions to overcome the above written error? Amplify.API.query(request: .syncQuery(modelType: MyStuff.self)) Amplify.API.query(request: .syncQuery(modelSchema: MyStuff.schema))
  2. If point 1. above cannot be resolved/those functions cannot be used for the purpose I want, do you agree that there's a discrepancy between the JS and Swift libraries that should be resolved?
  3. Is there any other way to achieve what I'd like to? I.e. to have a traceable, publicly exposed pagination state

Steps To Reproduce

1. Have a scheme with below:
type MyStuff @model @auth(rules: [{allow: public}]) {
  id: ID!
  version: Int!
  name: String!
}
2. Call any of the below functions to fetch a paginated list:
`Amplify.API.query(request: , limit: 10)`
`Amplify.API.query(request: .syncQuery(modelType: MyStuff.self, limit 10))`
`Amplify.API.query(request: .syncQuery(modelSchema: MyStuff.schema))`
3. The functions should return nextToken (1st one does not return nextToken) and no errors (2nd and 3rd return error below):
 `GraphQLResponseError<PaginatedList<AnyModel>>: GraphQL service returned a successful response containing errors: [Amplify.GraphQLError(message: "Validation error of type FieldUndefined: Field \'syncMyStuffs\' in type \'Query\' is undefined @ \'syncMyStuffs\'", locations: Optional([Amplify.GraphQLError.Location(line: 2, column: 3)]), path: nil, extensions: nil)]
`

Expected behavior

  1. Function Amplify.API.query(request: , limit: 10) should return nextToken AND then be capable to handle nextToken as input.

  2. Functions: Amplify.API.query(request: .syncQuery(modelType: MyStuff.self, limit 10)) Amplify.API.query(request: .syncQuery(modelSchema: MyStuff.schema)) 2.1. Should have documentation. 2.2. Should not return error.

  3. There should be some way provided by Amplify Swift library to allow iOS devs to trace pagination state.

Amplify Framework Version

latest

Amplify Categories

API

Dependency manager

Swift PM

Swift version

5.9

CLI version

12.7.1

Xcode version

15.0

Relevant log output

No response

Is this a regression?

No

Regression additional context

No response

Platforms

iOS

OS Version

iOS 17.1

Device

iPhone 15 Pro simulator

Specific to simulators

No

Additional context

I see many assertionFailure in the Amplify code for hasNextPage and getNextPage, which is really not nice. An error should be thrown instead, or nil returned or literally anything else.

harsh62 commented 10 months ago

@agrabz Thanks for opening the issue. Our team will investigate and provide updates on the issue as soon as we have more information.

lawmicha commented 10 months ago

Hi @agrabz,

Thanks for the details, as you may have noticed the Amplify List contains a lot of functionality that abstracts away the next token, allow query depth control, and connected models traversal. As for your request, we will have to investigate how we can better provide the lower level alternative like you saw in the JS experience.

The API will most likely look like this:

import AWSPluginsCore

public struct ListQueryResult<ModelType: Model>: Decodable {
    public let items: [ModelType]
    public let nextToken: String?
}

extension GraphQLRequest {
    public static func listQuery<M: Model>(_ modelType: M.Type,
                                           where predicate: QueryPredicate? = nil,
                                           limit: Int? = nil,
                                           nextToken: String? = nil,
                                           authType: AWSAuthorizationType? = nil) -> GraphQLRequest<ListQueryResult<M>> {
        var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema,
                                                               operationType: .query,
                                                               primaryKeysOnly: true)
        documentBuilder.add(decorator: DirectiveNameDecorator(type: .list))
        if let predicate = predicate {
            documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelType.schema)))
        }
        documentBuilder.add(decorator: PaginationDecorator(limit: limit, nextToken: nextToken))
        documentBuilder.add(decorator: AuthRuleDecorator(.query, authType: authType))
        let document = documentBuilder.build()

        let awsPluginOptions = AWSPluginOptions(authType: authType, modelName: modelType.schema.name)
        let requestOptions = GraphQLRequest<ListQueryResult<M>>.Options(pluginOptions: awsPluginOptions)

        return GraphQLRequest<ListQueryResult<M>>(document: document.stringValue,
                                               variables: document.variables,
                                               responseType: ListQueryResult<M>.self,
                                               decodePath: document.name,
                                               options: requestOptions)
    }
}

Usage:

let graphQLResponse = try await Amplify.API.query(request: .listQuery(Post3.self))
guard case let .success(listQueryResults) = graphQLResponse else {
    XCTFail("Missing successful response")
    return
}

XCTAssertTrue(!listQueryResults.items.isEmpty)
XCTAssertTrue(!listQueryResults.nextToken.isEmpty)

One of the challenges we face with adding this to our library publicly is that the re-use of the code generated Model types, although makes it easier to use as part of the decoding type, also holds the high level types (LazyReference and Amplify.List type mentioned in the beginning) we built to facilitate the lazy loading and pagination support. Calling the listQuery (code snippet above) and then trying to traverse to the connected models will not have the expected outcome. Using the .list API that we provide hooks into the rest of the functionality we've built.

However, if the traversal to connected models isn't something you need right now, you can get unblocked with the code snippet above. The caveat is that this code uses "public but internal" interfaces that may change in the future versions of Amplify. If you'd like to create a more simple version but closer to your data modeling use cases, you can hardcode the document string in the GraphQL request and reuse ListQueryResult above. When hardcoding the document string, make sure to include the nextToken and the required fields of your data model. You can see some examples of the construction here https://docs.amplify.aws/swift/build-a-backend/graphqlapi/advanced-workflows/

github-actions[bot] commented 10 months ago

This has been identified as a feature request. If this feature is important to you, we strongly encourage you to give a 👍 reaction on the request. This helps us prioritize new features most important to you. Thank you!