diff --git a/README.md b/README.md index 0e3a43c1d..ea7dec9ab 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,60 @@ -# GiraphQL SchemaBuilder +## GiraphQL - A plugin based GraphQL schema builder for typescript -GiraphQL is library for creating GraphQL schemas in typescript using a strongly typed code first -approach. The GiraphQL schema builder makes writing schemas easy by providing a simple clean API -with helpful auto-completes, and removing the need for compile steps or defining the same types in -multiple files. +GiraphQL makes writing graphql schemas in typescript easy, fast and enjoyable. The core of GiraphQL +adds 0 overhead at runtime, and has `graphql` as its only dependency. -GiraphQL works in Node, Deno, or even the browser. +By leaning heavily on typescripts ability to infer types, GiraphQL is the most type-safe way of +writing GraphQL schemas in typescript/node while requiring very few manual type definitions and no +code generation. +GiraphQL has a unique and powerful plugin system that makes every plugin feel like its features are +built into the core library. Plugins can extend almost any part of the API by adding new options or +methods that can take full advantage of GiraphQLs type system. + +## Hello, World ```typescript -import SchemaBuilder from '@giraphql/core'; import { ApolloServer } from 'apollo-server'; +import SchemaBuilder from '@giraphql/core'; const builder = new SchemaBuilder({}); builder.queryType({ - fields: (t) => ({ - hello: t.string({ - args: { - name: t.arg.string({}), - }, - resolve: (parent, { name }) => `hello, ${name || 'World'}`, - }), + fields: (t) => ({ + hello: t.string({ + args: { + name: t.arg.string({}), + }, + resolve: (parent, { name }) => `hello, ${name || 'World'}`, }), + }), }); -const server = new ApolloServer({ - schema: builder.toSchema({}), -}); - -server.listen(3000); +new ApolloServer({ + schema: builder.toSchema({}), +}).listen(3000); ``` -## Full docs available at https://giraphql.com - -## Development +## Plugins that make GiraphQL even better + +- ## [Scope Auth](plugins/scope-auth.md) + Add global, type level, or field level authorization checks to your schema +- ## [Validation](plugins/validation.md) + Validating your inputs and arguments +- ## [Dataloader](plugins/dataloader.md) + Quickly define data-loaders for your types and fields to avoid n+1 queries. +- ## [Relay](plugins/relay.md) +- Easy to use builder methods for defining relay style nodes and connections, and helpful utilities + for cursor based pagination. +- ## [Simple Objects](plugins/simple-objects.md) + Define simple object types without resolvers or manual type definitions. +- ## [Mocks](plugins/mocks.md) + Add mock resolver for easier testing +- ## [Sub-Graph](plugins/sub-graph.md) + Build multiple subsets of your graph to easily share code between internal and external APIs. +- ## [Directives](plugins/directives.md) + Integrate with existing schema graphql directives in a type-safe way. +- ## [Smart Subscriptions](plugins/smart-subscriptions.md) + Make any part of your graph subscribable to get live updates as your data changes. -### Setup - -After cloning run - -```bash -yarn install -yarn prepare -yarn build -``` - -### Scripts - -- `yarn prepare`: re-create all the config files for tools used in this repo -- `yarn build`: builds .js and .d.ts files -- `yarn clean`: removes all build artifacts for code and docs -- `yarn lint {path to file/directory}`: runs linter (eslint) -- `yarn jest {path to file/directory}`: run run tests -- `yarn test`: runs typechecking, lint, and tests -- `yarn prettier`: formats code and docs -- `yarn type`: run typechecking +## Full docs available at https://giraphql.com diff --git a/docs/README.md b/docs/README.md index fd46d4cd4..a6c5b4a28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,9 +3,18 @@ name: Overview route: / --- -# Overview +## GiraphQL - A plugin based GraphQL schema builder for typescript -GiraphQL is a plugin based schema builder for creating code-first GraphQL schemas in typescript. +GiraphQL makes writing graphql schemas in typescript easy, fast and enjoyable. The core of GiraphQL +adds 0 overhead at runtime, and has `graphql` as its only dependency. + +By leaning heavily on typescripts ability to infer types, GiraphQL is the most type-safe way of +writing GraphQL schemas in typescript/node while requiring very few manual type definitions and no +code generation. + +GiraphQL has a unique and powerful plugin system that makes every plugin feel like its features are +built into the core library. Plugins can extend almost any part of the API by adding new options or +methods that can take full advantage of GiraphQLs type system. ## Hello, World @@ -31,42 +40,24 @@ new ApolloServer({ }).listen(3000); ``` -## What GiraphQL offers - -* A type safe way to build GraphQL schemas with minimal manual type definitions and no build - - process for generating type definitions - -* A powerful plugin system that enables extending almost any part of the schema builder, as well - - as adding runtime features like authorization. - -* A lack of dependencies: GiraphQL uses `graphql` as it's only peer dependency -* A set of plugins for common use cases: - * [`@giraphql/plugin-scope-auth`](plugins/scope-auth.md): A plugin for adding authorization checks - - throughout your schema - - * [`@giraphql/plugin-relay`](plugins/relay.md): A plugin for adding builder methods for defining - - relay style nodes and connections, and some helpful utilities for cursor based pagination - - * [`@giraphql/plugin-smart-subscriptions`](plugins/smart-subscriptions.md): A plugin for a more - - graph friendly way of defining subscriptions. - - * [`@giraphql/plugin-simple-objects`](plugins/simple-objects.md): A plugin for creating simple - - objects and interfaces without defining types, resolvers, or arguments. - - * [`@giraphql/plugin-mocks`](plugins/mocks.md): A plugin for mocking out resolvers in your schema. - * [`@giraphql/plugin-sub-graph`](plugins/sub-graph.md): A plugin for creating sub selections of - - your graph. - - * [`@giraphql/plugin-directives`](plugins/directives.md): A plugin for using directives with - - GiraphQL schemas. - - * [`@giraphql/plugin-validation`](plugins/validation.md): A plugin for validating arguments. - +## Plugins that make GiraphQL even better + +- ## [Scope Auth](plugins/scope-auth.md) + Add global, type level, or field level authorization checks to your schema +- ## [Validation](plugins/validation.md) + Validating your inputs and arguments +- ## [Dataloader](plugins/dataloader.md) + Quickly define data-loaders for your types and fields to avoid n+1 queries. +- ## [Relay](plugins/relay.md) +- Easy to use builder methods for defining relay style nodes and connections, and helpful utilities + for cursor based pagination. +- ## [Simple Objects](plugins/simple-objects.md) + Define simple object types without resolvers or manual type definitions. +- ## [Mocks](plugins/mocks.md) + Add mock resolver for easier testing +- ## [Sub-Graph](plugins/sub-graph.md) + Build multiple subsets of your graph to easily share code between internal and external APIs. +- ## [Directives](plugins/directives.md) + Integrate with existing schema graphql directives in a type-safe way. +- ## [Smart Subscriptions](plugins/smart-subscriptions.md) + Make any part of your graph subscribable to get live updates as your data changes. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 1be8c0eac..d7ea19957 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,38 +1,38 @@ # Table of contents -* [Overview](README.md) -* [Guide](guide/README.md) - * [Object Types](guide/objects.md) - * [SchemaBuilder](guide/schema-builder.md) - * [Fields](guide/fields.md) - * [Args](guide/args.md) - * [Context object](guide/context.md) - * [Input Objects](guide/inputs.md) - * [Enums](guide/enums.md) - * [Scalars](guide/scalars.md) - * [Interfaces](guide/interfaces.md) - * [Unions](guide/unions.md) - * [Using Plugins](guide/using-plugins.md) - * [App layout](guide/app-layout.md) - * [Printing Schema](guide/printing-schemas.md) - * [Changing Default Nullability](guide/changing-default-nullability.md) - * [Writing Plugins](guide/writing-plugins.md) - * [Deno](guide/deno.md) -* [Plugins](plugins/README.md) - * [Auth Plugin](plugins/scope-auth.md) - * [Mocks Plugin](plugins/mocks.md) - * [Relay Plugin](plugins/relay.md) - * [Simple Objects Plugin](plugins/simple-objects.md) - * [Smart Subscriptions Plugin](plugins/smart-subscriptions.md) - * [SubGraph Plugin](plugins/sub-graph.md) - * [Directives Plugin](plugins/directives.md) - * [Validation Plugin](plugins/validation.md) -* [API](api/README.md) - * [SchemaBuilder](api/schema-builder.md) - * [FieldBuilder](api/field-builder.md) - * [ArgBuilder](api/arg-builder.md) - * [InputFieldBuilder](api/input-field-builder.md) -* [Design](design.md) -* [Migrations](migrations/README.md) - * [v2.0](migrations/2.0.md) - +- [Overview](README.md) +- [Guide](guide/README.md) + - [Object Types](guide/objects.md) + - [SchemaBuilder](guide/schema-builder.md) + - [Fields](guide/fields.md) + - [Args](guide/args.md) + - [Context object](guide/context.md) + - [Input Objects](guide/inputs.md) + - [Enums](guide/enums.md) + - [Scalars](guide/scalars.md) + - [Interfaces](guide/interfaces.md) + - [Unions](guide/unions.md) + - [Using Plugins](guide/using-plugins.md) + - [App layout](guide/app-layout.md) + - [Printing Schema](guide/printing-schemas.md) + - [Changing Default Nullability](guide/changing-default-nullability.md) + - [Writing Plugins](guide/writing-plugins.md) + - [Deno](guide/deno.md) +- [Plugins](plugins/README.md) + - [Auth](plugins/scope-auth.md) + - [Dataloader](plugins/dataloader.md) + - [Directives](plugins/directives.md) + - [Mocks](plugins/mocks.md) + - [Relay](plugins/relay.md) + - [Simple Objects](plugins/simple-objects.md) + - [Smart Subscriptions](plugins/smart-subscriptions.md) + - [SubGraph](plugins/sub-graph.md) + - [Validation](plugins/validation.md) +- [API](api/README.md) + - [SchemaBuilder](api/schema-builder.md) + - [FieldBuilder](api/field-builder.md) + - [ArgBuilder](api/arg-builder.md) + - [InputFieldBuilder](api/input-field-builder.md) +- [Design](design.md) +- [Migrations](migrations/README.md) + - [v2.0](migrations/2.0.md) diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 94e81f40f..fa15fc4ac 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -1,15 +1,21 @@ # Plugins -- [`@giraphql/plugin-scope-auth`](scope-auth.md): A plugin for adding authorization checks - throughout your schema -- [`@giraphql/plugin-relay`](relay.md): A plugin for adding builder methods for defining relay style - nodes and connections, and some helpful utilities for cursor based pagination -- [`@giraphql/plugin-smart-subscriptions`](smart-subscriptions.md): A plugin for a more graph - friendly way of defining subscriptions. -- [`@giraphql/plugin-simple-objects`](simple-objects.md): A plugin for creating simple objects and - interfaces without resolvers or arguments. -- [`@giraphql/plugin-mocks`](mocks.md): A plugin for mocking out resolvers in your schema. -- [`@giraphql/plugin-sub-graph`](sub-graph.md): A plugin for creating sub selections of your graph. -- [`@giraphql/plugin-directives`](directives.md): A plugin for using directives with GiraphQL - schemas. -- [`@giraphql/plugin-validation`](validation.md): A plugin for validating arguments. +- ## [Scope Auth](./scope-auth.md) + Add global, type level, or field level authorization checks to your schema +- ## [Validation](./validation.md) + Validating your inputs and arguments +- ## [Dataloader](./dataloader.md) + Quickly define data-loaders for your types and fields to avoid n+1 queries. +- ## [Relay](./relay.md) +- Easy to use builder methods for defining relay style nodes and connections, and helpful utilities + for cursor based pagination. +- ## [Simple Objects](./simple-objects.md) + Define simple object types without resolvers or manual type definitions. +- ## [Mocks](./mocks.md) + Add mock resolver for easier testing +- ## [Sub-Graph](./sub-graph.md) + Build multiple subsets of your graph to easily share code between internal and external APIs. +- ## [Directives](./directives.md) + Integrate with existing schema graphql directives in a type-safe way. +- ## [Smart Subscriptions](./smart-subscriptions.md) + Make any part of your graph subscribable to get live updates as your data changes. diff --git a/packages/core/README.md b/packages/core/README.md index 8dfd543cc..ea7dec9ab 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,13 +1,21 @@ -# GiraphQL SchemaBuilder +## GiraphQL - A plugin based GraphQL schema builder for typescript -GiraphQL is library for creating GraphQL schemas in typescript using a strongly typed code first -approach. The GiraphQL schema builder makes writing schemas easy by providing a simple clean API -with helpful auto-completes, and removing the need for compile steps or defining the same types in -multiple files. +GiraphQL makes writing graphql schemas in typescript easy, fast and enjoyable. The core of GiraphQL +adds 0 overhead at runtime, and has `graphql` as its only dependency. + +By leaning heavily on typescripts ability to infer types, GiraphQL is the most type-safe way of +writing GraphQL schemas in typescript/node while requiring very few manual type definitions and no +code generation. + +GiraphQL has a unique and powerful plugin system that makes every plugin feel like its features are +built into the core library. Plugins can extend almost any part of the API by adding new options or +methods that can take full advantage of GiraphQLs type system. + +## Hello, World ```typescript -import SchemaBuilder from '@giraphql/core'; import { ApolloServer } from 'apollo-server'; +import SchemaBuilder from '@giraphql/core'; const builder = new SchemaBuilder({}); @@ -22,11 +30,31 @@ builder.queryType({ }), }); -const server = new ApolloServer({ +new ApolloServer({ schema: builder.toSchema({}), -}); - -server.listen(3000); +}).listen(3000); ``` +## Plugins that make GiraphQL even better + +- ## [Scope Auth](plugins/scope-auth.md) + Add global, type level, or field level authorization checks to your schema +- ## [Validation](plugins/validation.md) + Validating your inputs and arguments +- ## [Dataloader](plugins/dataloader.md) + Quickly define data-loaders for your types and fields to avoid n+1 queries. +- ## [Relay](plugins/relay.md) +- Easy to use builder methods for defining relay style nodes and connections, and helpful utilities + for cursor based pagination. +- ## [Simple Objects](plugins/simple-objects.md) + Define simple object types without resolvers or manual type definitions. +- ## [Mocks](plugins/mocks.md) + Add mock resolver for easier testing +- ## [Sub-Graph](plugins/sub-graph.md) + Build multiple subsets of your graph to easily share code between internal and external APIs. +- ## [Directives](plugins/directives.md) + Integrate with existing schema graphql directives in a type-safe way. +- ## [Smart Subscriptions](plugins/smart-subscriptions.md) + Make any part of your graph subscribable to get live updates as your data changes. + ## Full docs available at https://giraphql.com diff --git a/packages/plugin-dataloader/README.md b/packages/plugin-dataloader/README.md index 0c270e7e3..831932294 100644 --- a/packages/plugin-dataloader/README.md +++ b/packages/plugin-dataloader/README.md @@ -1,3 +1,240 @@ # Dataloader Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/dataloader +This plugin makes it easy to add fields and types that are loaded through a dataloader. + +## Usage + +### Install + +To use the dataloader plugin you will need to install both the `dataloader` package and the +validation plugin: + +```bash +yarn add dataloader @giraphql/plugin-dataloader +``` + +### Setup + +```typescript +import DataloaderPlugin from '@giraphql/plugin-dataloader'; +const builder = new SchemaBuilder({ + plugins: [DataloaderPlugin], +}); +``` + +### loadable objects + +To create an object type that can be loaded with a dataloader use the new `builder.loadableObject` +method: + +```ts +const User = builder.loadableObject('User', { + // load will be called with ids of users that need to be loaded + // Note that the types for keys (and context if present) are required + load: (ids: string[], context: ContextType) => context.loadUsersById(ids), + fields: (t) => ({ + id: t.exposeID('id', {}), + username: t.string({ + // the shape of parent will be inferred from `loadUsersById()` above + resolve: (parent) => parent.username, + }), + }), +}); +``` + +When defining fields that return `User`s, you will now be able to return either a `string` (based in +ids param of `load`), or a User object (type based on the return type of `loadUsersById`). + +```ts +builder.queryType({ + fields: (t) => ({ + user: t.field({ + type: User, + args: { + id: t.arg.string({ required: true }), + }, + // Here we can just return the ID directly rather than loading the user ourselves + resolve: (root, args) => args.id, + }), + currentUser: t.field({ + type: User, + // If we already have the user, we use it, and the dataloader will not be called + resolve: (root, args, context) => context.currentUser, + }), + users: t.field({ + type: [User], + args: { + ids: t.arg.stringList({ required: true }), + }, + // Mixing ids and user objects also works + resolve: (_root, args, context) => [...args.ids, context.CurrentUser], + }), + }), +}); +``` + +GiraphQL will detect when a resolver returns `string`, `number`, or `bigint` (typescript will +constrain the allowed types to whatever is expected by the load function). If a resolver returns an +object instead, GiraphQL knows it can skip the dataloader for that object. + +### loadable fields + +In some cases you may need more granular dataloaders. To handle these cases there is a new +`t.loadable` method for defining fields with their own dataloaders. + +```ts +// Normal object that the fields below will load +const Post = builder.objectRef<{ id: string; title: string; content: string }>('Post').implement({ + fields: (t) => ({ + id: t.exposeID('id', {}), + title: t.exposeString('title', {}), + content: t.exposeString('title', {}), + }), +}); + +// Loading a single +builder.objectField(User, 'latestPost', (t) => + t.loadable({ + type: Post, + // will be called with ids of latest posts for all users in query + load: (ids: number[], context) => context.loadPosts(ids), + resolve: (user, args) => user.lastPostID, + }), +); +// Loading a multiple +builder.objectField(User, 'posts', (t) => + t.loadable({ + type: [Post], + // will be called with ids of posts loaded for all users in query + load: (ids: number[], context) => context.loadPosts(ids), + resolve: (user, args) => user.postIDs, + }), +); +``` + +### Manually using dataloader + +Dataloaders for "loadable" objects can be accessed via their ref by passing in the context object +for the current request. dataloaders are not shared across requests, so we need the context to get +the correct dataloader for the current request: + +```ts +// create loadable object +const User = builder.loadableObject('User', { + load: (ids: string[], context: ContextType) => context.loadUsersById(ids), + fields: (t) => ({ + id: t.exposeID('id', {}), + }), +}); + +builder.queryField('user', (t) => + t.field({ + type: User, + resolve: (parent, args, context) => { + // get data loader for User type + const loader = User.getDataloader(context); + + // manually load a user + return loader.load('123'); + }, + }), +); +``` + +### Errors + +Calling dataloader.loadMany will resolve to a value like (Type | Error)[]. Your `load` function may +also return results in that format if your loader can have parital failures. GraphQL does not have +special handling for Error objects. Instead GiraphQL will map these results to something like +`(Type | Promise)[]` where Errors are replaced with promises that will be rejected. This +allows the normal graphql resolver flow to correctly handle these errors. + +If you are using the `loadMany` method from a dataloader manually, you can apply the same mapping +using the `rejectErrors` helper: + +```ts +import { rejectErrors } from '@giraphql/plugin-dataloader'; + +builder.queryField('user', (t) => + t.field({ + type: [User], + resolve: (parent, args, context) => { + const loader = User.getDataloader(context); + + return rejectErrors(loader.loadMany(['123', '456'])); + }, + }), +); +``` + +### (Optional) Adding loaders to context + +If you want to make dataloaders accessible via the context object directly, there is some additional +setup required. Below are a few options for different ways you can load data from the context +object. You can determine which of these options works best for you or add you own helpers. + +First you'll need to update the types for your context type: + +```ts +import { LoadableRef } from '@giraphql/plugin-dataloader'; + +export interface ContextType { + userLoader: DataLoader; // expose a specific loader + getLoader: (ref: LoadableRef) => DataLoader; // helper to get a loader from a ref + load: (ref: LoadableRef, id: K) => Promise; // helper for loading a single resource + loadMany: (ref: LoadableRef, ids: K[]) => Promise<(Error | V)[]>; // helper for loading many + // other context fields +} +``` + +next you'll need to update your context factory function. The exact format of this depends on what +graphql server implementation you are using. + +```ts +import { initContextCache } from '@giraphql/core'; +import { LoadableRef, rejectErrors } from '@giraphql/plugin-dataloader'; + +export const createContext = (req, res): ContextType => ({ + // Adding this will prevent any issues if you server implementation + // copies or extends the context object before passing it to your resolvers + ...initContextCache(), + + // using getters allows us to access the context object using `this` + get userLoader() { + return User.getDataloader(this); + }, + get getLoader() { + return (ref: LoadableRef) => ref.getDataloader(this); + }, + get load() { + return (ref: LoadableRef, id: K) => ref.getDataloader(this).load(id); + }, + get loadMany() { + return (ref: LoadableRef, ids: K[]) => + rejectErrors(ref.getDataloader(this).loadMany(ids)); + }, +}); +``` + +Now you can use these helpers from your context object: + +```ts +builder.queryFields((t) => ({ + fromContext1: t.field({ + type: User, + resolve: (root, args, { userLoader }) => userLoader.load('123'), + }), + fromContext2: t.field({ + type: User, + resolve: (root, args, { getLoader }) => getLoader(User).load('456'), + }), + fromContext3: t.field({ + type: User, + resolve: (root, args, { load }) => load(User, '789'), + }), + fromContext4: t.field({ + type: [User], + resolve: (root, args, { loadMany }) => loadMany(User, ['123', '456']), + }), +})); +``` diff --git a/packages/plugin-directives/README.md b/packages/plugin-directives/README.md index 02df2d7eb..efe9fe252 100644 --- a/packages/plugin-directives/README.md +++ b/packages/plugin-directives/README.md @@ -1,3 +1,106 @@ -A GiraphQL plugin for using graphql-tools based schema directives +# A GiraphQL plugin for using graphql-tools based schema directives -For full documentation see https://giraphql.com/ +A plugin for using schema directives with schemas generated by GiraphQL. + +Schema Directives are not intended to be used with code first schemas, but there is a large existing +community with several very useful directives based + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-directives +``` + +### Setup + +```typescript +import DirectivePlugin from '@giraphql/plugin-directives'; +import { SchemaDirectiveVisitor } from 'apollo-server'; +import { createRateLimitDirective } from 'graphql-rate-limit-directive'; + +const builder = new SchemaBuilder< + Directives: { + rateLimit: { + locations: 'OBJECT' | 'FIELD_DEFINITION'; + args: { limit: number, duration: number }; + }, + useGraphQLToolsUnorderedDirectives: true, +>({ + plugins: [DirectivePlugin], +}); + +builder.queryType({ + directives: { + rateLimit: { limit: 5, duration: 60 }, + }, + fields: (t) => ({ + hello: t.string({ resolve: () => 'world' }); + }); +}); + +const schema = builder.toSchema(); + +createRateLimitDirective().visitSchemaDirectives(schema, {}); +``` + +The directives plugin allows you to define types for the directives your schema will use the +`SchemaTypes` parameter. Each directive can define a set of locations the directive can appear, and +an object type representing the arguments the directive accepts. + +The valid locations for directives are: + +- `ARGUMENT_DEFINITION` \| +- `ENUM_VALUE` +- `ENUM` +- `FIELD_DEFINITION` +- `INPUT_FIELD_DEFINITION` +- `INPUT_OBJECT` +- `INTERFACE` +- `OBJECT` +- `SCALAR` +- `SCHEMA` +- `UNION` + +GiraphQL does not apply the directives itself, this plugin simply adds directive information to the +extensions property of the underlying GraphQL type so that it can be consumed by other tools like +`graphql-tools`. + +By default this plugin uses the format that Gatsby uses \(described +[here](https://github.com/graphql/graphql-js/issues/1343#issuecomment-479871020)\). This format is +[currently not supported by `graphql-tools`](https://github.com/ardatan/graphql-tools/issues/2534). +To support `graphql-tools` based directives like the rate-limit directive from the example above you +can set the `useGraphQLToolsUnorderedDirectives` option. This option does not preserve the order +that directives are defined in. This will be okay for most cases, but may cause issues if your +directives need to be applied in a specific order. + +To define directives on your fields or types, you can add a `directives` property in any of the +supported locations using one of the following 2 formats: + +```typescript +{ + directives: [ + { + name: "validation", + args: { + regex: "/abc+/" + } + }, + { + name: "required", + args: {}, + } + ], + // or + directives: { + validation: { + regex: "/abc+/" + }, + required: {} + } +} +``` + +Each of these applies the same 2 directives. The first format is preferred, especially when using +directives that are sensitive to ordering, or can be repeated multiple times for the same location. diff --git a/packages/plugin-mocks/README.md b/packages/plugin-mocks/README.md index 0d8279166..5fe2fa2a6 100644 --- a/packages/plugin-mocks/README.md +++ b/packages/plugin-mocks/README.md @@ -1,3 +1,80 @@ # Mocks Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/mocks +A simple plugin for adding resolver mocks to a graphQL schema. + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-mocks +``` + +### Setup + +```typescript +import MocksPlugin from '@giraphql/plugin-mocks'; +const builder = new SchemaBuilder({ + plugins: [MocksPlugin], +}); +``` + +### Adding mocks + +You can mock any field by adding a mock in the options passed to `builder.builSchema` under +`mocks.{typeName}.{fieldName}`. + +```typescript +builder.queryType({ + fields: (t) => ({ + someField: t.string({ + resolve: () => { + throw new Error('Not implemented'); + }, + }), + }), +}); + +builder.toSchema({ + mocks: { + Query: { + someField: (parent, args, context, info) => 'Mock result!', + }, + }, +}); +``` + +Mocks will replace the resolve functions any time a mocked field is executed. A schema can be build +multiple times with different mocks. + +### Adding mocks for subscribe functions + +To add a mock for a subscriber you can nest the mocks for subscribe and resolve in an object: + +```typescript +builder.subscriptionType({ + fields: (t) => ({ + someField: t.string({ + resolve: () => { + throw new Error('Not implemented'); + }, + subscribe: () => { + throw new Error('Not implemented'); + }, + }), + }), +}); + +builder.toSchema({ + mocks: { + Subscription: { + someField: { + resolve: (parent, args, context, info) => 'Mock result!', + subscribe: (parent, args, context, info) => { + /* return a mock async iterator */ + }, + }, + }, + }, +}); +``` diff --git a/packages/plugin-relay/README.md b/packages/plugin-relay/README.md index 64a53d3e1..b21dbecae 100644 --- a/packages/plugin-relay/README.md +++ b/packages/plugin-relay/README.md @@ -1,3 +1,304 @@ # Relay Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/relay +The Relay plugin adds a number of builder methods a helper functions to simplify building a relay +compatible schema. + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-relay +``` + +### Setup + +```typescript +import RelayPlugin from '@giraphql/plugin-relay'; +const builder = new SchemaBuilder({ + plugins: [RelayPlugin], + relayOptions: { + nodeQueryOptions: {}, + nodesQueryOptions: {}, + nodeTypeOptions: {}, + pageInfoTypeOptions: {}, + }, +}); +``` + +The options objects here are required, but will often be empty. Like many other places in the +GiraphQL API, options objects are required because other plugins may contribute required options. +These options objects will enable things like defining auth policies for your node query fields if +you are using the auth plugin. + +### Global ids + +To make it easier to create globally unique ids the relay plugin adds new methods for creating +globalID fields. + +```typescript +import { encodeGlobalID } from '@giraphql/plugin-relay'; + +builder.queryFields((t) => ({ + singleID: t.globalID({ + resolve: (parent, args, context) => { + return encodeGlobalID('SomeType', 123); + }, + }), + listOfIDs: t.globalIDList({ + resolve: (parent, args, context) => { + return [{ id: 123, type: 'SomeType' }]; + }, + }), +})); +``` + +The returned IDs can either be a string \(which is expected to already be a globalID\), or an object +with the an `id` and a `type`, The type can be either the name of a name as a string, or any object +that can be used in a type parameter. + +There are also new methods for adding globalIDs in arguments or fields of input types: + +```typescript +builder.queryType({ + fields: (t) => ({ + fieldThatAcceptsGlobalID: t.boolean({ + args: { + id: t.arg.globalID({ + required: true, + }), + idList: t.arg.globalIDList({}), + }, + resolve(parent, args) { + console.log(`Get request for type ${args.id.type} with id ${args.id.typename}`); + return true; + }, + }), + }), +}); +``` + +globalIDs used in arguments expect the client to send a globalID string, but will automatically be +converted to an object with 2 properties (`id` and `typename`) before they are passed to your +resolver in the arguments object. + +### Creating Nodes + +To create objects that extend the `Node` interface, you can use the new `builder.node` method. + +```typescript +class NumberThing { + id: number; + + binary: string; + + constructor(n: number) { + this.id = n; + this.binary = n.toString(2); + } +} + +builder.node(NumberThing, { + id: { + resolve: (num) => num.id, + // other options for id field can be added here + }, + loadOne: (id) => new NumberThing(parseInt(id)), + loadMany: (ids) => ids.map((id) => new NumberThing(parseInt(id))), + name: 'Number', + fields: (t) => ({ + binary: t.exposeString('binary', {}), + }), +}); +``` + +`builder.node` will create an object type that implements the `Node` interface. It will also create +the `Node` interface the first time it is used. The `resolve` function for `id` should return a +number or string, which will be converted to a globalID. The `loadOne` and `loadMany` methods are +optional, and `loadMany` will be used if both are present. These methods allow a nodes to be loaded +by id. The relay plugin adds to new query fields `node` and `nodes` which can be used to directly +fetch nodes using global IDs. + +Nodes may also implement an `isTypeOf` method which can be used to resolve the correct type for +lists of generic nodes. When using a class as the type parameter, the `isTypeOf` method defaults to +using an `instanceof` check, and falls back to checking the constructor property on the prototype. +The means that for many cases if you are using classes in your type parameters, and all your values +are instances of those classes, you won't need to implement an `isTypeOf` method, but it is ussually +better to explicitly define that behavior. + +### Creating Connections + +The `t.connection` field builder method can be used to define connections. This method will +automatically create the `Connection` and `Edge` objects used by the connection, and add `before`, +`after`, `first`, and `last` arguments. The first time this method is used, it will also create the +`PageInfo` type. + +```typescript +builder.queryFields((t) => ({ + numbers: t.connection( + { + type: NumberThing, + resolve: (parent, { first, last, before, after }) => { + return resolveOffsetConnection({ args }, ({ limit, offset }) => { + return { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'abc', + endCursor: 'def', + }, + edges: [ + { + cursor: 'xyz', + node: new NumberThing(123), + }, + ], + }; + }); + }, + }, + { + name: 'NameOfConnectionType', // optional, will use ParentObject + capitalize(FieldName) + "Connection" as the default + fields: () => ({ + /* define extra fields on Connection */ + }), + // Other options for connection object can be added here + }, + { + // Same as above, but for the Edge Object + name: 'NameOfEdgeType', // optional, will use Connection name + "Edge" as the default + fields: () => ({ + /* define extra fields on Edge */ + }), + }, + ), +})); +``` + +Manually implementing connections can be cumbersome, so there are a couple of helper methods that +can make resolving connections a little easier. + +For limit/offset based apis: + +```typescript +import { resolveOffsetConnection } from '@giraphql/plugin-relay'; + +builder.queryFields((t) => ({ + numbers: t.connection( + { + type: SomeThings, + resolve: (parent, args) => { + return resolveOffsetConnection({ args }, ({ limit, offset }) => { + return getThings(offset, limit); + }); + }, + }, + {}, + {}, + ), +})); +``` + +`resolveOffsetConnection` has a few default limits to prevent unintentionally allowing too many +records to be fetched at once. These limits can be configure using the following options: + +```typescript +{ + args: ConnectionArguments; + defaultSize?: number; // defaults to 20 + maxSize?: number; // defaults to 100 +} +``` + +For APIs where you have the full array available you can use `resolveArrayConnection`, which works +just like `resolveOffsetConnection` and accepts the same options. + +```typescript +import { resolveArrayConnection } from '@giraphql/plugin-relay'; + +builder.queryFields((t) => ({ + numbers: t.connection( + { + type: SomeThings, + resolve: (parent, args) => { + return resolveOffsetConnection({ args }, getAllTheThingsAsArray()); + }, + }, + {}, + {}, + ), +})); +``` + +I am planning to add more helpers in the future. + +### Expose nodes + +The `t.node` and `t.nodes` methods can be used to add additional node fields. the expected return +values of `id` and `ids` fields is the same as the resolve value of `t.globalID`, and can either be +a globalID or an object with and an `id` and a `type`. + +Loading nodes by `id` uses a request cache, so the same node will only be loaded once per request, +even if it is used multiple times across the schema. + +```typescript +builder.queryFields((t) => ({ + extraNode: t.node({ + id: () => 'TnVtYmVyOjI=', + }), + moreNodes: t.nodeList({ + ids: () => ['TnVtYmVyOjI=', { id: 10, type: 'SomeType' }], + }), +})); +``` + +### decoding and encoding global ids + +The relay plugin exports `decodeGlobalID` and `encodeGlobalID` as helper methods for interacting +with global IDs directly. If you accept a global ID as an argument you can use the `decodeGlobalID` +function to decode it: + +```typescript +builder.mutationFields((t) => ({ + updateThing: t.field({ + type: Thing, + args: { + id: t.args.id({ required: true }), + update: t.args.string({ required: true }), + }, + resolve(parent, args) { + const { type, id } = decodeGlobalId(args.id); + + const thing = Thing.findById(id); + + thing.update(args.update); + + return thing; + }, + }), +})); +``` + +### Using custom encoding for global ids + +In some cases you may want to encode global ids differently than the build in ID encoding. To do +this, you can pass a custom encoding and decoding function into the relay options of the builder: + +```typescript +import '@giraphql/plugin-relay'; +const builder = new SchemaBuilder({ + plugins: ['GiraphQLRelay'], + relayOptions: { + nodeQueryOptions: {}, + nodesQueryOptions: {}, + nodeTypeOptions: {}, + pageInfoTypeOptions: {}, + encodeGlobalID: (typename: string, id: string | number | bigint) => `${typename}:${id}`, + decodeGlobalID: (globalID: string) => { + const [typename, id] = globalID.split(':'); + + return { typename, id }; + }, + }, +}); +``` diff --git a/packages/plugin-scope-auth/README.md b/packages/plugin-scope-auth/README.md index 809ed8cc6..07c2a0657 100644 --- a/packages/plugin-scope-auth/README.md +++ b/packages/plugin-scope-auth/README.md @@ -1,3 +1,557 @@ # Scope Auth Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/scope-auth +The scope auth plugin aims to be a general purpose authorization plugin that can handle a wide +variety of authorization use cases, while incurring a minimal performance overhead. + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-scope-auth +``` + +#### IMPORTANT + +When using `scope-auth` with other plugins, make sure that the `scope-auth` plugin is listed first +to ensure that other plugins that wrap resolvers do not execute first. + +### Setup + +```typescript +import SchemaBuilder from '@giraphql/core'; +import ScopeAuthPlugin from '@giraphql/plugin-scope-auth'; + +type MyPerms = 'readStuff' | 'updateStuff' | 'readArticle'; + +const builder = new SchemaBuilder<{ + // Types used for scope parameters + AuthScopes: { + public: boolean; + employee: boolean; + deferredScope: boolean; + customPerm: MyPerms; + }; +}>({ + plugins: [ScopeAuthPlugin], + // scope initializer, create the scopes and scope loaders for each request + authScopes: async (context) => ({ + public: !!context.User, + // eagerly evaluated scope + employee: await context.User.isEmployee(), + // evaluated when used + deferredScope: () => context.User.isEmployee(), + // scope loader with argument + customPerm: (perm) => context.permissionService.hasPermission(context.User, perm), + }), +}); +``` + +In the above setup, We import the `scope-auth` plugin, and include it in the builders plugin list. +We also define 2 important things: + +1. The `AuthScopes` type in the builder `SchemaTypes`. This is a map of types that define the types + + used by each of your scopes. We'll see how this is used in more detail below. + +2. The `scope initializer` function, which is the implementation of each of the scopes defined in + + the type above. This function returns a map of either booleans \(indicating if the request has + the + + scope\) or functions that load the scope \(with an optional parameter\). + +The names of the scopes \(`public`, `employee`, `deferredScope`, and `customPerm`\) are all +arbitrary, and are not part of the plugin. You can use whatever scope names you prefer, and can add +as many you need. + +### Using a scope on a field + +```typescript +builder.queryType({ + fields: (t) => ({ + message: t.string({ + authScopes: { + public: true, + }, + resolve: () => 'hi', + }), + }), +}); +``` + +## Terminology + +A lot of terms around authorization are overloaded, and can mean different things to different +people. Here is a short list of a few terms used in this document, and how they should be +interpreted: + +- `scope`: A scope is unit of authorization that can be used to authorize a request to resolve a + + field. + +- `scope map`: A map of scope names and scope parameters. This defines the set of scopes that will + + be checked for a field or type to authorize the request the resolve a resource. + +- `scope loader`: A function for dynamically loading scope given a scope parameter. Scope loaders + + are ideal for integrating with a permission service, or creating scopes that can be customized + + based in the field or values that they are authorizing. + +- `scope parameter`: A parameter that will be passed to a scope loader. These are the values in the + + authScopes objects. + +- `scope initializer`: The function that creates the scopes or scope loaders for the current + + request. + +While this plugin uses `scopes` as the term for it's authorization mechanism, this plugin can easily +be used for role or permission based schemes, and is not intended to dictate a specific philosophy +around how to authorize requests/access to resources. + +## Use cases + +Examples below assume the following builder setup: + +```typescript +const builder = new SchemaBuilder<{ + // Types used for scope parameters + AuthScopes: { + public: boolean; + employee: boolean; + deferredScope: boolean; + customPerm: MyPerms; + }; +}>({ + plugins: [ScopeAuthPlugin], + authScopes: async (context) => ({ + public: !!context.User, + employee: await context.User.isEmployee(), + deferredScope: () => context.User.isEmployee(), + customPerm: (perm) => context.permissionService.hasPermission(context.User, perm), + }), +}); +``` + +### Top level auth on queries and mutations + +To add an auth check to root level queries or mutations, add authScopes to the field options: + +```typescript +builder.queryType({ + fields: (t) => ({ + internalMessage: t.string({ + authScopes: { + employee: true, + }, + resolve: () => 'hi', + }), + }), +}); +``` + +This will require the requests to have the `employee` scope. Adding multiple scopes to the +`authScopes` object will check all the scopes, and if the user has any of the scopes, the request +will be considered authorized for the current field. Subscription and Mutation root fields work the +same way. + +### Auth on nested fields + +Fields on nested objects can be authorized the same way scopes are authorized on the root types. + +```typescript +builder.objectType(Article, { + fields: (t) => ({ + title: t.exposeString('title', { + authScopes: { + employee: true, + }, + }), + }), +}); +``` + +### Default auth for all fields on types + +To apply the same scope requirements to all fields on a type, you can define an `authScope` map in +the type options rather than on the individual fields. + +```typescript +builder.objectType(Article, { + authScopes: { + public: true, + }, + fields: (t) => ({ + title: t.exposeString('title', {}), + content: t.exposeString('content', {}), + }), +}); +``` + +### Overwriting default auth on field + +In some cases you may want to use default auth scopes for a type, but need to change the behavior +for one specific field. + +To add additional requirements for a specific field you can simply add additional scopes on the +field itself. + +```typescript +builder.objectType(Article, { + authScopes: { + public: true, + }, + fields: (t) => ({ + title: t.exposeString('title', {}), + viewCount: t.exposeInt('viewCount', { + authScopes: { + employee: true, + }, + }), + }), +}); +``` + +To remove the type level scopes for a field, you can use the `skipTypeScopes` option: + +```typescript +builder.objectType(Article, { + authScopes: { + public: true, + }, + fields: (t) => ({ + title: t.exposeString('title', { + skipTypeScopes: true, + }), + content: t.exposeString('title', {}), + }), +}); +``` + +This will allow non-logged in users to resolve the title, but not the content of an Article. +`ignoreScopesFromType` can be used in conjunction with `authScopes` on a field to completely +overwrite the default scopes. + +### Generalized auth functions with field specific arguments + +The scopes we have covered so far have all been related to information that applies to a full +request. In more complex applications you may not make sense to enumerate all the scopes a request +is authorized for ahead of time. To handle these cases you can define a scope loader which takes a +parameter and dynamically determines if a request is authorized for a scope using that parameter. + +One common example of this would be a permission service that can check if a user or request has a +certain permission, and you want to specify the specific permission each field requires. + +```typescript +builder.queryType({ + fields: (t) => ({ + articles: t.field({ + type: [Article], + authScopes: { + customPerm: 'readArticle', + }, + resolve: () => Article.getSome(), + }), + }), +}); +``` + +In the example above, the authScope map uses the coolPermission scope loader with a parameter of +`readArticle`. The first time a field requests this scope, the coolPermission loader will be called +with `readArticle` as its argument. This scope will be cached, so that if multiple fields request +the same scope, the scope loader will still only be called once. + +The types for the parameters you provide for each scope are based on the types provided to the +builder in the `AuthScopes` type. + +### Setting scopes that apply for a full request + +We have already seen several examples of this. For scopes that apply to a full request like `public` +or `employee`, rather than using a scope loader, the scope initializer can simply use a boolean to +indicate if the request has the given scope. If you know ahead of time that a scope loader will +always return false for a specific request, you can do something like the following to avoid the +additional overhead of running the loader: + +```typescript +const builder = new SchemaBuilder<{ + AuthScopes: { + humanPermission: string; + }; +}>({ + plugins: [ScopeAuthPlugin], + authScopes: async (context) => ({ + humanPermission: context.user.isHuman() ? (perm) => context.user.hasPermission(perm) : false, + }), +}); +``` + +This will ensure that if a request access a field that requests a `humanPermission` scope, and the +request is made by another service or bot, we don't have to run the `hasPermission` check at all for +those requests, since we know it would return false anyways. + +### Logical operations on auth scopes \(any/all\) + +By default the the scopes in a scope map are evaluated in parallel, and if the request has any of +the requested scopes, the field will be resolved. In some cases, you may want to require multiple +scopes: + +```typescript +builder.objectType(Article, { + fields: (t) => ({ + title: t.exposeString('title', {}), + viewCount: t.exposeInt('viewCount', { + authScopes: { + $all: { + $any: { + employee: true, + deferredScope: true, + }, + public: true, + }, + }, + }), + }), +}); +``` + +You can use the built in `$any` and `$all` scope loaders to combine requirements for scopes. The +above example requires a request to have either the `employee` or `deferredScope` scopes, and the +`public` scope. `$any` and `$all` each take a scope map as their parameters, and can be nested +inside each other. + +### Auth that depends on parent value + +For cases where the required scopes depend on the value of the requested resource you can use a +function in the `authScopes` option that returns the scope map for the field. + +```typescript +builder.objectType(Article, { + fields: (t) => ({ + viewCount: t.exposeInt('viewCount', { + authScopes: (article, args, context, info) => { + if (context.User.id === article.author.id) { + // If user is author, let them see it + // returning a boolean lets you set auth without specifying other scopes to check + return true; + } + + // If the user is not the author, require the employee scope + return { + employee: true, + }; + }, + }), + }), +}); +``` + +authScope functions on fields will receive the same arguments as the field resolver, and will be +called each time the resolve for the field would be called. This means the same authScope function +could be called multiple time for the same resource if the field is requested multiple times using +an alias. + +returning a boolean from an auth scope function is an easy way to allow or disallow a request from +resolving a field without needing to evaluate additional scopes. + +### Setting type level scopes based on the parent value + +You can also use a function in the authScope option for types. This function will be invoked with +the parent and the context as its arguments, and should return a scope map. + +```typescript +builder.objectType(Article, { + authScope: (parent, context) => { + if (parent.isPublished()) { + return { + public: true, + }; + } + + return { + employee: true, + }; + }, + fields: (t) => ({ + title: t.exposeString('title', {}), + }), +}); +``` + +The above example uses an authScope function to prevent the fields of an article from being loaded +by non employees unless they have been published. + +### Setting scopes based on the return value of a field + +This is a use that is not currently supported. The current work around is to move those checks down +to the returned type. The downside of this is that any resulting permission errors will appear on +the fields of the returned type rather than the parent field. + +### Granting access to a resource based on how it is accessed + +In some cases, you may want to grant a request scopes to access certain fields on a child type. To +do this you can use `$granted` scopes. + +```typescript +builder.queryType({ + fields: (t) => ({ + freeArticle: t.field({ + grantScopes: ['readArticle'], + // or + grantScopes: (parent, args, context, info) => ['readArticle'], + }), + }), +}); + +builder.objectType(Article, { + authScopes: { + public: true, + $granted: 'readArticle', + } + fields: (t) => ({ + title: t.exposeString('title', {}), + }), +}); +``` + +In the above example, the fields of the `Article` type normally require the `public` scope granted +to logged in users, but can also be accessed with the `$granted` scope `readArticle`. This means +that if the field that returned the Article "granted" the scope, the article ran be read. The +`freeArticle` field on the `Query` type grants this scope, allowing anyone querying that field to +access fields of the free article. `$granted` scopes are separate from other scopes, and do not give +a request access to normal scopes of the same name. `$granted` scopes are also not inherited by +nested children, and would need to be explicitly passed down for each field if you wanted to grant +access to nested children. + +### Reusing checks for multiple, but not all fields + +You may have cases where groups of fields on a type are accessible using some shared condition. This +is another case where `$granted` scopes can be helpful. + +```typescript +builder.objectType(Article, { + grantScopes: (article, context) => { + if (context.User.id === article.author.id) { + return ['author', 'readArticle']; + } + + if (article.isDraft()) { + return []; + } + + return ['readArticle']; + }, + fields: (t) => ({ + title: t.exposeString('title', { + authScopes: { + $granted: 'readArticle', + }, + }), + content: t.exposeString('content', { + authScopes: { + $granted: 'readArticle', + }, + }), + viewCount: t.exposeInt('viewCount', { + authScopes: { + $granted: 'author', + }, + }), + }), +}); +``` + +In the above example, `title`, `content`, and `viewCount` each use `$granted` scopes. In this case, +rather than scopes being granted by the parent field, they are granted by the the Article type +itself. This allows the access to each field to change based on some dynamic conditions \(if the +request is from the author, and if the article is a draft\) without having to duplicate that logic +in each individual field. + +### Interfaces + +Interfaces can define auth scopes on their fields the same way objects do. Fields for a type will +run checks for each interface it implements separately, meaning that a request would need to satisfy +the scope requirements for each interface separately before the field is resolved. + +## When checks are run, and how things are cached + +### Scope Initializer + +The scope initializer would be run once the first time a field protected by auth scopes is resolved, +its result will be cached for the current request. + +### authScopes functions on fields + +when using a function for `authScopes` on a field, the function will be run each time the field is +resolved, since it has access to all the arguments passed to the resolver + +### authScopes functions on types + +when using a function for `authScopes` on a type, the function will be run the once for each +instance of that type in the response. It will be run lazily when the first field for that object is +resolved, and its result will be cached and reused by all fields for that instance of the type. + +### scope loaders + +Scope loaders will be run run whenever a field requires the corresponding scope with a unique +parameter. The scope loader results are cached per request based on a combination of the name of the +scope, and its parameter. + +### grantScope on field + +`grantScopes` on a field will run after the field is resolved, and is not cached + +### grantScope on type + +`grantScopes` on a type \(object or interface\) will run when the first field on the type is +resolved. It's result will be cached and reused for each field of the same instance of the type. + +## API + +### Types + +- `AuthScopes`: `extends {}`. Each property is the name of its scope, each value is the type for the + + scopes parameter. + +- `ScopeLoaderMap`: Object who's keys are scope names \(from `AuthScopes`\) and whos values are + either + + booleans \(indicating whether or not the request has the scope\) or function that take a parameter + + \(type from `AuthScope`\) and return `MaybePromise` + +- `ScopeMap`: A map of scope names to parameters. Based on `AuthScopes`, may also contain `$all`, + + `$any` or `$granted`. + +### Builder + +- `authScopes`: \(context: Types\['Context'\]\) => `MaybePromise>` + +### Object and Interface options + +- `authScopes`: `ScopeMap` or `function`, accepts `parent` and `context` returns + + `MaybePromise` + +- `grantScopes`: `function`, accepts `parent` and `context` returns `MaybePromise` + +### Field Options + +- `authScopes`: `ScopeMap` or `function`, accepts same arguments as resolver, returns + + `MaybePromise` + +- `grantScopes`: `string[]` or `function`, accepts same arguments as resolver, returns + + `MaybePromise` + +- `skipTypeScopes`: `boolean` +- `skipInterfaceScopes`: `boolean` + +### toSchema options + +- `disableScopeAuth`: disable the scope auth plugin. Useful for testing. diff --git a/packages/plugin-simple-objects/README.md b/packages/plugin-simple-objects/README.md index 394b5ea01..ed9d7583f 100644 --- a/packages/plugin-simple-objects/README.md +++ b/packages/plugin-simple-objects/README.md @@ -3,4 +3,89 @@ The Simple Objects Plugin provides a way to define objects and interfaces without defining type definitions for those objects, while still getting full type safety. -## Full docs available at https://giraphql.com/plugins/simple-objects +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-simple-objects +``` + +### Setup + +```typescript +import SimpleObjectsPlugin from '@giraphql/plugin-simple-objects'; +const builder = new SchemaBuilder({ + plugins: [SimpleObjectsPlugin], +}); +``` + +### Example + +```typescript +import SchemaBuilder from '@giraphql/core'; +import SimpleObjectsPlugin from '@giraphql/plugin-simple-objects'; + +const builder = new SchemaBuilder({ + plugins: [SimpleObjectsPlugin], +}); + +const ContactInfo = builder.simpleObject('ContactInfo', { + fields: (t) => ({ + email: t.string({ + nullable: false, + }), + phoneNUmber: t.string({ + nullable: true, + }), + }), +}); + +const Node = builder.simpleInterface('Node', { + fields: (t) => ({ + id: t.id({ + nullable: false, + }), + }), +}); + +const UserType = builder.simpleObject('User', { + interfaces: [Node], + fields: (t) => ({ + firstName: t.string({}), + lastName: t.string({}), + contactInfo: t.field({ + type: ContactInfo, + nullable: false, + }), + }), +}); + +builder.queryType({ + fields: (t) => ({ + user: t.field({ + type: UserType, + args: { + id: t.arg.id({ required: true }), + }, + resolve: (parent, args, { User }) => { + return { + id: '1003', + firstName: 'Leia', + lastName: 'Organa', + contactInfo: { + email: 'leia@example.com', + phoneNUmber: null, + }, + }; + }, + }), + }), +}); +``` + +## Limitations + +When using simpleObjects in combination with other plugins like authorization, those plugins may use +`unknown` as the parent type in some custom fields \(eg. `parent` of a permission check function on +a field\). diff --git a/packages/plugin-smart-subscriptions/README.md b/packages/plugin-smart-subscriptions/README.md index eae792578..321c3aea1 100644 --- a/packages/plugin-smart-subscriptions/README.md +++ b/packages/plugin-smart-subscriptions/README.md @@ -1,22 +1,197 @@ # Smart Subscriptions Plugin for GiraphQL -This plugin provides a way of turning queries into graphql subscriptions. Each field, Object, and -Interface in a schema can define subscriptions to be registerd when that field or type is used in a +This plugin provides a way of turning queries into GraphQL subscriptions. Each field, Object, and +Interface in a schema can define subscriptions to be registered when that field or type is used in a smart subscription. The basic flow of a smart subscription is: 1. Run the query the smart subscription is based on and push the initial result of that query to the - subsciption -1. As the query is resolved, register any subscriptions defined on fields or types that where used + + subscription + +2. As the query is resolved, register any subscriptions defined on fields or types that where used + in the query -1. When any of the subscriptions are triggered, re-execute the query and push the updated data to - the subsciption. + +3. When any of the subscriptions are triggered, re-execute the query and push the updated data to + + the subscription. There are additional options which will allow only the sub-tree of a field/type that triggered a fetch to re-resolved. This pattern makes it easy to define subscriptions without having to worry about what parts of your -schema are accessible via the subscribe query, since any type or field can register a subscrption. +schema are accessible via the subscribe query, since any type or field can register a subscription. + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-auth +``` + +### Setup + +```typescript +import SchemaBuilder from '@giraphql/core'; +import SmartSubscriptionsPlugin from '@giraphql/plugin-smart-subscriptions'; + +const builder = new SchemaBuilder({ + plugins: [SmartSubscriptionsPlugin], + smartSubscriptions: { + debounceDelay: number | null; + subscribe: ( + name: string, + context: Context, + cb: (err: unknown, data?: unknown) => void, + ) => Promise | void; + unsubscribe: (name: string, context: Context) => Promise | void; + }, +}); +``` + +#### Helper for ussage with async iterators + +```typescript +const builder = new SchemaBuilder({ + smartSubscriptions: { + ...subscribeOptionsFromIterator((name, { pubsub }) => { + return pubsub.asyncIterator(name); + }), + }, +}); +``` + +### Creaating a smart subscription + +```typescript +builder.queryFields((t) => ({ + polls: t.field({ + type: ['Poll'], + smartSubscription: true, + subscribe: (subscriptions, root, args, ctx, info) => { + subscriptions.register('poll-added') + subscriptions.register('poll-delted') + }, + resolve: (root, args, ctx, info) => { + return ctx.getThings(); + }, + }), +}) +``` + +Adding `smartSubscription: true` to a query field creates a field of the same name on the +`Subscriptions` type. The `subscribe` option is optional, and shows how a field can register a +subscription. + +This would be queried as: + +```graphql +subsciption { + polls { + question + answers { + id + value + } + } +} +``` + +### registering subscriptions for objects + +```typescript +builder.objectType('Poll', { + subscribe: (subscriptions, poll, context) => { + subscriptions.register(`poll/${poll.id}`) + }, + fields: (t) => ({ + question: t.exposeString('question', {}), + answers: t.field({...}), + }), +}); +``` + +This will create a new subscription for every `Poll` that is returned in the subscription. When the +query is updated to fetch a new set of results because a subscription event fired, the subscribe +call will be called again for each poll in the new result set. + +#### more options + +```typescript +builder.objectType('Poll', { + subscribe: (subscriptions, poll, context) => { + subscriptions.register(`poll/${poll.id}`, { + filter: (value) => true | false, + invalidateCache: (value) => context.PollCache.remove(poll.id), + refetch: (): => context.Polls.fetchByID(poll.id)!), + }); + }, + fields: (t) => ({ + ... + }), +}); +``` + +Passing a `filter` function will filter the events, any only cause a re-fetch if it returns true. + +`invalidateCache` is called before refetching data, to allow any cache invalidation to happen so +that when the new data is loaded, results are not stale. + +`refetch` enables directly refetching the current object. When refetch is provided and a +subscription event fires for the current object, or any of its children, other parts of the query +that are not dependents of this object will no be refetched. + +### registering subscriptions for fields + +```typescript +builder.objectType('Poll', { + fields: (t) => ({ + question: t.exposeString('question', {}), + answers: t.field({ + nullable: true, + type: ['Answer'], + subscribe: (subscriptions, poll) => subscriptions.register(`poll-answers/${poll.id}`), + resolve: (parent, args, context, info) => { + return parent.answers; + }, + }), + }), +}); +``` + +#### more options for fields + +```typescript +builder.objectType('Poll', { + fields: (t) => ({ + question: t.exposeString('question', {}), + answers: t.field({ + nullable: true, + type: ['Answer'], + canRefetch: true, + subscribe: (subscriptions, poll) => + subscriptions.register(`poll-answers/${poll.id}`, { + filter: (value) => true | false, + invalidateCache: (value) => context.PollCache.remove(poll.id), + }), + resolve: (parent, args, context, info) => { + return parent.answers; + }, + }), + }), +}); +``` + +Similar to subscriptions on objects, fields can pass `filter` and `invalidateCache` functions when +registering a subscription. Rather than passing a `refetch` function, you can set `canRefetch` to +`true` in the field options. This will re-run the current resolve function to update it \(and it's +children\) without having to re-run the rest of the query. + +### Known limitations -## Full docs available at https://giraphql.com/plugins/smart-subscriptions +- Currently value passed to `filter` and `invalidateCache` is typed as `unknown`. This should be + improved in the future. +- Does not work with list fields implemented with async-generators (used for `@stream` queries) diff --git a/packages/plugin-sub-graph/README.md b/packages/plugin-sub-graph/README.md index 12b53b779..f51a4a2ce 100644 --- a/packages/plugin-sub-graph/README.md +++ b/packages/plugin-sub-graph/README.md @@ -1,3 +1,100 @@ -# Mocks Plugin for GiraphQL +# SubGraph Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/sub-graph +A plugin for creating sub-selections of your graph. This Allows you to use the same code/types for +multiple variants of your API. + +One common use case for this is to share implementations between your public and internal APIs, by +only exposing a subset of your graph publicly. + +## Usage + +### Install + +```bash +yarn add @giraphql/plugin-sub-graph +``` + +### Setup + +```typescript +import SubGraphPlugin from '@giraphql/plugin-sub-graph'; +const builder = new SchemaBuilder<{ + SubGraphs: 'Public' | 'Internal'; +}>({ + plugins: [SubGraphPlugin], + subGraphs: { + defaultsForTypes: [], + inheritFieldGraphsFromType: true, + }, +}); + +//in another file: + +const schema = builder.toSchema({}); +const publicSchema = builder.toSchema({ subGraph: 'Public' }); +const internalSchema = builder.toSchema({ subGraph: 'Internal' }); +``` + +### Options on Types + +- `subGraphs`: An optional array of sub-graph the type should be included in. + +### Object and Interface types: + +- `defaultSubGraphsForFields`: Default sub-graph for fields of the type to be included in. + +## Options on Fields + +- `subGraphs`: An optional array of sub-graph the field to be included in. If not provided, will + + fallback to: + + - `defaultSubGraphsForFields` if set on type + - `subGraphs` of the type if `subGraphs.fieldsInheritFromTypes` was set in the builder + - an empty array + +### Options on Builder + +- `subGraphs.defaultForTypes`: Specifies what sub-graph a type is part of by default. +- `subGraphs.fieldsInheritFromTypes`: defaults to `false`. When true, fields on a type will default + + to being part of the same sub-graph as their parent type. Only applies when type does not have + + `defaultSubGraphsForFields` set. + +You can mock any field by adding a mock in the options passed to `builder.buildSchema` under +`mocks.{typeName}.{fieldName}`. + +### Usage + +```typescript +builder.queryType({ + // Query type will be available in default, Public, and Internal schemas + subGraphs: ['Public', 'Internal'], + // Fields on the Query object will now default to not being a part of any subgraph + defaultSubGraphsForFields: []; + fields: (t) => ({ + someField: t.string({ + // someField will be in the default schema and "Internal" sub graph, but + // not present in the Public sub graph + subGraphs: ['Internal'] + resolve: () => { + throw new Error('Not implemented'); + }, + }), + }), +}); +``` + +### Missing types + +When creating a sub-graph, the plugin will only copy in types that are included in the sub-graph, +either by explicitly setting it on the type, or because the sub-graph is included in the default +list. Like types, output fields that are not included in a sub-graph will also be omitted. Arguments +and fields on Input types can not be removed because that would break assumptions about arguments +types in resolves. + +If a type that is not included in the sub-graph is referenced by another part of the graph that is +included in the graph, a runtime error will be thrown when the sub graph is constructed. This can +happen in a number of cases including cases where a removed type is used in the interfaces of an +object, a member of a union, or the type of an field argument. diff --git a/packages/plugin-validation/README.md b/packages/plugin-validation/README.md index bbb04e628..24d651b4a 100644 --- a/packages/plugin-validation/README.md +++ b/packages/plugin-validation/README.md @@ -1,3 +1,356 @@ # Validation Plugin for GiraphQL -## Full docs available at https://giraphql.com/plugins/validation +A plugin for adding validation for field arguments based on +[zod](https://github.com/colinhacks/zod). This plugin does not expose zod directly, but most of the +options map closely to the validations available in zod. + +## Usage + +### Install + +To use the validation plugin you will need to install both `zod` package and the validation plugin: + +zod version 3 is still in beta, but is compatible. Zod version 2 was deprecated, so installing zod@1 +or zod@3 is recommended. + +```bash +yarn add zod@1 @giraphql/plugin-validation +# or +yarn add zod@3 @giraphql/plugin-validation +``` + +### Setup + +```typescript +import ValidationPlugin from '@giraphql/plugin-validation'; +const builder = new SchemaBuilder({ + plugins: [ValidationPlugin], +}); + +builder.queryType({ + fields: (t) => ({ + simple: t.boolean({ + nullable: true, + args: { + // Validate individual args + email: t.arg.string({ + validate: { + email: true, + }, + }), + phone: t.arg.string({}), + }, + // Validate all args together + validate: (args) => !!args.phone || !!args.email, + resolve: () => true, + }), + }), +}); +``` + +### Examples + +#### With custom message + +```typescript +builder.queryType({ + fields: (t) => ({ + withMessage: t.boolean({ + nullable: true, + args: { + email: t.arg.string({ + validate: { + email: [true, { message: 'invalid email address' }], + }, + }), + phone: t.arg.string({}), + }, + validate: [ + (args) => !!args.phone || !!args.email, + { message: 'Must provide either phone number or email address' }, + ], + resolve: () => true, + }), +}); +``` + +### Validating List + +```typescript +builder.queryType({ + fields: (t) => ({ + list: t.boolean({ + nullable: true, + args: { + list: t.arg.stringList({ + validate: { + items: { + email: true, + }, + maxLength: 3, + }, + }), + }, + resolve: () => true, + }), + }), +}); +``` + +### Using your own zod schemas + +If you just want to use a zod schema defined somewhere else, rather than using the validation +options you can use the `schema` option: + +```typescript +builder.queryType({ + fields: (t) => ({ + list: t.boolean({ + nullable: true, + args: { + max5: t.arg.int({ + validate: { + schema: zod.number().int().max(5), + }, + }), + }, + resolve: () => true, + }), + }), +}); +``` + +## API + +### On Object fields + +- `validate`: `Refinement` | `Refinement[]`. + +### On InputObjects + +- `validate`: `Refinement` | `Refinement[]` + +### On arguments and fields on InputObjects + +- `validate`: `Refinement` | `ValidationOptions` + +### `Refinement` + +A `Refinement` is a function that will be passed to the `zod` `refine` method. It receives the args +object, input object, or value of the specific field the refinement is defined on. It should return +a `boolean`. + +`Refinement`s can either be just a function: `(val) => isValid(val)`, or an array with the function, +and an options object like: `[(val) => isValid(val), { message: 'field should be valid' }]`. + +The options object may have a `message` property, and if the type being validated is an object, it +can also include a `path` property with an array of strings indicating the path of the field in the +object being validated. See the zod docs on `refine` for more details. + +### `ValidationOptions` + +The validation options available depend on the type being validated. Each property of +`ValidationOptions` can either be a value specific to the constraint, or an array with the value, +and the options passed to the underlying zod method. This options object can be used to set a custom +error message: + +```ts +{ + validate: { + max: [10, { message: 'should not be more than 10' }], + int: true, + } +} +``` + +#### Number + +- `type`?: `'number'` +- `refine`?: `Refinement | Refinement[]` +- `min`?: `Constraint` +- `max`?: `Constraint` +- `positive`?: `Constraint` +- `nonnegative`?: `Constraint` +- `negative`?: `Constraint` +- `nonpositive`?: `Constraint` +- `int`?: `Constraint` +- `schema`?: `ZodSchema` + +#### BigInt + +- `type`?: `'bigint'` +- `refine`?: `Refinement | Refinement[]` +- `schema`?: `ZodSchema` + +#### Boolean + +- `type`?: `'boolean'` +- `refine`?: `Refinement | Refinement[]` +- `schema`?: `ZodSchema` + +#### Date + +- `type`?: `'boolean'` +- `refine`?: `Refinement | Refinement[]` +- `schema`?: `ZodSchema` + +#### String + +- `type`?: `'string'`; +- `refine`?: `Refinement | Refinement[]` +- `minLength`?: `Constraint` +- `maxLength`?: `Constraint` +- `length`?: `Constraint` +- `url`?: `Constraint` +- `uuid`?: `Constraint` +- `email`?: `Constraint` +- `regex`?: `Constraint` +- `schema`?: `ZodSchema` + +#### Object + +- `type`?: `'object'`; +- `refine`?: `Refinement | Refinement[]` +- `schema`?: `ZodSchema` + +#### Array + +- `type`?: `'array'`; +- `refine`?: `Refinement | Refinement[]` +- `minLength`?: `Constraint` +- `maxLength`?: `Constraint` +- `length`?: `Constraint` +- `items`?: `ValidationOptions | Refinement` +- `schema`?: `ZodSchema` + +### How it works + +Each arg on an object field, and each field on an input type with validation will build its own zod +validator. These validators will be a union of all potential types that can apply the validations +defined for that field. For example, if you define an optional field with a `maxLength` validator, +it will create a zod schema that looks something like: + +```ts +zod.union([zod.null(), zod.undefined(), zod.array().maxLength(5), zod.string().maxLength(5)]); +``` + +If you set and `email` validation instead the schema might look like: + +```ts +zod.union([zod.null(), zod.undefined(), zod.string().email()]); +``` + +At runtime, we don't know anything about the types being used by your schema, we can't infer the +expected js type from the type definition, so the best we can do is limit the valid types based on +what validations they support. The `type` validation allows explicitly validating the `type` of a +field to be one of the base types supported by zod: + +```ts +// field +{ +validate: { + type: 'string', + maxLength: 5 +} +// generated +zod.union([zod.null(), zod.undefined(), zod.string().maxLength(5)]); +``` + +There are a few exceptions the the above: + +1: args and input fields that are `InputObject`s always use `zod.object()` rather than creating a +union of potential types. + +1. args and input fields that are list types always use `zod.array()`. + +1. If you only include a `refine` validation (or just pass a function directly to validate) we will + just use `zod`s unknown validator instead: + +```ts +// field +{ + validate: (val) => isValid(val), +} +// generated +zod.union([zod.null(), zod.undefined(), zod.unknown().refine((val) => isValid(val))]); +``` + +If the validation options include a `schema` that schema will be used as an intersection wit the +generated validator: + +```ts +// field +{ + validate: { + int: true, + schema: zod.number().max(10), +} +// generated +zod.union([zod.null(), zod.undefined(), zod.intersection(zod.number().max(10), zod.number().int())]); +``` + +### Sharing schemas with client code + +The easiest way to share validators is the use the to define schemas for your fields in an external +file using the normal zod APIs, and then attaching those to your fields using the `schema` option. + +```ts +// shared +import { ValidationOptions } from '@giraphql/plugin-validation'; + +const numberValidation = zod.number().max(5); + +// server +builder.queryType({ + fields: (t) => ({ + example: t.boolean({ + args: { + num: t.arg.int({ + validate: { + schema: numberValidation, + } + }), + }, + resolve: () => true, + }), + }); +}); + +// client +numberValidator.parse(3) // pass +numberValidator.parse('3') // fail +``` + +You can also use the `createZodSchema` helper from the plugin directly to create zod Schemas from an +options object: + +```ts +// shared +import { ValidationOptions } from '@giraphql/plugin-validation'; + +const numberValidation: ValidationOptions = { + max: 5, +}; + +// server +builder.queryType({ + fields: (t) => ({ + example: t.boolean({ + args: { + num: t.arg.int({ + validate: numberValidation, + }), + }, + resolve: () => true, + }), + }); +}); + +// client +import { createZodSchema } from '@giraphql/plugin-validation'; + +const validator = createZodSchema(numberValidator); + +validator.parse(3) // pass +validator.parse('3') // fail +```