@ttoss/graphql-api
v0.8.1
Published
A library for building GraphQL APIs using ttoss ecosystem.
Downloads
319
Readme
@ttoss/graphql-api
This package offers an opinionated approach to building a GraphQL API using the ttoss ecosystem modules. It is designed to provide a resilient and scalable solution for creating complex GraphQL APIs while focusing on the following goals:
Modular Design:
Build your GraphQL API using modules to simplify and organize the development of large, complex APIs.Relay Compatibility:
As Relay is the primary GraphQL client in the ttoss ecosystem, this package implements the Relay Server Specification for seamless client-server interaction.Schema Building:
Generate the GraphQL schema required for Relay's introspection queries with @ttoss/graphql-api-cli.TypeScript Types Generation: Automatically generate TypeScript types for your GraphQL schema with @ttoss/graphql-api-cli.
AWS AppSync Support: Create GraphQL APIs compatible with AWS AppSync. Additionally, this package includes support for running a local GraphQL API server for development and testing purposes.
Installation
pnpm add @ttoss/graphql-api graphql
Usage
This library uses graphql-compose
to create the GraphQL schema. It re-export all the graphql-compose
types and methods, so you can use it directly from this package.
Type Creation
For more examples about how to create types, check the graphql-compose
documentation.
import { schemaComposer } from '@ttoss/graphql-api';
const UserTC = schemaComposer.createObjectTC({
name: 'User',
fields: {
id: 'ID!',
name: 'String!',
},
});
This library uses the tsconfig.json
file from the target package it is being applied on. If you are using relative imports in your package you can skip this section, but, if you use path aliases in your typescript code by leveraging the paths
property, the baseUrl
must be filled accordingly.This is needed because in order to interpret the path aliases, ts-node
uses tsconfig-paths
to resolve the modules that uses this config, and tsconfig-paths
needs both baseUrl
and paths
values to be non-null. A tsconfig.json
example that follows such recommendations is given below:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["src/*"]
}
}
}
Resolvers
TODO
Integrate All Modules
Once you've created all your types and resolvers, you can integrate all the modules to create the GraphQL schema.
// scr/schemaComposer.ts
import { schemaComposer } from '@ttoss/graphql-api';
import './modules/Module1/composer';
import './modules/Module3/composer';
import './modules/User/composer';
export { schemaComposer };
Relay Server Specification
As ttoss uses Relay as the main GraphQL client, this library implements the Relay Server Specification.
Object Identification
Method composeWithRelay
will handle the object identification for your ObjectTypeComposer
, it will return a globally unique ID among all types in the following format base64(TypeName + ':' + recordId)
.
Method composeWithRelay
only works if ObjectTypeComposer
meets the following requirements:
Has defined
recordIdFn
: returns the id for theglobalId
construction. For example, if you use DynamoDB, you could create id from hash and range keys:UserTC.setRecordIdFn((source) => { return `${source.hashKey}:${source.rangeKey}`; });
Have
findById
resolver: this resolver will be used byRootQuery.node
to resolve the object byglobalId
. Also, add the__typename
field is required by Relay to know the type of the object to thenode
field works. For example:UserTC.addResolver({ name: 'findById', type: UserTC, args: { id: 'String!', }, resolve: ({ args }) => { const { type, recordId } = fromGlobalId(args.id); // find object }, });
Call
composeWithRelay
method: this will add theid
field and thenode
query. For example:composeWithRelay(UserTC);
Example
import {
composeWithRelay,
schemaComposer,
fromGlobalId,
} from '@ttoss/graphql-api';
const UserTC = schemaComposer.createObjectTC({
name: 'User',
fields: {
id: 'ID!',
name: 'String!',
},
});
/**
* 1. Returns you id for the globalId construction.
*/
UserTC.setRecordIdFn((source) => {
/**
* If you use DynamoDB, you could create id from hash and range keys:
* return `${source.hashKey}:${source.rangeKey}`;
*/
return source.id;
});
/**
* 2. Define `findById` resolver (that will be used by `RootQuery.node`).
*/
UserTC.addResolver({
name: 'findById',
type: UserTC,
args: {
id: 'String!',
},
resolve: async ({ args }) => {
const { type, recordId } = fromGlobalId(args.id);
const user = await query({ id: recordId });
return {
...user,
__typename: UserTC.getTypeName(), // or 'User';
};
},
});
/**
* 3. This will add the `id` field and the `node` query.
*/
composeWithRelay(UserTC);
We inspired ourselves on graphql-compose-relay to create composeWithRelay
.
Connections
This packages provides the method composeWithConnection
to create a connection type and queries for a given type, based on graphql-compose-connection plugin and following the Relay Connection Specification.
import { composeWithConnection } from '@ttoss/graphql-api';
AuthorTC.addResolver({
name: 'findMany',
type: AuthorTC,
resolve: async ({ args }) => {
// find many
},
});
composeWithConnection(AuthorTC, {
findManyResolver: AuthorTC.getResolver('findMany'),
countResolver: AuthorTC.getResolver('count'),
sort: {
ASC: {
value: {
scanIndexForward: true,
},
cursorFields: ['id'],
beforeCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$lt = cursorData.id;
},
afterCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$gt = cursorData.id;
},
},
DESC: {
value: {
scanIndexForward: false,
},
cursorFields: ['id'],
beforeCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$gt = cursorData.id;
},
afterCursorQuery: (rawQuery, cursorData, resolveParams) => {
if (!rawQuery.id) rawQuery.id = {};
rawQuery.id.$lt = cursorData.id;
},
},
},
});
schemaComposer.Query.addFields({
authors: Authors.getResolver('connection'),
});
When you composeWithConnection
a type composer, it will add the resolver connection
to the type composer, so you can add to Query
or any other type composer. For example:
schemaComposer.Query.addFields({
authors: Authors.getResolver('connection'),
});
The resolver connection
has the following arguments based on the Relay Connection Specification:
first
: the number of nodes to return.after
: the cursor to start the query.last
: the number of nodes to return.before
: the cursor to start the query.limit
: the limit of nodes to return. It's thefirst
orlast
argument plus one. It's used to know if there are more nodes to return to sethasNextPage
orhasPreviousPage
PageInfo fields. For example, iffirst
is10
,limit
will be11
. If the resolver returns11
nodes, the resolver will return10
but it knows there are more nodes to return, sohasNextPage
will betrue
.skip
: it's thecount
minuslast
. It only works on backward pagination.sort
: the sort option to use. It's thevalue
of thesort
object. In our example, it's{ scanIndexForward: true }
forASC
and{ scanIndexForward: false }
, forDESC
.filter
: the filter to use. It'll exist if you add thefilter
tofindManyResolver
for example, the implementation below will add thefilter
argument with thename
andbook
fields:AuthorTC.addResolver({ name: 'findMany', type: AuthorTC, args: { filter: { name: 'String', book: 'String', }, }, resolve: async ({ args }) => { // find many }, });
To configure composeWithConnection
, you need to provide the following options:
findManyResolver
The resolver that will be used to find the nodes. It receives the following arguments:
args
: theargs
object from the resolver. Example:AuthorTC.addResolver({ name: 'findMany', type: AuthorTC, args: { filter: { name: 'String', book: 'String', }, }, resolve: async ({ args, }: { args: { first?: number; after?: string; last?: number; before?: string; /** * It's the `first` or `last` argument plus one. */ limit: number; /** * The `filter` argument, if provided on the query. */ filter?: { name: string; book: string; }; /** * The `sort` argument, if provided on the query as * they keys of the `sort` object. In our example * above, it's `ASC` and `DESC`. `scanIndexForward` * is the value of the `value` property on the sort * object. In our example above, it's `true` for * `ASC` and `false` for `DESC`. */ sort: { scanIndexForward: boolean; }; }; }) => { // }, });
rawQuery
: an object created bybeforeCursorQuery
orafterCursorQuery
methods from sort option.
countResolver
The resolver that will be used to count the nodes.
sort
It's an object that defines the sort options. Each key is the sort name and the value is an object with the following properties:
value
: and object that theargs
resolver will receive as thesort
argument. It'll also be the values of the sort enum composer created (check the implementation details here.)cursorFields
: an array of fields that will be used to create the cursor.beforeCursorQuery
andafterCursorQuery
: methods that will be used to create therawQuery
object for thefindManyResolver
. They receive the following arguments:rawQuery
: therawQuery
object that will be used to find the nodes.cursorData
: the data from the cursor define oncursorFields
. For example, if you definecursorFields
as['id', 'name']
, thecursorData
will an object with theid
andname
properties.resolveParams
: theresolveParams
object from the resolver. You can accessargs
,context
andinfo
and other GraphQL properties from this object.
Example:
composeWithConnection(AuthorTC, { // ... sort: { ASC: { // ... cursorFields: ['id', 'name'], // Called when `before` cursor is provided. beforeCursorQuery: (rawQuery, cursorData, resolveParams) => { if (!rawQuery.id) rawQuery.id = {}; rawQuery.id.$lt = cursorData.id; rawQuery.name.$lt = cursorData.name; }, // Called when `after` cursor is provided. afterCursorQuery: (rawQuery, cursorData, resolveParams) => { if (!rawQuery.id) rawQuery.id = {}; rawQuery.id.$gt = cursorData.id; rawQuery.name.$gt = cursorData.name; }, }, }, });
In the example above, the
findManyResolver
will receive the followingrawQuery
object whenbefore
cursor is provided:{ "id": { "$lt": "id-from-cursor" }, "name": { "$lt": "name-from-cursor" } }
Middlewares
This package provides a way to add middlewares to your final schema. You can add middlewares compatible with graphql-middleware
by passing them to the middlewares
option on buildSchema
method. For example, you can use GraphQL Shield to add authorization to your API:
import { buildSchema } from '@ttoss/graphql-api';
import { allow, deny, shield } from '@ttoss/graphql-api/shield';
import { schemaComposer } from './schemaComposer';
const NotAuthorizedError = new Error('Not authorized!');
/**
* The error name is the same value `errorType` on GraphQL errors response.
*/
NotAuthorizedError.name = 'NotAuthorizedError';
const permissions = shield(
{
Query: {
'*': deny,
author: allow,
},
Author: {
id: allow,
name: allow,
},
},
{
fallbackRule: deny,
fallbackError: NotAuthorizedError,
}
);
/**
* Apply middlewares to all resolvers.
*/
const logInput = async (resolve, source, args, context, info) => {
console.log(`1. logInput: ${JSON.stringify(args)}`)
const result = await resolve(source, args, context, info)
console.log(`5. logInput`)
return result
}
/**
* Apply middlewares only to a specific resolver.
*/
const logOnQueryMe = {
Query: {
me: logInput
}
}
const schema = buildSchema({
schemaComposer,
middlewares; [permissions, logInput, logOnQueryMe],
})
Shield
This package re-exports the all methods from GraphQL Shield.
import { allow, deny, shield } from '@ttoss/graphql-api/shield';
Building Schema and Types
Check @ttoss/graphql-api-cli for more information about how to build the schema and types.
How to Create Tests
We recommend testing the whole GraphQL API using the graphql
object and the schema composer to provide the schema. For example:
import { graphql } from 'graphql';
import { schemaComposer } from './schemaComposer';
test('testing my query', () => {
const author = {
id: '1',
name: 'John Doe',
};
const response = await graphql({
schema: schemaComposer.buildSchema(),
source: /* GraphQL */ `
query ($id: ID!) {
node(id: $id) {
id
... on Author {
name
}
}
}
`,
variableValues: {
id: author.id,
},
});
expect(response).toEqual({
data: {
node: {
id: author.id,
name: author.name,
},
},
});
});