Custom resolvers

NOTICE: Custom resolvers are temporarily disabled. See this issue. Until certain candid and authorization issues are worked out, you'll want to create custom functions in your Rust or Motoko canisters that provide their own authorization and call into your graphql canister. See here and here for some information and examples.

DISCLAIMER: Custom resolvers have only been minimally tested. Information presented here may not be entirely accurate. If you find issues please get in contact with @lastmjs or open issues on the repository.

Though Sudograph generates many powerful CRUD operations for you, it will not be able to cover every conceivable requirement of your applications. Custom resolvers provide a way for you to create your own functionality that is accessible through the same GraphQL API as Sudograph's generated functionality. There are two main locations a resolver can be written, within the graphql canister or in a separate canister.

Resolvers within the graphql canister

You can see a full example of Rust custom resolvers here.

To write resolvers within your graphql canister, start by augmenting your schema, for example in canisters/graphql/src/schema.graphql:

type Query {
    custom_get(id: ID!): Message
}

type Mutation {
    custom_set(id: ID!, text: String): Boolean!
}

type Message {
    id: ID!
    text: String!
}

We've added one custom query and one custom mutation to the schema. Next we need to implement the resolvers in code.

To implement a resolver, we add an asynchronous function to the Rust file that contains our graphql_database macro invocation. The function should have the same name as the query or mutation in the schema, and should use parameter and return types that match the types in the schema. The return type should be a Result with the Ok variant matching the return type in the schema, and you should use sudograph::async_graphql::Error as the Err variant. Object types generated from your schema are automatically in scope in Rust, because they are generated by the graphql_database macro.

Type conversions between GraphQL and Rust can be found here.

Now we'll implement the custom resolvers for the query and mutation in canisters/graphql/src/graphql.rs:


#![allow(unused)]
fn main() {
use sudograph::graphql_database;

graphql_database!("canisters/graphql/src/schema.graphql");

type PrimaryKey = String;
type MessageStore = HashMap<PrimaryKey, Option<Message>>;

async fn custom_get(id: ID) -> Result<Option<Message>, sudograph::async_graphql::Error> {
    let message_store = sudograph::ic_cdk::storage::get::<MessageStore>();

    let message_option = message_store.get(&id.to_string());

    match message_option {
        Some(message) => {
            return Ok(message.clone());
        },
        None => {
            return Ok(None);
        }
    };
}

async fn custom_set(id: ID, text: Option<String>) -> Result<bool, sudograph::async_graphql::Error> {
    let message_store = sudograph::ic_cdk::storage::get_mut::<MessageStore>();

    let message = match text {
        Some(text_value) => Some(Message {
            id: id.clone(),
            text: text_value
        }),
        None => None
    };

    message_store.insert(id.to_string(), message);

    return Ok(true);
}
}

Resolvers within a different canister

You can also write resolvers that are deployed to other canisters, using any language supported by the Internet Computer. For now you'll most likely be using Rust or Motoko, so examples are included below.

The process is similar to what you've just seen above, but in your GraphQL schema the custom queries and mutations have the addition of a @canister directive with the canister id of the canister that implements your resolver function.

Rust

In a Rust canister, start by augmenting your schema, for example in canisters/graphql/src/schema.graphql:

type Query {
    custom_get(id: ID!): Message @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai")
}

type Mutation {
    custom_set(id: ID!, text: String): Boolean! @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai")
}

type Message {
    id: ID!
    text: String!
}

Notice we've added @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai") to the custom query and mutation.

Now we need to implement the Rust canister. Let's imagine we've created another Rust canister in canisters/another-rust-canister. We might have a file called canisters/another-rust-canister/src/lib.rs, and it would look like this:


#![allow(unused)]
fn main() {
use sudograph;

// TODO This hasn't been tested, might need some derive macros
struct ID(String);

impl ID {
    fn to_string(&self) -> String {
        return String::from(&self.0);
    }
}

// TODO This hasn't been tested, might need some derive macros
struct Message {
    id: String,
    text: String
};

type PrimaryKey = String;
type MessageStore = HashMap<PrimaryKey, Option<Message>>;

#[sudograph::ic_cdk_macros::query]
async fn custom_get(id: ID) -> Option<Message> {
    let message_store = sudograph::ic_cdk::storage::get::<MessageStore>();

    let message_option = message_store.get(&id.to_string());

    match message_option {
        Some(message) => {
            return message.clone();
        },
        None => {
            return None;
        }
    };
}

#[sudograph::ic_cdk_macros::update]
async fn custom_set(id: ID, text: Option<String>) -> bool {
    let message_store = sudograph::ic_cdk::storage::get_mut::<MessageStore>();

    let message = match text {
        Some(text_value) => Some(Message {
            id: id.clone(),
            text: text_value
        }),
        None => None
    };

    message_store.insert(id.to_string(), message);

    return true;
}
}

Notice that these functions do not return a Result, they directly return the Rust types that correspond to the GraphQL types. This may change in the future as returning a Result may end up being more appropriate.

Also notice that we had to implement the ID and Message types ourselves. We do not have all of the generated types available because we are not using the graphql_database macro in this canister. In the future Sudograph may provide a simple way to generate these types for you without generating the entire database, but for now you'll have to implement them yourself or figure out an appropriate way to induce proper serialization and deserialization. For example, Candid might serialize and deserialize ID to and from strings for us...you'll just have to figure this out on your own for now.

Motoko

You can see a full example of Motoko custom resolvers here.

In a Motoko canister, start by augmenting your schema, for example in canisters/graphql/src/schema.graphql:

type Query {
    customGet(id: ID!): Message @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai")
}

type Mutation {
    customSet(id: ID!, text: String): Boolean! @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai")
}

type Message {
    id: ID!
    text: String!
}

Notice we've added @canister(id: "ryjl3-tyaaa-aaaaa-aaaba-cai") to the custom query and mutation.

Now we need to implement the Motoko canister. Let's imagine we've created a Motoko canister in canisters/motoko. We might have a file called canisters/motoko/main.mo, and it would look like this:

import Text "mo:base/Text";
import Map "mo:base/HashMap";
import Option "mo:base/Option";

actor Motoko {
    let message_store = Map.HashMap<Text, ?Message>(10, Text.equal, Text.hash);

    type Message = {
        id: Text;
        text: Text;
    };

    public query func customGet(id: Text): async ?Message {
        return Option.flatten(message_store.get(id));
    };

    public func customSet(id: Text, text: ?Text): async Bool {
        let message: ?Message = switch (text) {
            case null null;
            case (?text_value) Option.make({
                id;
                text = text_value;
            });
        };
        
        message_store.put(id, message);

        return true;
    };
}

Implementing the Motoko resolvers is very similar to implementing the Rust resolvers, the biggest difference besides the lanuage itself being the type conversions. We've implemented the Message type, and we've excluded the ID type and just used the native Motoko Text type. Again, you might have to experiment with the serialization and deserialization of values between canisters, a lot of it has to do with Candid.

Other languages

Other languages are somewhat possible to use now (C, C++, AssemblyScript), and many more will come in the future as WebAssembly matures. Writing resolvers in each of these languages will be similar to writing them in Rust or Motoko. Once your schema is setup and correctly pointing to a canister, you simply implement the resolver in the language of choice and ensure that the types align correctly.

Type conversions

GraphQL -> Rust

Object, ID, and Date types must be created in Rust canisters if the graphql_database macro is not invoked. ID and Date types might work as String in Rust.

  • Blob -> Vec<u8>
  • Boolean -> bool
  • Date -> Date
  • Float -> f32
  • ID -> ID
  • Int -> i32
  • JSON -> serde_json::Value
  • String -> String

Creating a custom ID type:


#![allow(unused)]
fn main() {
// TODO This hasn't been tested, might need some derive macros
struct ID(String);

impl ID {
    fn to_string(&self) -> String {
        return String::from(&self.0);
    }
}
}

Creating a custom Date type:


#![allow(unused)]
fn main() {
// TODO This hasn't been tested, might need some derive macros
struct Date(String);

impl Date {
    fn to_string(&self) -> String {
        return String::from(&self.0);
    }
}
}

GraphQL -> Motoko

Object types must be manually created in Motoko.

  • Blob -> Blob
  • Boolean -> Bool
  • Date -> Text
  • Float -> Float
  • ID -> Text
  • Int -> Int32
  • JSON -> Text (it's unclear if this will work)
  • String -> Text