A Tryton integration for Graphene

Last week-end I tweeted that I had a PoC of a Tryton integration for Graphene.

Graphene is a GraphQL framework for Python. It consists of a type system and query language that allows to create APIs reachable through one specific webhook.

Here is the review of the implementation: https://codereview.tryton.org/304791002

Some of the requirements I had for this project:

  • Easily define GraphQL types
  • Tryton fields available in the GraphQL fields should be defined in trytond modules and thus evolve according to the activated tryton modules
  • GraphiQL support (WiP)
  • A Tryton Model can be mapped to multiple GraphQL types (WiP)

I also had another requirement myself: make this implementation support multiple databases.
I took a lot of ideas from the graphene-django implementation although the multi database support made everything a bit more complicated.

A small example


from graphene_tryton import ModelObjectType, GraphQLEndpoint

class Party(metaclass=PoolMeta):
    __name__ = 'party.party'
    @classmethod
    def graphql_fields(cls):
        return ['id', 'name', 'code', 'addresses', 'code_readonly']

class Address(metaclass=PoolMeta):
    __name__ = 'party.address'
    @classmethod
    def graphql_fields(cls):
        return ['id', 'name', 'party']

class PartyType(ModelObjectType):
    class Meta:
        name = 'Party'
        tryton_model = 'party.party'

class AddressType(ModelObjectType):
    class Meta:
        name = 'Address'
        tryton_model = 'party.address'


class Query(graphene.ObjectType):
    all_parties = graphene.List(PartyType)
    addresses_by_party = graphene.List(AddressType, code=graphene.String(required=True))

    def resolve_all_parties(root, info):
        pool = Pool()
        Party = pool.get('party.party')
        return Party.search([])

    def resolve_addresses_by_party(root, info, code):
        pool = Pool()
        Party = pool.get('party.party')

        try:
            party, = Party.search([
                    ('code', '=', code),
                    ])
            return party.addresses
        except ValueError:
            return []


@app.route('/<database_name>/party/graphql')
@with_pool
@with_transaction()
@GraphQLEndpoint(Query)
def party_graphql():
    pass

You could test it with the following GraphQL query (saved as t.graphql):

query {
    allParties {
        name
        code
        codeReadonly
    }
    partyByCode(code: "1") {
        name
        addresses {
            name
        }
    }
    addressesByParty(code: "4") {
        name
        party {
            name
            code
        }
    }
}

Thanks to this small curl command:

% curl --header "Content-Type: application/graphql" --data @t.graphql http://localhost:8000/graphql/party/graphql

The multi database support

As you can see from the small example the GraphQLEndpoint decorator takes a graphene.ObjectType argument, this argument is the schema of this endpoint.

The multidatabase support implies that multiple schemas are possible for this endpoint. The code I submitted use a lot of introspection on the graphene objects created in order to use the schema provided as a template and then on the first query that is bounded to a database, compute all the GraphQL types (thanks to the calls to graphql_fields) and the related schema. This computation is kept in cache for the next calls.

Mapping a tryton model to multiple GraphQL types

As I said this is not done yet. But I think I can reach this goal by replacing the way graphql_fields work. Right now it returns a list of field names. It could return a dictionary that specify the name of a type and the fields that are available for this type. Relational fields will have to specify which type name they are pointing to.

Tryton to graphene type mapping

Most of the types are supported either through graphene.Scalar types or graphene.List types. There are some exception though.

Lack of timedelta support

graphene do not support timedelta fields.

One way to support those would be to have a specific resolver for those that transform them into a float representing the number of seconds of the timedelta.

Handling fields.Selection

graphene handle those using a python Enum. The implementation creates a enum from the field definition.

Handling fields.Reference

GraphQL is typed, thus having a Reference field is a bit difficult. But I noticed today that types can be an union of types. I will exploit this in another iteration to support the reference fields.

GraphiQL support

One of the nice perks of GraphQL is that a lot of tools already work out of the box (from auto-completion in editors to data binding in javascript libraries). One of those (that is already supported by graphene-django) is GraphiQL which is kind of a query sandbox: https://www.onegraph.com/graphiql

As from my understanding it’s only a matter of serving an HTML page with some javascript libraries it’s not technically difficult to do. If we were not to include this feature in graphene-tryton adding it would only be a matter of subclassing GraphQLEndpoint to handle correctly those queries (so for me it’s not a big issue either way :smiley:).

Conclusion

This implementation do not relies on the tryton internals (but way more so on the graphene internals) so I think it could be a side project under the Tryton umbrella that will provide yet another way to access the tryton data.

So if after the review process if its quality is deemed sufficient by the community I propose to add it to https://hg.tryton.org

One of my interrogation is about testing this. I haven’t looked yet at how django is doing, it might be an inspiration :stuck_out_tongue_winking_eye:.

I think you should follow the same pattern as done in tryton tests: Create a module for it (under a separate package), configure some graphene models and use a python client to test the endpoints.

Does graphene have any testing client? Something that the test client available in flask.

Should not the returned value depends on the query or entrypoint or Type?
Maybe it could be passed as a context.

Why not name it just model = 'party.party'.

This sounds good. Is it using the tryton Cache? Because it should take care of module activation to clear the cache.

I think it should have a default generic Type which could be marked as default with an attribute.
Then we could have another method graphql_type(field) which may return a different Type for the relational field.

That’s what we do in other similar case like the SQLite database.

I do not this it is needed to create a module. An testing API can be defined using the standard module ir and res.

You’re right that differing entrypoints might return different fields. But the usual recommendation when creating GraphQL APIs is to keep a single namespace.

So I think that having different Types for the same model would be enough, no?

You’re right, I knew that some cache invalidation should happen in this case, I’ll use tryton’s Cache in this effect.

I don’t understand.
The field in the call to graphql_type is the relational field pointing to the Tryton model?

For an entrypoint, I guess.
I guess a common usage will be to create multiple entrypoint for different purpose like a warehouse application, a point of sale etc.

Yes but you must have a way to choose which one to use per entrypoint.

Yes so you can customize which Type to use based on the entrypoint in the context for example.

I don’t know, it’s not clear I think:

The most simple way I think about when it’s time to create a new endpoint is when you have a new independent “graph”. If you find that you have a whole new collection of types that don’t really relate to your existing types and are unlikely to ever in the future, then a new endpoint with a new graphql schema is appropriate.

As a warehouse share the products with PoS, I guess the guy saying what I am quoting would put them together in the same graph.

Anyway we should support both scenario if it’s just a matter of putting a key in the context.

OK I got it then. But the fields does not have a link to the model, does it? So I think the model should also be in the signature.

They have get_target method.