Skip to content

GraphQL services and definitions

How to write a GraphQL API service, structure queries, mutations, subscriptions and fragments, and generate TypeScript types from them.

GraphQL API services

An API service wraps the Apollo Client calls, error handling and response parsing, so that components stay free of GraphQL client details and several features can share the same query or mutation logic.

Example: ProjectStatusReportApi

projectStatusReportApi.ts (app/frontend/src/easy-ai/projectStatusReport/shared/api/) manages project status reports. The excerpt below shows one query, one mutation and one subscription.

import { ApolloClient, FetchResult } from "@apollo/client/core";
import apolloClient from "@/src/shared/plugins/apolloClient";
import { notifyOnGraphqlValidationErrors } from "@/src/shared/api/utils";
import { ProjectStatusReportQuery } from "@/src/easy-ai/projectStatusReport/detail/graphql/queries/projectStatusReport.generated";
import { projectStatusReportQuery } from "@/src/easy-ai/projectStatusReport/detail/graphql/queries/projectStatusReport";
import { FullProjectStatusReportFragment } from "@/src/easy-ai/projectStatusReport/detail/graphql/fragments/fullProjectStatusReport.generated";
import { useSubscription, UseSubscriptionReturn } from "@vue/apollo-composable";
import { projectStatusReportUpdatedSubscription } from "@/src/easy-ai/projectStatusReport/detail/graphql/subscriptions/projectStatusReportUpdated";
import { DeleteProjectStatusReportMutation } from "@/src/easy-ai/projectStatusReport/detail/graphql/mutation/deleteProjectStatusReport.generated";
import { deleteProjectStatusReportMutation } from "@/src/easy-ai/projectStatusReport/detail/graphql/mutation/deleteProjectStatusReport";
import {
  ProjectStatusReportUpdatedSubscription,
  ProjectStatusReportUpdatedSubscriptionVariables,
} from "@/src/easy-ai/projectStatusReport/detail/graphql/subscriptions/projectStatusReportUpdated.generated";

export class ProjectStatusReportApi {
  constructor(private readonly apolloClient: ApolloClient<object>) {}

  getProjectStatusReport = async (projectStatusReportId: string): Promise<FullProjectStatusReportFragment | null> => {
    const { data }: FetchResult<ProjectStatusReportQuery> = await this.apolloClient.query({
      query: projectStatusReportQuery,
      variables: {
        id: projectStatusReportId,
      },
    });

    return data?.easyAiProjectStatusReport || null;
  };

  deleteProjectStatusReport = async (projectStatusReportId: string): Promise<void> => {
    const { data }: FetchResult<DeleteProjectStatusReportMutation> = await this.apolloClient.mutate({
      mutation: deleteProjectStatusReportMutation,
      variables: {
        id: projectStatusReportId,
      },
    });

    notifyOnGraphqlValidationErrors(data?.deleteEasyAiProjectStatusReport);
  };

  useProjectStatusReportSubscription = (
    projectStatusReportId: string
  ): UseSubscriptionReturn<ProjectStatusReportUpdatedSubscription, ProjectStatusReportUpdatedSubscriptionVariables> => {
    return useSubscription(projectStatusReportUpdatedSubscription, { id: projectStatusReportId });
  };
}

export default new ProjectStatusReportApi(apolloClient);

Example: using a subscription

Subscribe to updates of a project status report and stop the previous subscription before starting a new one:

public initSubscription = (projectStatusReportId: string) => {
  if (this.stopCurrentSubscription) {
    this.stopCurrentSubscription();
  }

  if (this.shouldSubscribe()) {
    const { onResult, stop } = projectStatusReportApi.useProjectStatusReportSubscription(projectStatusReportId);
    this.stopCurrentSubscription = stop;
    onResult(this.processProjectStatusReportSubscription);
  }
};

private processProjectStatusReportSubscription = (response: FetchResult<ProjectStatusReportUpdatedSubscription>) => {
  if (!response.data?.easyAiProjectStatusReportUpdated) return;
  this.activeProjectStatusReport.value = response.data?.easyAiProjectStatusReportUpdated;
};

GraphQL definitions

Keep queries, mutations, subscriptions and fragments in dedicated files, separate from the API service.

Best practices

  • Keep one definition per file, in the module folder structure.
  • Define a fragment for every recurring field set. Fragments can be nested.
  • Follow the naming conventions below.

Naming conventions (required)

For every definition under app/frontend/src/**/graphql/**, keep these names aligned:

  1. File name (camelCase): fooBar.ts
  2. Exported const (camelCase + suffix):
  3. fragment: fooBarFragment
  4. query: fooBarQuery
  5. mutation: fooBarMutation
  6. subscription: fooBarSubscription
  7. GraphQL definition name (PascalCase, same semantic name):
  8. fragment: fragment FooBar on ...
  9. query: query fooBar(...) (follow module-local pattern; PascalCase or camelCase as used in target folder)
  10. Generated file name must follow source file name:
  11. fooBar.ts -> fooBar.generated.ts

Rules:

  • Keep naming consistent with neighboring files in the same folder.
  • Do not rename prefixes like base*, available*, full* unless there is a clear local pattern and all imports are updated.
  • When renaming a GraphQL source file, update imports of both source and generated files.

Examples:

  • fullProjectStatusReport.ts -> fullProjectStatusReportFragment -> fragment FullProjectStatusReport ...
  • projectStatusReport.ts -> projectStatusReportQuery -> query projectStatusReport(...)

Example of definitions

GraphQL fragment: fullProjectStatusReport

fullProjectStatusReport.ts defines all fields of a project status report and nests two other fragments, projectStatusReportBlockDataFragment and repeatingTemplateFragment.

import { gql } from "@apollo/client";
import { projectStatusReportBlockDataFragment } from "@/src/easy-ai/projectStatusReport/detail/graphql/fragments/projectStatusReportBlockData";
import { repeatingTemplateFragment } from "@/src/easy-ai/projectStatusReport/detail/graphql/fragments/repeatingTemplateFragment";

export const fullProjectStatusReportFragment = gql`
  fragment FullProjectStatusReport on ProjectStatusReport {
    blocksToReport
    createdAt
    blocksData {
      ...ProjectStatusReportBlockData
    }
    id
    interval
    manageable
    name
    repeatingTemplate {
      ...RepeatingTemplate
    }
    project {
      identifier
    }
  }

  ${projectStatusReportBlockDataFragment}
  ${repeatingTemplateFragment}
`;

GraphQL query: projectStatusReport

projectStatusReport.ts fetches one report using fullProjectStatusReportFragment:

import { gql } from "@apollo/client";
import { fullProjectStatusReportFragment } from "@/src/easy-ai/projectStatusReport/detail/graphql/fragments/fullProjectStatusReport";

export const projectStatusReportQuery = gql`
  query projectStatusReport($id: ID!) {
    easyAiProjectStatusReport(id: $id) {
      ...FullProjectStatusReport
    }
  }

  ${fullProjectStatusReportFragment}
`;

GraphQL mutation: deleteProjectStatusReport

deleteProjectStatusReport.ts deletes a report and reuses easyErrorFragment:

import { gql } from "@apollo/client";
import { easyErrorFragment } from "@/src/shared/graphql/fragments/error";

export const deleteProjectStatusReportMutation = gql`
  mutation deleteProjectStatusReport($id: ID!) {
    deleteEasyAiProjectStatusReport(id: $id) {
      errors {
        ...EasyError
      }
    }
  }

  ${easyErrorFragment}
`;

Every mutation must return an errors field with the EasyError fragment, otherwise validation errors are not reported.

GraphQL subscription: projectStatusReportUpdated

projectStatusReportUpdated.ts listens for updates of a report:

import { gql } from "@apollo/client";
import { fullProjectStatusReportFragment } from "@/src/easy-ai/projectStatusReport/detail/graphql/fragments/fullProjectStatusReport";

export const projectStatusReportUpdatedSubscription = gql`
  subscription projectStatusReportUpdated($id: ID!) {
    easyAiProjectStatusReportUpdated(id: $id) {
      ...FullProjectStatusReport
    }
  }

  ${fullProjectStatusReportFragment}
`;

Use gql as a tagged template literal, gql`...`, never as a function call gql(`...`): the call form fails to parse with a GraphQLError: Syntax Error. The only exception is a query built dynamically, see fields from plugins.

GraphQL definitions with directives

Use GraphQL directives such as @include to include or skip fields conditionally, instead of building the query string.

export const easySprintBoardUpdateSwimlaneSubscription = gql`
  subscription easySprintBoardUpdateSwimlaneAttributes($easySprintBoardId: ID!, $hasStickyNotes: Boolean!) {
    easySprintBoardUpdateSwimlaneAttributes(easySprintBoardId: $easySprintBoardId) {
      result {
        mutationName
        easySwimlane {
          id
          color
          easyProductBacklogItem {
            id
            color
          }
          easyAgileSprint {
            id
          }
          easySprintBoard @include(if: $hasStickyNotes) {
            stickyNotesInheritColor
          }
          easyStickyNotes @include(if: $hasStickyNotes) {
            ...StickyNote
          }
        }
      }
    }
  }
  ${stickyNoteFragment}
`;

GraphQL definitions with fields from plugins

A plugin can be inactive, and then its fields are not in the GraphQL schema. A query that names such a field fails, and a directive cannot help because the field is still validated. Check that the plugin is active and build the query string in JavaScript. getValidateIssueMutation below adds the sprint fields only when sprintOn is true.

Calling gql as a function is allowed only for such dynamic queries, and needs // eslint-disable-next-line easy-rules/no-gql-function-call.

import { gql } from "@apollo/client";

export const getValidateIssueMutation = (sprintOn: boolean, checklistOn: boolean) => {
  const sprintFields = `
    easySprint {
      capacity
      closed
      name
    }
    easyStoryPoints`;

  const projectChecklistsFields = `
    addableChecklists
    addableChecklistItems
    visibleChecklists
  `;

  const validateIssueMutation = `
    mutation issueValidator ($id: ID!, $attributes: IssueAttributes!) {
      validateModalIssue(attributes: $attributes, id: $id){
        issue {
          id
          category
          subject
          project {
            id
            name
            ${checklistOn ? projectChecklistsFields : ""}
          }
          ${sprintOn ? sprintFields : ""}
        }
        errors {
          messages
        }
      }
    }`;

  // eslint-disable-next-line easy-rules/no-gql-function-call
  return gql(validateIssueMutation);
};

Generating types

TypeScript types are generated from the GraphQL schema and from your definitions, so every definition file gets typed results and variables.

bundle exec rake easyproject:graphql_generate

The task dumps the schema to app/frontend/src/shared/graphql_schema/schema.generated.graphql and then runs yarn codegen:graphql (configuration in codegen.ts). It writes:

  • app/frontend/src/shared/types/schemaTypes.generated.ts: all schema types.
  • A <name>.generated.ts file next to every definition under graphql/ folders, for example projectStatusReport.generated.ts with ProjectStatusReportQuery.

Run it after every change of a definition or of the backend schema. The generated files are git-ignored, so do not commit them, and never edit a *.generated.* file by hand.

Import generated types in services with import type where possible, as in the service example above (ProjectStatusReportQuery, FullProjectStatusReportFragment, ProjectStatusReportAttributes).

Runtime schema enums

A runtime import of an enum from schemaTypes.generated.ts makes the build depend on that file, and the build fails when it has not been generated. Use the type-only import from the generated file and take runtime enum values from app/frontend/src/shared/types/schemaEnums.ts:

import { PROMPT_TEMPLATE_USED_FOR } from "@/src/shared/types/schemaEnums";
import type { PromptTemplateUsedFor } from "@/src/shared/types/schemaEnums";

if (usedFor === PROMPT_TEMPLATE_USED_FOR.WriteAssistant) {
  // runtime-safe enum comparison
}

Do not write import { PromptTemplateUsedFor } from "@/src/shared/types/schemaTypes.generated" for runtime comparisons.

To add an enum, export an uppercase constant and a PascalCase type in schemaEnums.ts, following the existing entries:

const PROMPT_TEMPLATE_USED_FOR_VALUES = {
  Ask: "ask",
  HelpdeskAssistant: "helpdesk_assistant",
  ReadAssistant: "read_assistant",
  WriteAssistant: "write_assistant",
} as const satisfies GraphqlEnumShape<SchemaEnumObject<"PromptTemplateUsedFor">>;

export const PROMPT_TEMPLATE_USED_FOR = PROMPT_TEMPLATE_USED_FOR_VALUES as GraphqlEnumObject<
  typeof PROMPT_TEMPLATE_USED_FOR_VALUES,
  SchemaEnum<"PromptTemplateUsedFor">
>;
export type PromptTemplateUsedFor = ObjectValues<typeof PROMPT_TEMPLATE_USED_FOR>;

With generated files present, yarn tsc checks the values against the schema and reports missing keys, extra keys and mismatched strings.