0.0.2 • Published 1 year ago

@scinorandex/yoko v0.0.2

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

Yoko - a declarative approach to GraphQL

Yoko allows developers to create GraphQL type definitions and resolvers without the need for classes, aiming to be the complete opposite of TypeGraphQL.

Getting started

To get started, create a new TypeScript project and install @scinorandex/yoko

In this example, we're going to create a graphql schema that looks like this:

type User {
  id: String!
  name: String!
  hobbies: [Hobby!]!
  capitalized(suffix: String): String!
}

type Hobby {
  id: String!
  name: String!
  description: String!
  users: [User!]!
}

type Query {
  getUser(id: String!): User!
  getUsers(id: String!): [User!]!
}

type Mutation {
  createUser(name: String!): User!
}

Defining base fields

Zod is used to define the base fields of a model. Currently, only boolean, strings, numbers, and their optional versions are supported.

import { z } from "zod";

export const HobbyModel = z.object({
  id: z.string(),
  name: z.string(),
  description: z.string(),
});

export const UserModel = z.object({
  id: z.string(),
  name: z.string(),
});

Defining field resolvers

Once our models have been defined, we can define their associated resolvers using the defineType() method. This method accepts the base model as the 1st argument, and a function that returns an object containing resolvers as the 2nd argument.

The second argument is given a function as a parameter, named defineField in this case, that allows us to have typesafe field resolvers.

import { defineResolver, defineType, yoko } from "@scinorandex/yoko"

const UserType = defineType(UserModel, (defineField) => ({
  // Defines a field resolver called "hobbies" in the UserType
  hobbies: defineField({
    // This field resolver accepts no arguments
    args: undefined,
    
    // Define what this field resolver returns, in this case an array of `Hobby`s. 
    // Currently it only supports literal type, models, and their optional versions.
    returns: z.array(HobbyValidator),

    // Define the function that computes the field from its parent
    // parent is completely typesafe. In this case, it's type is { id: string, name: string }
    resolver: (parent) => {
      // find and return all hobbies that the user has
      return relation.filter((r) => r.userId === parent.id).map((r) => hobbies.find((h) => h.id === r.hobbyId)!);
    },
  }),

  // This field resolver accepts an optional suffix parameter and returns a string
  // It computes the field from the parent's name property and the suffix parameter
  capitalized: defineField({
    args: z.object({ suffix: z.string().optional() }),
    returns: z.string(),
    resolver: (parent, args) => parent.name.toUpperCase() + (args.suffix ?? ""),
  }),
}));

We can then define the Hobby model's resovlers the exact same way

const HobbyType = defineType(HobbyModel, (defineField) => ({
  users: defineField({
    returns: z.array(UserModel),
    args: undefined,
    resolver: (parent) => {
      const filtered = relation.filter((r) => r.hobbyId === parent.id);
      return filtered.map((r) => users.find((user) => user.id === r.userId)!);
    },
  }),
}));

Defining queries and mutations

Queries and mutations are written as resolver maps. These are objects whose key is the name of the query / mutation and the value is a resovler endpoint.

A resolver endpoint is defined using the defineResolver() method, that works very similar to the defineType() method. It accepts an object that defines the parameters, return type, and function for that resolver.

const queries = {
  // The getUser query returns a user model and requires an argument named id.
  // It throws an error if a user was not found with that ID.
  getUser: defineResolver({
    returns: UserModel,
    args: z.object({ id: z.string() }),
    resolver: async ({ id }) => {
      const user = users.find((user) => user.id === id);
      if (user) return user;
      else throw new Error("User not found");
    },
  }),

  // The getUsers query returns an array of users, requiring no arguments
  getUsers: defineResolver({
    returns: z.array(UserModel),
    args: undefined,
    resolver: () => users,
  }),
};

let maxId = 4;
const mutations = {
  // The createUser mutation requires a name string parameter,
  // and returns the newly created user object.
  createUser: defineResolver({
    returns: UserModel,
    args: z.object({ name: z.string() }),
    async resolver({ name }) {
      const user = { id: (++maxId).toString(), name };
      users.push(user);
      return user;
    },
  }),
};

Building it all together

Once our types, queries, and mutations are complete, we use the yoko() function to build the schema and rootValue that we run queries against.

// schema is a GraphQLSchema object that can be passed into the `graphql()` function or be used by a library.
// rootValue is the object that merges the query and mutation resolver maps.
// schemaString is a stringified version of the GraphQL schema, that can be saved to a `schema.graphql` file.
export const { schema, schemaString, rootValue } = yoko({
  // The keys on the types object matter, these keys are reflected on the type in the schema
  types: { User: UserType, Hobby: HobbyType },
  queries,
  mutations,
});

Example usage (no api)

We can query against the schema and resolvers using the graphql() function from the graphql package.

import { graphql } from "graphql";
import { schema, rootValue } from "./schema";

const query = `
{
  getUser(id: "1") {
    id, name, capitalized(suffix: " - is the soldier")
    
    hobbies {
      id, name
    }
  }
}
`;

graphql({ schema, rootValue, source: query }).then((testing) => {
  if (testing.data) return console.log(JSON.stringify(testing.data));
  else console.log(testing);
});

Which prints:

{
  "getUser": {
    "id": "1",
    "name": "John Doe",
    "capitalized": "JOHN DOE - is the soldier",
    "hobbies": [
      {
        "id": "1",
        "name": "Reading"
      },
      {
        "id": "2",
        "name": "Running"
      }
    ]
  }
}

Example usage (with API)

We can serve a GraphQL API very easily using the graphql-http package and ruru for graphiql

import { createHandler } from "graphql-http/lib/use/http";
import { rootValue, schema } from "./schema";
import express from "express";
import { ruruHTML } from "ruru/server";

async function main() {
  const app = express();

  // Serve ruru on /graphql as a playground
  app.get("/graphql", (req, res, next) => {
    res.writeHead(200, { "Content-Type": "text/html" });
    return res.end(ruruHTML({ endpoint: "/graphql" }));
  });
  
  // Create the handler using the schema we built from Yoko
  app.post("/graphql", createHandler({ schema, rootValue }));

  // Start the server on port 7000
  app.listen(7000, () => console.log("Server is running on port 7000"));
}

main().catch(console.error);
0.0.2

1 year ago

0.0.1

1 year ago