amann
amann

Reputation: 5902

Remove read-only fields before mutation in GraphQL

I've got a type called Article in my schema:

type Article {
  id: ID!
  updated: DateTime
  headline: String
  subline: String
}

For updates to it, there's a corresponding input type that is used by a updateArticle(id: ID!, article: ArticleInput!) mutation:

input ArticleInput {
  headline: String
  subline: String
}

The mutation itself looks like this:

mutation updateArticle($id: ID!, $article: ArticleInput!) {
  updateArticle(id: $id, article: $article) {
    id
    updated
    headline
    subline
  }
}

The article is always saved as a whole (not individual fields one by one) and so when I pass an article to that mutation that I've previously fetched, it throws errors like Unknown field. In field "updated", Unknown field. In field "__typename" and Unknown field. In field "id". These have the root cause, that those fields aren't defined on the input type.

This is correct behaviour according to the spec:

(…) This unordered map should not contain any entries with names not defined by a field of this input object type, otherwise an error should be thrown.

Now my question is what a good way to deal these kinds of scenarios is. Should I list all properties that are allowed on the input type in my app code?

If possible I'd like to avoid this and maybe have a utility function slice them off for me which knows about the input type. However, since the client doesn't know about the schema, this would have to happen on the server side. Thus, the unnecessary properties would be transferred there, which I suppose is the reason why they shouldn't be transferred in the first place.

Is there a better way than maintaining a list of properties?

I'm using apollo-client, react-apollo and graphql-server-express.

Upvotes: 10

Views: 9791

Answers (3)

LIIT
LIIT

Reputation: 594

If one uses graphql-codegen, then one can add another codegen config to his project, like so :

import { CodegenConfig } from '@graphql-codegen/cli';

import { commonConfig } from './configs/common.config';

const classesCodegen: CodegenConfig = {
  schema: 'apps/back/src/app/schema.gql',
  documents: ['apps/front/**/*.tsx'],
  ignoreNoDocuments: true,
  generates: {
    'libs/data-layer/src/lib/gql/classes.ts': {
      plugins: ['typescript'],
      config: {
        declarationKind: {
          // directive: 'type',
          // scalar: 'type',
          input: 'class',
          // type: 'type',
          // interface: 'type',
          // arguments: 'type',
        },
        ...commonConfig,
      },
    },
  },
};

export default classesCodegen;

Above, we ask graphql-codegen to generate inputs as classes. Geneated code will be something like :

/** Material Input */
export class MaterialInput {
  /** Material's id */
  _id?: InputMaybe<Scalars['Id']['input']>;
  /** Material's coding config */
  codingConfig: CodingConfigUnionInput;
  /** Material's content */
  content?: InputMaybe<Scalars['String']['input']>;
  /** Material's cost usages */
  costUsages: Array<CostUsageInput>;
  /** Material's label */
  label: Scalars['String']['input'];
  /** Material's status id */
  statusId: Scalars['Id']['input'];
  /** Material's title */
  title?: InputMaybe<Scalars['String']['input']>;
};

By having classes instead of types or interfaces, we are now able to prune our data before sending it to backend.

Here is a small util to remove any excess data from an object, regarding a given class :

import type { C, O } from 'ts-toolbelt';
import { assign, keys, pick } from 'lodash';

export const pruneInput = <I extends object>(
  instance: O.Object,
  Class: C.Class<unknown[], I>,
): I => {
  const input = new Class();
  assign(input, pick(instance, keys(input)));
  return input;
};

Finally, you can clean your data without having to maintain this cleansing step anymore :

import type { O } from 'ts-toolbelt';

import { CostUsageInput, MaterialInput } from '@your-organization/data-layer';

import { pruneInput } from '../../../utils/prune-input.util';

import { codingConfigForm2ApiMapper } from '../../mappers/coding-config.form2api.mapper';

import type {
  MaterialForm_Material,
  MaterialInput as IMaterialInput,
} from '..';

export function materialForm2ApiMapper(
  material: O.Readonly<MaterialForm_Material>,
): O.Readonly<IMaterialInput> {
  const materialInput = pruneInput(material, MaterialInput);

  const costUsagesInput = materialInput.costUsages.map((costUsage) =>
    pruneInput(costUsage, CostUsageInput),
  );

  return {
    ...materialInput,
    costUsages: costUsagesInput,
    codingConfig: codingConfigForm2ApiMapper(material.codingConfig),
  };
}

Upvotes: 0

amann
amann

Reputation: 5902

You can use a fragment for the query, which includes all mutable fields of the data. That fragment can be used by a filter utility to remove all unwanted data before the mutation happens.

The gist is:

const ArticleMutableFragment = gql`
fragment ArticleMutable on Article {
  headline
  subline
  publishing {
    published
    time
  }
}
`

const ArticleFragment = gql`
fragment Article on Article {
  ...ArticleMutable
  id
  created
  updated
}
${ArticleMutableFragment}
`;

const query = gql`
query Article($id: ID!) {
  article(id: $id) {
    ...Article
  }
}
${ArticleFragment}
`;

const articleUpdateMutation = gql`
mutation updateArticle($id: ID!, $article: ArticleInput!) {
  updateArticle(id: $id, article: $article) {
    ...Article
  }
}
${ArticleFragment}
`;

...

import filterGraphQlFragment from 'graphql-filter-fragment';

...

graphql(articleUpdateMutation, {
  props: ({mutate}) => ({
    onArticleUpdate: (id, article) =>
      // Filter for properties the input type knows about
      mutate({variables: {id, article: filterGraphQlFragment(ArticleMutableFragment, article)}})
  })
})

...

The ArticleMutable fragment can now also be reused for creating new articles.

Upvotes: 10

Jiř&#237; Brabec
Jiř&#237; Brabec

Reputation: 298

I've personally had same idea and took @amann 's approach earlier, but after some time the conceptual flaw of using query fragments on input types became evident. You would'n have an option to pick input type field that isn't present in (corresponding) object type - is there even any?

Currently I'm describing my input data by typesafe-joi schemas and using it's stripUnknown option to filter out my form data.

Invalid data never leaves form so valid data can be statically typed.

In a sense, creating joi schema is same activity as defining "input fragment" so no code duplication takes place and your code can be type-safe.

Upvotes: 0

Related Questions