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:
- File name (camelCase):
fooBar.ts - Exported const (camelCase + suffix):
- fragment:
fooBarFragment - query:
fooBarQuery - mutation:
fooBarMutation - subscription:
fooBarSubscription - GraphQL definition name (PascalCase, same semantic name):
- fragment:
fragment FooBar on ... - query:
query fooBar(...)(follow module-local pattern; PascalCase or camelCase as used in target folder) - Generated file name must follow source file name:
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.
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.tsfile next to every definition undergraphql/folders, for exampleprojectStatusReport.generated.tswithProjectStatusReportQuery.
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.