GraphQL

시작하기

유용한 자원

일반 자원:

GitLab의 GraphQL:

라이브러리

프론트엔드 개발에 GraphQL을 사용할 때 Apollo (특히 Apollo Client)와 Vue Apollo를 사용합니다.

Vue 애플리케이션에서 GraphQL을 사용하는 경우, Vue에서의 사용법 섹션이 Vue Apollo 통합 방법을 배우는 데 도움이 될 수 있습니다.

다른 사용 사례에 대해서는 Vue 외부에서의 사용법 섹션을 확인하십시오.

불변 캐시 업데이트를 위해 Immer를 사용합니다. 더 많은 정보는 불변성과 캐시 업데이트를 참조하십시오.

도구

Apollo GraphQL VS Code 확장

VS Code를 사용하는 경우, Apollo GraphQL 확장.graphql 파일에서 자동 완성을 지원합니다. GraphQL 확장을 설정하려면 다음 단계를 따르십시오:

  1. 스키마 생성: bundle exec rake gitlab:graphql:schema:dump
  2. gitlab 로컬 디렉터리의 루트에 apollo.config.js 파일 추가.
  3. 파일에 다음 내용을 채웁니다:

    module.exports = {
      client: {
        includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
        service: {
          name: 'GitLab',
          localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql',
        },
      },
    };
    
  4. VS Code를 재시작합니다.

GraphQL API 탐색

우리의 GraphQL API는 귀하의 인스턴스에서 /-/graphql-explorer 또는 GitLab.com에서 GraphiQL을 통해 탐색할 수 있습니다. 필요할 경우 GitLab GraphQL API 참조 문서를 참조하십시오.

모든 기존 쿼리와 변형을 확인하려면 GraphiQL의 오른쪽에서 Documentation explorer를 선택하십시오.

작성한 쿼리와 변형의 실행을 확인하려면 왼쪽 상단에서 Execute query를 선택하십시오.

GraphiQL 인터페이스

Apollo 클라이언트

다양한 앱에서 중복 클라이언트가 생성되는 것을 방지하기 위해 우리는 기본 클라이언트를 사용해야 합니다. 이 클라이언트는 올바른 URL로 Apollo 클라이언트를 설정하고 CSRF 헤더도 설정합니다.

기본 클라이언트는 두 가지 매개변수를 허용합니다: resolversconfig.

  • resolvers 매개변수는 로컬 상태 관리 쿼리 및 변형에 대한 리졸버 객체를 받기 위해 만들어졌습니다.

  • config 매개변수는 구성 설정 객체를 받습니다:

    • cacheConfig 필드는 Apollo 캐시를 사용자 정의하기 위해 선택적 설정 객체를 받습니다.

    • baseUrl은 메인 엔드포인트와 다른 GraphQL 엔드포인트에 대한 URL을 전달할 수 있게 해줍니다 (예: ${gon.relative_url_root}/api/graphql).

    • fetchPolicy는 컴포넌트가 Apollo 캐시와 어떻게 상호작용하길 원하는지를 결정합니다. 기본값은 “cache-first”입니다.

동일 객체에 대한 다중 클라이언트 쿼리

동일한 Apollo 클라이언트 객체에 여러 쿼리를 수행하는 경우 다음과 같은 오류를 만날 수 있습니다: Cache data may be lost when replacing the someProperty field of a Query object. To address this problem, either ensure all objects of SomeEntityhave an id or a custom merge function. 우리는 이미 id가 있는 모든 GraphQL 유형에 대해 id 존재 여부를 확인하고 있으므로 이럴 경우는 없을 것입니다 (단위 테스트를 실행할 때 이 경고가 나타나는 경우에는, 요청 시 항상 응답에 id가 포함되도록 모의 응답을 확인하십시오).

SomeEntity 유형이 GraphQL 스키마에 id 속성이 없는 경우, 이 경고를 수정하기 위해 사용자 정의 병합 함수를 정의해야 합니다.

기본 클라이언트에는 merge: true가 정의된 일부 클라이언트 전체 유형이 있으며 typePolicies에서 확인할 수 있습니다 (이는 Apollo가 후속 쿼리 시 기존 응답과 수신 응답을 병합함을 의미합니다). SomeEntity를 여기에 추가하거나 이를 위한 사용자 정의 병합 함수를 정의하는 것을 고려하십시오.

GraphQL 쿼리

런타임에 쿼리 컴파일을 저장하기 위해 webpack은 .graphql 파일을 직접 가져올 수 있습니다. 이렇게 하면 클라이언트가 쿼리를 컴파일하는 대신, webpack이 컴파일 타임에 쿼리를 사전 처리할 수 있습니다.

쿼리, 변형 및 프래그먼트를 구분하기 위해 다음 명명 규칙이 권장됩니다:

  • 쿼리용으로는 all_users.query.graphql;

  • 변형용으로는 add_user.mutation.graphql;

  • 프래그먼트용으로는 basic_user.fragment.graphql.

CustomersDot GraphQL 엔드포인트를 사용하여 쿼리를 사용하는 경우, 파일 이름을 .customer.query.graphql, .customer.mutation.graphql, 또는 .customer.fragment.graphql로 끝나게 하십시오.

조각

조각은 복잡한 GraphQL 쿼리를 더 읽기 쉽고 재사용 가능하게 만드는 방법입니다. 다음은 GraphQL 조각의 예입니다:

fragment DesignListItem on Design {
  id
  image
  event
  filename
  notesCount
}

조각은 별도의 파일에 저장되고, 쿼리, 변형 또는 다른 조각에서 가져와서 사용할 수 있습니다.

#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"

fragment DesignItem on Design {
  ...DesignListItem
  fullPath
  diffRefs {
    ...DesignDiffRefs
  }
}

조각에 대해 더 알아보세요:
GraphQL 문서

글로벌 ID

GitLab GraphQL API는 id 필드를 PostgreSQL 기본 키 id 대신 글로벌 ID로 표현합니다. 글로벌 ID는 관례로 클라이언트 측 라이브러리에서 캐싱 및 가져오기에 사용됩니다.

글로벌 ID를 기본 키 id로 변환하려면 getIdFromGraphQLId를 사용할 수 있습니다:

import { getIdFromGraphQLId } from '~/graphql_shared/utils';

const primaryKeyId = getIdFromGraphQLId(data.id);

모든 GraphQL 유형에서 쿼리할 때 글로벌 id가 필요합니다:

query allReleases(...) {
  project(...) {
    id // 프로젝트는 GraphQL 스키마에 ID가 있으므로 이를 가져와야 합니다
    releases(...) {
      nodes {
        // 릴리스는 GraphQL 스키마에서 ID 속성이 없습니다
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // 링크는 GraphQL 스키마에 ID가 있으므로 이를 가져와야 합니다
              name
            }
          }
        }
      }
      pageInfo {
        // PageInfo는 GraphQL 스키마에 ID 속성이 없습니다
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

비동기 변수를 통한 쿼리 건너뛰기

쿼리에 하나 이상의 변수가 포함되어 있으며 이 변수가 실행되기 전에 다른 쿼리가 실행되어야 하는 경우, 쿼리의 모든 관계에 skip() 속성을 추가하는 것이 매우 중요합니다.

이를 수행하지 않으면 쿼리가 두 번 실행됩니다: 한 번은 기본값으로( data 속성에 정의된 값이나 undefined) 실행되고, 한 번은 초기 쿼리가 해결된 후 새로운 변수 값이 스마트 쿼리에 주입되고 Apollo에 의해 다시 가져오는 시점에서 실행됩니다.

data() {
  return {
    // 모든 Apollo 쿼리에 대한 데이터 속성 정의
    project: null,
    issues: null
  }
},
apollo: {
  project: {
    query: getProject,
    variables() {
      return {
        projectId: this.projectId
      }
    }
  },
  releaseName: {
    query: getReleaseName,
    // 이 skip이 없으면, 쿼리는 처음에 `projectName: null`로 실행됩니다.
    // 그런 다음 `getProject`가 해결되면 다시 실행됩니다.
    skip() {
      return !this.project?.name
    },
    variables() {
      return {
        projectName: this.project?.name
      }
    }
  }
}

불변성 및 캐시 업데이트

Apollo 3.0.0 버전부터 모든 캐시 업데이트는 불변해야 합니다. 전체적으로 새롭고 업데이트된 개체로 대체해야 합니다.

캐시를 업데이트하고 새 개체를 반환하는 과정을 용이하게 하기 위해 Immer 라이브러리를 사용합니다. 다음 규칙을 따르십시오:

  • 업데이트된 캐시는 data라고 합니다.
  • 원본 캐시 데이터는 sourceData라고 합니다.

일반적인 업데이트 과정은 다음과 같습니다:

...
const sourceData = client.readQuery({ query });

const data = produce(sourceData, draftState => {
  draftState.commits.push(newCommit);
});

client.writeQuery({
  query,
  data,
});
...

코드 예제에서 produce를 사용하여 draftState의 직접적인 조작을 수행할 수 있습니다. 또한, immerdraftState에 대한 변경 사항을 포함하는 새로운 상태가 생성됨을 보장합니다.

Vue에서의 사용

Vue Apollo를 사용하려면 Vue Apollo 플러그인과 기본 클라이언트를 가져와야 합니다. 이 클라이언트는 Vue 애플리케이션이 마운트되는 지점에서 생성해야 합니다.

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(),
});

new Vue({
  ...,
  apolloProvider,
  ...
});

Vue Apollo 문서에서 Vue Apollo에 대해 자세히 읽어보세요.

Apollo를 통한 로컬 상태

기본 클라이언트를 생성할 때 Apollo로 애플리케이션 상태를 관리할 수 있습니다.

클라이언트 측 리졸버 사용

기본 상태는 기본 클라이언트를 설정한 후 캐시에 기록하여 설정할 수 있습니다. 아래 예제에서는 @client Apollo 디렉티브를 사용하여 초기 데이터를 Apollo 캐시에 기록하고 Vue 컴포넌트에서 이 상태를 가져옵니다:

// user.query.graphql

query User {
  user @client {
    name
    surname
    age
  }
}
// index.js

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import userQuery from '~/user/user.query.graphql'
Vue.use(VueApollo);

const defaultClient = createDefaultClient();

defaultClient.cache.writeQuery({
  query: userQuery,
  data: {
    user: {
      name: 'John',
      surname: 'Doe',
      age: 30
    },
  },
});

const apolloProvider = new VueApollo({
  defaultClient,
});
// App.vue
import userQuery from '~/user/user.query.graphql'

export default {
  apollo: {
    user: {
      query: userQuery
    }
  }
}

writeQuery 대신, userQuery를 캐시에서 읽을 때마다 user를 반환하는 타입 정책을 생성할 수 있습니다:

const defaultClient = createDefaultClient({}, {
  cacheConfig: {
    typePolicies: {
      Query: {
        fields: {
          user: {
            read(data) {
              return data || {
                user: {
                  name: 'John',
                  surname: 'Doe',
                  age: 30
                },
              }
            }
          }
        }
      }
    }
  }
});

로컬 데이터를 생성하는 것 외에도, @client 필드를 사용하여 기존 GraphQL 유형을 확장할 수 있습니다. 이는 아직 GraphQL API에 추가되지 않은 필드에 대한 API 응답을 모의(mock)할 때 매우 유용합니다.

로컬 Apollo 캐시로 API 응답 모의

로컬 Apollo 캐시를 사용하는 것은 GraphQL API 응답, 쿼리 또는 변이를 로컬에서 모의할 이유가 있을 때 유용합니다(예: 아직 실제 API에 추가되지 않은 경우).

예를 들어, 우리는 쿼리에서 사용되는 DesignVersion에 대한 조각이 있습니다:

fragment VersionListItem on DesignVersion {
  id
  sha
}

버전 드롭다운 목록에 표시하기 위해 버전 작성자와 created at 속성을 가져와야 합니다. 그러나 이러한 변경 사항은 아직 API에 구현되지 않았습니다. 기존 조각을 변경하여 이러한 새로운 필드에 대한 모의 응답을 가져올 수 있습니다:

fragment VersionListItem on DesignVersion {
  id
  sha
  author @client {
    avatarUrl
    name
  }
  createdAt @client
}

이제 Apollo는 @client 디렉티브로 표시된 모든 필드에 대한 _리졸버_를 찾으려고 시도합니다. DesignVersion 유형에 대한 리졸버를 생성해 보겠습니다(왜 DesignVersion인가요? 조각이 이 유형에서 생성되었기 때문입니다).

// resolvers.js

const resolvers = {
  DesignVersion: {
    author: () => ({
      avatarUrl:
        'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
      name: 'Administrator',
      __typename: 'User',
    }),
    createdAt: () => '2019-11-13T16:08:11Z',
  },
};

export default resolvers;

기존 Apollo Client에 리졸버 객체를 전달해야 합니다:

// graphql.js

import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';

const defaultClient = createDefaultClient(resolvers);

버전을 가져오려는 모든 시도에 대해 클라이언트는 원격 API 엔드포인트에서 idsha를 가져옵니다. 그런 다음 authorcreatedAt 버전 속성에 하드코딩된 값을 할당합니다. 이를 통해 프론트엔드 개발자는 백엔드에 의해 차단되지 않고 UI 작업을 수행할 수 있습니다. 응답이 API에 추가되면 사용자 정의 로컬 리졸버를 제거할 수 있습니다. 쿼리/조각에 대한 유일한 변경은 @client 디렉티브를 제거하는 것입니다.

로컬 상태 관리에 대한 자세한 내용은 Vue Apollo 문서에서 확인하세요.

Pinia와 함께 사용하기

Pinia와 Apollo를 단일 Vue 애플리케이션에서 결합하는 것은 일반적으로 권장되지 않습니다.

Apollo와 Pinia를 결합하는 것에 대한 제한 사항 및 상황 알아보기.

Vuex와 함께 사용하기

Vuex와 Apollo Client를 결합하는 것을 추천하지 않습니다. Vuex는 GitLab에서 더 이상 지원되지 않습니다.

기존의 Vuex 스토어가 Apollo와 함께 사용되고 있는 경우, 완전히 Vuex에서 이전하는 것을 강력히 권장합니다.

GitLab의 상태 관리에 대해 더 알아보세요.

프론트엔드와 백엔드가 일치하지 않을 때 GraphQL 기반 기능 작업하기

GraphQL 쿼리/변경 사항이 생성되거나 업데이트되어야 하는 모든 기능은 신중하게 계획되어야 합니다. 프론트엔드와 백엔드 담당자는 클라이언트측 및 서버측 요구 사항을 모두 충족하는 스키마에 동의해야 합니다. 이렇게 하면 두 부서가 서로를 차단하지 않고 각자의 부분을 구현할 수 있습니다.

이상적으로는 백엔드 구현이 프론트엔드보다 먼저 완료되어 클라이언트가 두 부서 간의 최소한의 소통으로 API를 즉시 쿼리할 수 있어야 합니다. 그러나 우리는 우선 순위가 항상 일치하지 않음을 인식합니다. 반복 작업과 우리가 약속한 작업을 제공하기 위해, 프론트엔드가 백엔드보다 먼저 구현되어야 할 수도 있습니다.

백엔드보다 앞서 프론트엔드 쿼리 및 변형 구현하기

이 경우 프론트엔드는 아직 백엔드 리졸버와 일치하지 않는 GraphQL 스키마 또는 필드를 정의합니다. 제품에서 공개적으로 동작하지 않는 오류로 이어지지 않도록 구현이 적절하게 기능 플래그로 설정되어 있다면 괜찮습니다. 그러나 우리는 graphql-verify CI 작업을 통해 클라이언트 측 쿼리/변형을 백엔드 GraphQL 스키마와 검증합니다. 백엔드가 실제로 이를 지원하기 전에 변경 사항이 검증을 통과해야 병합될 수 있습니다. 이를 해결하기 위한 몇 가지 제안을 아래에 나열합니다.

@client 지시어 사용하기

선호되는 접근 방식은 백엔드에서 아직 지원되지 않는 새로운 쿼리, 변형 또는 필드에 @client 지시어를 사용하는 것입니다. 지시어가 있는 모든 엔터티는 graphql-verify 검증 작업에서 생략됩니다.

또한 Apollo는 이를 클라이언트측에서 해결하려고 시도하며, 이는 로컬 Apollo 캐시로 API 응답 모킹하기와 함께 사용할 수 있습니다. 이는 클라이언트 측에서 정의된 가짜 데이터를 사용하여 기능을 테스트하는 편리한 방법을 제공합니다.

변경 사항에 대한 병합 요청을 열 때, 리뷰어가 GDK에서 적용하여 작업을 쉽게 스모크 테스트할 수 있도록 로컬 리졸버를 패치로 제공하는 것이 좋습니다.

지시어 제거를 후속 이슈로 추적하거나 백엔드 구현 계획의 일부로 간주하세요.

알려진 실패 목록에 예외 추가하기

특정 파일에 대해 GraphQL 쿼리/변형 검증을 완전히 끌 수 있으며, 파일의 경로를

config/known_invalid_graphql_queries.yml 파일에 추가하면 됩니다. 이는 .eslintignore 파일을 통해 일부 파일의 ESLint를 비활성화하는 방식과 유사합니다.

여기에서 나열된 모든 파일은 전혀 검증되지 않음을 유의하세요. 따라서 기존 쿼리에 필드만 추가하는 경우, 나머지 쿼리가 여전히 검증되도록 @client 지시어 접근 방식을 사용하십시오.

다시 말하지만, 이러한 오버라이드는 해당 제거를 적절한 이슈로 추적하여 가능한 한 짧은 기간 동안 유지하도록 하세요.

기능 플래그된 쿼리

백엔드가 완료되고 프런트엔드가 기능 플래그 뒤에서 구현되고 있는 경우,

GraphQL 쿼리에서 기능 플래그를 활용할 수 있는 몇 가지 옵션이 있습니다.

@include 지시어

@include(또는 그 반대인 @skip)를 사용하여 엔티티가 쿼리에 포함되어야 할지를 제어할 수 있습니다.

@include 지시어가 false로 평가되면, 엔티티의 리졸버가 호출되지 않으며 엔티티는 응답에서 제외됩니다. 예를 들어:

query getAuthorData($authorNameEnabled: Boolean = false) {
  username
  name @include(if: $authorNameEnabled)
}

그러면 Vue(또는 JavaScript)에서 쿼리를 호출할 때 기능 플래그를 전달할 수 있습니다.

이 기능 플래그는 이미 올바르게 설정되어 있어야 합니다.

기능 플래그 문서를 참조하여 이를 올바르게 설정하는 방법을 확인하세요.

export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      variables() {
        return {
          authorNameEnabled: gon?.features?.authorNameEnabled,
        };
      },
    }
  },
};

지시어가 false로 평가되더라도, 보호된 엔티티는 백엔드로 전송되어 GraphQL 스키마와 일치합니다.

따라서 이 접근 방식은 기능 플래그가 비활성화된 경우에도 기능 플래그된 엔티티가 스키마에 존재해야 합니다.

기능 플래그가 꺼진 경우, 리졸버는 최소한 프런트엔드와 동일한 기능 플래그를 사용하여 null을 반환하는 것이 좋습니다.

API GraphQL 가이드를 참조하세요.

쿼리의 다른 버전

표준 쿼리를 복제하는 또 다른 접근 방식이 있으며, 이는 피해야 합니다.

복사본은 새로운 엔티티를 포함하지만 원본은 변경되지 않습니다.

생산 코드는 기능 플래그의 상태에 따라 올바른 쿼리를 트리거해야 합니다. 예를 들어:

export default {
  apollo: {
    user: {
      query() {
        return this.glFeatures.authorNameEnabled ? NEW_QUERY : ORIGINAL_QUERY,
      }
    }
  },
};
여러 쿼리 버전 피하기

여러 버전 접근 방식은 권장되지 않으며, 이는 더 큰 병합 요청을 초래하며 기능 플래그가 존재하는 동안 두 개의 유사한 쿼리를 유지해야 합니다.

여러 버전은 새로운 GraphQL 엔티티가 아직 스키마의 일부가 아닌 경우 또는 스키마 수준에서 기능 플래그된 경우(new_entity: :feature_flag) 사용할 수 있습니다.

수동 쿼리 트리거링

구성 요소의 apollo 속성에 대한 쿼리는 구성 요소가 생성될 때 자동으로 생성됩니다.

일부 구성 요소는 대신 네트워크 요청을 필요할 때 생성하고 싶어합니다. 예를 들어, 지연 로드 항목이 있는 드롭다운 목록입니다.

이를 수행하는 두 가지 방법이 있습니다:

  1. skip 속성 사용
export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      skip() {
        // 드롭다운이 열릴 때만 쿼리를 만듭니다.
        return !this.isOpen;
      },
    }
  },
};
  1. addSmartQuery 사용

메서드에서 Smart Query를 수동으로 생성할 수 있습니다.

handleClick() {
  this.$apollo.addSmartQuery('user', {
    // `apollo` 섹션에서 사용할 값과 동일합니다.
    query: QUERY_IMPORT,
  }),
};

페이지네이션 작업

GitLab GraphQL API는 Relay 스타일 커서 페이지네이션을 사용합니다.

즉, “커서”는 데이터 세트에서 다음 항목을 가져올 위치를 추적하는 데 사용됩니다.

GraphQL Ruby 연결 개념은 연결에 대한 좋은 개요 및 소개입니다.

모든 연결 유형(예: DesignConnectionDiscussionConnection)에는 페이지네이션에 필요한 정보를 포함하는 pageInfo 필드가 있습니다:

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 첫 번째 항목의 커서를 표시하고 endCursor는 마지막 항목의 커서를 표시합니다.
  • hasPreviousPagehasNextPage는 현재 페이지 이전 또는 이후에 더 많은 페이지가 있는지 확인할 수 있게 해줍니다.

연결 유형으로 데이터를 가져올 때, 커서를 after 또는 before 매개변수로 전달하여 페이지네이션의 시작 또는 종료 지점을 나타낼 수 있습니다.

이들은 각각 주어진 지점 이후 또는 이전에 몇 개의 항목을 가져올지를 나타내기 위해 first 또는 last 매개변수로 따라야 합니다.

예를 들어, 다음과 같이 커서 이후에 10개의 디자인을 가져옵니다(projectQuery라고 부르겠습니다):

#import "~/graphql_shared/fragments/page_info.fragment.graphql"

query {
  project(fullPath: "root/my-project") {
    id
    issue(iid: "42") {
      designCollection {
        designs(atVersion: null, after: "Ihwffmde0i", first: 10) {
          edges {
            node {
              id
            }
          }
          pageInfo {
            ...PageInfo
          }
        }
      }
    }
  }
}

pageInfo 정보를 채우기 위해 page_info.fragment.graphql를 사용하고 있다는 점에 유의하십시오.

컴포넌트에서 fetchMore 메소드 사용하기

이 접근 방식은 사용자 핸들링 페이지네이션에 사용할 때 의미가 있습니다. 예를 들어, 스크롤을 통해 더 많은 데이터를 가져오거나 다음 페이지 버튼을 명시적으로 클릭하는 경우입니다.

처음에 모든 데이터를 가져와야 할 때는 재귀 쿼리(using a recursive query)를 사용하는 것이 좋습니다.

초기 가져오기를 할 때, 우리는 일반적으로 페이지네이션을 시작하기 위해 처음부터 시작하기를 원합니다.

이 경우 우리는 다음 중 하나를 선택할 수 있습니다:

  • 커서를 전달하지 않기.
  • afternull을 명시적으로 전달하기.

데이터가 가져와진 후, 우리는 update 훅을 사용하여 Vue 컴포넌트 속성에 설정된 데이터를 사용자 정의할 기회를 얻습니다.

이렇게 하면 다른 데이터와 함께 pageInfo 객체를 확보할 수 있습니다.

result 훅에서 우리는 pageInfo 객체를 점검하여 다음 페이지를 가져와야 하는지 확인할 수 있습니다. 또한, 애플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount를 유지합니다:

data() {
  return {
    pageInfo: null,
    requestCount: 0,
  }
},
apollo: {
  designs: {
    query: projectQuery,
    variables() {
      return {
        // ... 그 외의 디자인 변수들
        first: 10,
      };
    },
    update(data) {
      const { id = null, issue = {} } = data.project || {};
      const { edges = [], pageInfo } = issue.designCollection?.designs || {};

      return {
        id,
        edges,
        pageInfo,
      };
    },
    result() {
      const { pageInfo } = this.designs;

      // 새로운 결과로 요청 카운트를 증가시킵니다
      this.requestCount += 1;
      // 더 많은 요청이 있고 가져올 다음 페이지가 있을 경우에만 다음 페이지를 가져옵니다
      if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
        this.fetchNextPage(pageInfo.endCursor);
      }
    },
  },
},

다음 페이지로 이동하려면 Apollo의 fetchMore 메소드를 사용하여 새로운 커서를 전달합니다 (필요시 새로운 변수를 추가로 전달할 수 있습니다).

fetchNextPage(endCursor) {
  this.$apollo.queries.designs.fetchMore({
    variables: {
      // ... 그 외의 디자인 변수들
      first: 10,
      after: endCursor,
    },
  });
}
필드 머지 정책 정의

기존 결과와 들어오는 결과를 병합하는 방법을 지정하기 위해 필드 정책을 정의해야 합니다. 예를 들어, 이전/다음 버튼이 있는 경우, 기존 결과를 들어오는 결과로 대체하는 것이 합리적입니다:

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;

                  // 우리는 들어오는 노드만 저장하고 기존 것을 대체하기를 원합니다
                  return incoming
                }
              }
            }
          }
        }
      },
    },
  ),
});

무한 스크롤이 있는 경우, 기존 노드를 대체하기보다는 들어오는 designs 노드를 기존 노드에 추가하는 것이 더 합리적입니다. 이 경우 머지 함수는 약간 다르게 됩니다:

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;

                  const { nodes, ...rest } = incoming;
                  // 우리는 노드 배열만 병합할 필요가 있습니다.
                  // 나머지 필드(페이지네이션)는 항상 들어오는 것으로 덮어써야 합니다.
                  let result = rest;
                  result.nodes = [...existing.nodes, ...nodes];
                  return result;
                }
              }
            }
          }
        }
      },
    },
  ),
});

apollo-client페이지네이션이 있는 쿼리와 함께 사용할 수 있는 몇 가지 필드 정책을 제공합니다. 다음은 concatPagination 정책을 사용하여 무한 스크롤 페이지네이션을 구현하는 또 다른 방법입니다:

import { concatPagination } from '@apollo/client/utilities';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';

Vue.use(VueApollo);

export default new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          Project: {
            fields: {
              dastSiteProfiles: {
                keyArgs: ['fullPath'], // 캐시의 무결성을 보장하기 위해 keyArgs 옵션을 설정해야 할 수 있습니다
              },
            },
          },
          DastSiteProfileConnection: {
            fields: {
              nodes: concatPagination(),
            },
          },
        },
      },
    },
  ),
});

이는 이전의 DesignCollection 예제와 유사하며 새 페이지 결과가 이전 결과에 추가됩니다.

경우에 따라 필드에 대한 올바른 keyArgs를 정의하기 어려운 경우가 있습니다. 모든 필드가 업데이트되기 때문입니다. 이 경우 keyArgsfalse로 설정할 수 있습니다. 이는 Apollo Client가 자동 병합을 수행하지 않도록 지시하고, 우리가 작성한 merge 함수의 논리에 완전히 의존하게 됩니다.

예를 들어, 다음과 같은 쿼리가 있습니다:

query searchGroupsWhereUserCanTransfer {
  currentUser {
    id
    groups(after: 'somecursor') {
      nodes {
        id
        fullName
      }
      pageInfo {
        ...PageInfo
      }
    }
  }
}

여기서 groups 필드는 keyArgs에 대한 좋은 후보가 없습니다: 우리는 후속 페이지를 요청할 때 변경될 after 인수를 고려하고 싶지 않습니다. keyArgsfalse로 설정하면 업데이트가 의도한 대로 작동합니다:

typePolicies: {
  UserCore: {
    fields: {
      groups: {
        keyArgs: false,
      },
    },
  },
  GroupConnection: {
    fields: {
      nodes: concatPagination(),
    },
  },
}

구성 요소에서 재귀 쿼리 사용하기

모든 페이지 매김 데이터를 초기화할 필요가 있을 때, Apollo 쿼리가 우리를 도와줄 수 있습니다.

사용자 상호작용을 기반으로 다음 페이지를 가져와야 한다면, smartQuery와 함께 fetchMore-hook을 사용하는 것이 권장됩니다.

쿼리가 해결되면 구성 요소 데이터를 업데이트하고 pageInfo 객체를 검사할 수 있습니다. 이를 통해 다음 페이지를 가져와야 하는지 확인하고, 메서드를 재귀적으로 호출합니다.

어플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount를 유지해야 합니다.

data() {
  return {
    requestCount: 0,
    isLoading: false,
    designs: {
      edges: [],
      pageInfo: null,
    },
  }
},
created() {
  this.fetchDesigns();
},
methods: {
  handleError(error) {
    this.isLoading = false;
    // `error`로 작업 수행
  },
  fetchDesigns(endCursor) {
    this.isLoading = true;

    return this.$apollo
      .query({
        query: projectQuery,
        variables() {
          return {
            // ... 나머지 디자인 변수
            first: 10,
            endCursor,
          };
        },
      })
      .then(({ data }) => {
        const { id = null, issue = {} } = data.project || {};
        const { edges = [], pageInfo } = issue.designCollection?.designs || {};

        // 데이터 업데이트
        this.designs = {
          id,
          edges: [...this.designs.edges, ...edges],
          pageInfo: pageInfo,
        };

        // 새로운 결과마다 요청 수를 증가시킵니다.
        this.requestCount += 1;

        // 더 많은 요청이 있고 다음 페이지를 가져올 수 있는 경우에만 다음 페이지를 가져옵니다.
        if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
          this.fetchDesigns(pageInfo.endCursor);
        } else {
          this.isLoading = false;
        }
      })
      .catch(this.handleError);
  },
},

페이지 매김 및 낙관적 업데이트

Apollo가 클라이언트 측에서 페이지 매김 데이터를 캐시할 때, 캐시 키에 pageInfo 변수를 포함합니다.

그 데이터를 낙관적으로 업데이트하려면, .readQuery() 또는 .writeQuery()를 통해 캐시에 상호작용할 때 pageInfo 변수를 제공해야 합니다.

이것은 번거롭고 직관적이지 않을 수 있습니다.

Apollo는 캐시된 페이지 매김 쿼리를 더 쉽게 처리할 수 있도록 @connection 지시어를 제공합니다.

이 지시어는 데이터를 캐시할 때 사용되는 정적 키로 사용되는 key 매개변수를 받습니다.

그럼 특정 페이지 매김 변수를 제공하지 않고 데이터를 검색할 수 있습니다.

다음은 @connection 지시어를 사용하는 쿼리의 예입니다:

#import "~/graphql_shared/fragments/page_info.fragment.graphql"

query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
  project(fullPath: $fullPath) {
    siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
      @connection(key: "dastSiteProfiles") {
      pageInfo {
        ...PageInfo
      }
      edges {
        cursor
        node {
          id
          # ...
        }
      }
    }
  }
}

이 예제에서 Apollo는 안정적인 dastSiteProfiles 캐시 키로 데이터를 저장합니다.

캐시에서 해당 데이터를 검색하려면, $fullPath 변수를 제공하기만 하면 되고, afterbefore와 같은 페이지 매김 관련 변수를 생략하면 됩니다:

const data = store.readQuery({
  query: dastSiteProfilesQuery,
  variables: {
    fullPath: 'namespace/project',
  },
});

@connection 지시어에 대해 더 알아보려면 Apollo의 문서를 참조하세요.

유사 쿼리 배치

기본적으로 Apollo 클라이언트는 브라우저에서 쿼리마다 하나의 HTTP 요청을 보냅니다. 여러 쿼리를 단일 요청으로 배치하여 요청 수를 줄일 수 있으며, 이때 batchKey를 정의합니다.

이것은 동일한 컴포넌트에서 쿼리가 여러 번 호출될 때 유용하지만, UI를 한 번만 업데이트하고 싶을 때 사용할 수 있습니다. 이 예제에서는 컴포넌트 이름을 키로 사용합니다:

export default {
  name: 'MyComponent'
  apollo: {
    user: {
      query: QUERY_IMPORT,
      context: {
        batchKey: 'MyComponent',
      },
    }
  },
};

배치 키는 컴포넌트의 이름이 될 수 있습니다.

폴링 및 성능

Apollo 클라이언트는 간단한 폴링을 지원하지만, 성능상의 이유로 ETag 기반 캐싱이 데이터베이스를 매번 호출하는 것보다 선호됩니다.

ETag 리소스가 백엔드에서 캐시되도록 설정된 후, 프론트엔드에서 몇 가지 변경을 해야 합니다.

먼저, ETag 리소스를 백엔드에서 가져옵니다. 이는 URL 경로 형식이어야 합니다. 파이프라인 그래프의 예에서, 이는 graphql_resource_etag라고 하며, Apollo 컨텍스트에 추가할 새 헤더를 생성하는 데 사용됩니다:

/* pipelines/components/graph/utils.js */

/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
  return {
    fetchOptions: {
      method: 'GET',
    },
    headers: {
      /* 이 부분은 기능에 따라 달라집니다 */
      'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
      'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
      'X-REQUESTED-WITH': 'XMLHttpRequest',
    },
  };
};
/* eslint-enable @gitlab/require-i18n-strings */

/* component.vue */

apollo: {
  pipeline: {
    context() {
      return getQueryHeaders(this.graphqlResourceEtag);
    },
    query: getPipelineDetails,
    pollInterval: 10000,
    ..
  },
},

여기서 Apollo 쿼리는 graphqlResourceEtag의 변화를 감시하고 있습니다. ETag 리소스가 동적으로 변경되는 경우, 쿼리 헤더에 보내는 리소스도 업데이트되어야 합니다. 이를 위해 로컬 캐시에 ETag 리소스를 동적으로 저장하고 업데이트할 수 있습니다.

파이프라인 편집기에서 파이프라인 상태의 예를 볼 수 있습니다. 파이프라인 편집기는 최신 파이프라인의 변화를 감시합니다. 사용자가 새로운 커밋을 생성하면, 우리는 새 파이프라인의 변화를 폴링하기 위해 파이프라인 쿼리를 업데이트합니다.

# pipeline_etag.query.graphql

query getPipelineEtag {
  pipelineEtag @client
}
/* pipeline_editor/components/header/pipeline_status.vue */

import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';

apollo: {
  pipelineEtag: {
    query: getPipelineEtag,
  },
  pipeline: {
    context() {
      return getQueryHeaders(this.pipelineEtag);
    },
    query: getPipelineQuery,
    pollInterval: POLL_INTERVAL,
  },
}

/* pipeline_editor/components/commit/commit_section.vue */

await this.$apollo.mutate({
  mutation: commitCIFile,
  update(store, { data }) {
    const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;

    if (pipelineEtag) {
      store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } });
    }
  },
});

마지막으로, 브라우저 탭이 활성화되지 않을 때 컴포넌트가 폴링을 일시 중지하도록 가시성 검사를 추가할 수 있습니다. 이렇게 하면 페이지에 대한 요청 부담이 줄어듭니다.

/* component.vue */

import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils';

export default {
  mounted() {
    toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL);
  },
};

프론트엔드에서 ETag 캐싱을 완전히 구현하는 방법에 대한 참조로 이 MR을 사용할 수 있습니다.

구독이 성숙해지면, 이 프로세스는 구독을 사용하여 교체할 수 있으며 별도의 링크 라이브러리를 제거하고 쿼리를 배치할 수 있습니다.

ETag 캐싱 테스트 방법

구현이 잘 작동하는지 테스트하려면 네트워크 탭에서 요청을 확인하세요. ETag 리소스에 변경 사항이 없으면 모든 폴링 요청은 다음과 같아야 합니다:

  • POST 요청 대신 GET 요청이어야 합니다.

  • HTTP 상태가 200 대신 304여야 합니다.

테스트할 때 개발자 도구에서 캐싱이 비활성화되지 않았는지 확인하세요.

Chrome을 사용하고 200 HTTP 상태 코드를 계속 보는 경우 다음 버그가 있을 수 있습니다: 개발자 도구가 304 대신 200을 표시합니다. 이 경우 요청이 실제로 캐시되었고 304 상태 코드로 반환되었는지 확인하려면 응답 헤더의 소스를 검사하세요.

구독

우리는 구독을 사용하여 웹소켓을 통해 GraphQL API로부터 실시간 업데이트를 받습니다. 현재 존재하는 구독의 수는 제한되어 있으며 GraphiQL 탐색기에서 사용 가능한 구독 목록을 확인할 수 있습니다.

참고:

GraphiQL를 사용하여 구독을 테스트할 수 없습니다. 왜냐하면 구독은 ActionCable 클라이언트를 필요로 하고, GraphiQL는 현재 이를 지원하지 않기 때문입니다.

구독에 대한 포괄적인 소개는 실시간 위젯 개발자 가이드를 참조하세요.

모범 사례

변이에서 update 훅을 사용할 때와 사용하지 않을 때

Apollo Client의 .mutate() 메서드는 변이 수명 주기 동안 두 번 호출되는 update 훅을 노출합니다:

  • 한 번은 초기에, 즉 변이가 완료되기 전입니다.

  • 한 번은 변이가 완료된 후입니다.

이 훅은 상태 저장소(즉, ApolloCache)에 항목을 추가하거나 제거할 때만 사용해야 합니다. 이미 존재하는 항목을 _업데이트_하는 경우, 이는 일반적으로 전역 id로 표현됩니다.

이 경우, 변이 쿼리 정의에 이 id가 있으면 상태 저장소가 자동으로 업데이트됩니다. 다음은 id가 포함된 일반적인 변이 쿼리의 예입니다:

mutation issueSetWeight($input: IssueSetWeightInput!) {
  issuableSetWeight: issueSetWeight(input: $input) {
    issuable: issue {
      id
      weight
    }
    errors
  }
}

테스트

GraphQL 스키마 생성

우리의 일부 테스트는 스키마 JSON 파일을 로드합니다. 이러한 파일을 생성하려면 다음을 실행하세요:

bundle exec rake gitlab:graphql:schema:dump

업스트림에서 풀링한 후 또는 브랜치를 리베이스할 때 이 작업을 실행해야 합니다. 이는 gdk update의 일환으로 자동으로 실행됩니다.

참고:

RubyMine IDE를 사용하는 경우 tmp 디렉터리를 “제외됨”으로 표시한 경우, gitlab/tmp/tests/graphql에 대해 “디렉터리 마크 -> 제외 아님”으로 설정해야 합니다. 이렇게 하면 JS GraphQL 플러그인이 스키마를 자동으로 찾고 인덱싱할 수 있습니다.

Apollo Client 모킹

Apollo 작업으로 구성 요소를 테스트하려면 단위 테스트에서 Apollo Client를 모킹해야 합니다. 우리는 mock-apollo-client 라이브러리를 사용하여 Apollo 클라이언트를 모킹하고, 그 위에 우리가 만든 createMockApollo 헬퍼를 사용합니다.

Vue 인스턴스에 VueApollo를 주입하려면 Vue.use(VueApollo)를 호출해야 합니다. 이렇게 하면 파일의 모든 테스트에 대해 전역으로 VueApollo가 설치됩니다. Vue.use(VueApollo)는 임포트 바로 다음에 호출하는 것이 좋습니다.

import VueApollo from 'vue-apollo';
import Vue from 'vue';

Vue.use(VueApollo);

describe('Apollo 모킹이 있는 일부 구성 요소', () => {
  let wrapper;

  function createComponent(options = {}) {
    wrapper = shallowMount(...);
  }
})

이후 모킹된 Apollo 공급자를 생성해야 합니다:

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Apollo 모킹이 있는 일부 구성 요소', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {}) {
    mockApollo = createMockApollo(...)

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }

  afterEach(() => {
    // 테스트 간 공급자가 유지되지 않도록 보장합니다.
    mockApollo = null
  })
})

이제 각 쿼리 또는 변이에 대한 핸들러 배열을 정의해야 합니다. 핸들러는 올바른 쿼리 응답 또는 오류를 반환하는 모킹 함수여야 합니다:

import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';

describe('Apollo 모킹이 있는 일부 구성 요소', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, jest.fn().mockResolvedValue(moveDesignMutationResponse)],
    ])

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

모킹된 값을 사용할 때 응답의 구조가 실제 API 응답과 동일해야 함을 확인하세요. 예를 들어, 루트 속성은 data여야 합니다:

const designListQueryResponse = {
  data: {
    project: {
      id: '1',
      issue: {
        id: 'issue-1',
        designCollection: {
          copyState: 'READY',
          designs: {
            nodes: [
              {
                id: '3',
                event: 'NONE',
                filename: 'fox_3.jpg',
                notesCount: 1,
                image: 'image-3',
                imageV432x230: 'image-3',
                currentUserTodos: {
                  nodes: [],
                },
              },
            ],
          },
          versions: {
            nodes: [],
          },
        },
      },
    },
  },
};

쿼리를 테스트할 때 쿼리가 프로미스이므로 결과를 렌더링하려면 _해결_해야 합니다. 해결하지 않으면 쿼리의 loading 상태를 확인할 수 있습니다:

it('로딩 상태를 렌더링합니다', () => {
  const wrapper = createComponent();

  expect(wrapper.findComponent(LoadingSpinner).exists()).toBe(true)
});

it('디자인 목록을 렌더링합니다', async () => {
  const wrapper = createComponent();

  await waitForPromises()

  expect(findDesigns()).toHaveLength(3);
});

쿼리 오류를 테스트해야 하는 경우 요청 핸들러로 거부된 값을 모킹해야 합니다:

it('쿼리가 실패하면 오류를 렌더링합니다', async () => {
  const wrapper = createComponent({
    designListHandler: jest.fn().mockRejectedValue('Houston, we have a problem!')
  });

  await waitForPromises()

  expect(wrapper.find('.test-error').exists()).toBe(true)
})

변이도 같은 방식으로 테스트할 수 있습니다:

  const moveDesignHandlerSuccess = jest.fn().mockResolvedValue(moveDesignMutationResponse)

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse),
    moveDesignHandler: moveDesignHandlerSuccess
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, moveDesignHandler],
    ])

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }

it('올바른 매개변수로 변이를 호출하고 디자인을 재정렬합니다', async () => {
  const wrapper = createComponent();

  wrapper.find(VueDraggable).vm.$emit('change', {
    moved: {
      newIndex: 0,
      element: designToMove,
    },
  });

  expect(moveDesignHandlerSuccess).toHaveBeenCalled();

  await waitForPromises();

  expect(
    findDesigns()
      .at(0)
      .props('id'),
  ).toBe('2');
});

여러 쿼리 응답 상태를 모킹하려면 성공 및 실패가 있을 수 있으며, Apollo Client의 기본 재시도 동작을 Jest의 모킹 함수와 결합하여 일련의 응답을 생성할 수 있습니다. 이러한 응답은 수동으로 전진할 필요는 없지만 특정 방식으로 대기해야 합니다.

describe('쿼리가 시간 초과 될 때', () => {
  const advanceApolloTimers = async () => {
    jest.runOnlyPendingTimers();
    await waitForPromises()
  };

  beforeEach(async () => {
    const failSucceedFail = jest
      .fn()
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] })
      .mockResolvedValueOnce(mockPipelineResponse)
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] });

    createComponentWithApollo(failSucceedFail);
    await waitForPromises();
  });

  it('올바른 오류를 표시하고 데이터가 비어 있을 때 채워진 데이터를 덮어쓰지 않습니다', async () => {
    /* 처음에는 실패하여 오류를 표시하고 데이터가 없습니다 */
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(false);

    /* 성공하여 오류를 지우고 그래프를 표시합니다 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(false);
    expect(getGraph().exists()).toBe(true);

    /* 다시 실패하여 알림이 반환되지만 데이터가 유지됩니다 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(true);
  });
});

이전에 Apollo 기능을 테스트하기 위해 mount에서 { mocks: { $apollo ...}}를 사용했습니다. 이 접근 방식은 권장되지 않습니다 - 적절한 $apollo 모킹은 테스트에 많은 구현 세부 사항이 노출됩니다. 모킹된 Apollo 공급자로 이를 대체하는 것을 고려하세요.

wrapper = mount(SomeComponent, {
  mocks: {
    // 피하십시오! 실제 graphql 쿼리와 변이를 모킹하십시오
    $apollo: {
      mutate: jest.fn(),
      queries: {
        groups: {
          loading,
        },
      },
    },
  },
});

구독 테스트

구독을 테스트할 때, vue-apollo@4의 구독에 대한 기본 동작은 오류가 발생할 경우 재구독하고 즉시 새로운 요청을 발행하는 것입니다 (단, skip의 값이 이를 제한하지 않는 한).

import waitForPromises from 'helpers/wait_for_promises';

// subscriptionMock은 우리의 헬퍼에서 구독에 대한 핸들러 함수로 등록되어 있습니다.
const subscriptionMock = jest.fn().mockResolvedValue(okResponse);

// ...

it('오류 상태 테스트', () => {
  // 피하세요: 아래에서 멈출 것입니다!
  subscriptionMock = jest.fn().mockRejectedValue({ errors: [] });

  // 컴포넌트가 구독 모의를 호출하는 부분입니다.
  createComponent();
  // 영원히 멈출 것입니다:
  // * 거부된 프로미스가 재구독을 트리거합니다.
  // * 재구독이 다시 subscriptionMock을 호출하여 거부된 프로미스를 생성합니다.
  // * 거부된 프로미스가 다음 재구독을 트리거합니다.
  await waitForPromises();
  // ...
})

vue@3vue-apollo@4를 사용할 때 이러한 무한 루프를 피하려면 일회성 거부를 사용하는 것을 고려하세요.

it('실패 테스트', () => {
  // OK: 구독은 한 번 실패합니다.
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // 컴포넌트가 구독 모의를 호출하는 부분입니다.
  createComponent();
  await waitForPromises();

  // 이제 아래 코드를 실행합니다.
})

@client 쿼리 테스트

모의 리졸버 사용하기

응용 프로그램에 @client 쿼리가 포함된 경우, 핸들러만 전달할 때 다음과 같은 Apollo Client 경고가 발생합니다.

Unexpected call of console.warn() with:
Warning: mock-apollo-client - The query is entirely client-side (using @client directives) and resolvers have been configured. The request handler will not be called.

이 문제를 해결하려면 모의 핸들러 대신 모의 리졸버를 정의해야 합니다. 예를 들어, 다음과 같은 @client 쿼리가 주어졌다면:

query getBlobContent($path: String, $ref: String!) {
  blobContent(path: $path, ref: $ref) @client {
    rawData
  }
}

그리고 실제 클라이언트 측 리졸버는 다음과 같습니다.

import Api from '~/api';

export const resolvers = {
  Query: {
    blobContent(_, { path, ref }) {
      return {
        __typename: 'BlobContent',
        rawData: Api.getRawFile(path, { ref }).then(({ data }) => {
          return data;
        }),
      };
    },
  },
};

export default resolvers;

우리는 데이터와 동일한 형태로 반환하는 모의 리졸버를 사용할 수 있으며, 모의 함수로 결과를 모의할 수 있습니다.

let mockApollo;
let mockBlobContentData; // 모의 함수, jest.fn();

const mockResolvers = {
  Query: {
    blobContent() {
      return {
        __typename: 'BlobContent',
        rawData: mockBlobContentData(), // 모의 함수는 모의 데이터를 해결할 수 있습니다.
      };
    },
  },
};

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // 리졸버는 두 번째 매개변수입니다.

  wrapper = shallowMount(MyComponent, {
    propsData: {},
    apolloProvider: mockApollo,
    // ...
  })
};

그 후, 필요한 값을 해결하거나 거부할 수 있습니다.

beforeEach(() => {
  mockBlobContentData = jest.fn();
});

it('데이터를 표시합니다', async() => {
  mockBlobContentData.mockResolvedValue(data); // 결과를 모의하기 위해 해결하거나 거부할 수 있습니다.

  createComponentWithApollo();

  await waitForPromises(); // 리졸버 모의가 실행되도록 기다립니다.

  expect(findContent().text()).toBe(mockCiYml);
});
cache.writeQuery 사용하기

때때로 우리는 로컬 쿼리의 result 훅을 테스트하고 싶습니다. 이를 트리거하려면, 이 쿼리로 가져올 수 있는 올바른 데이터로 캐시를 채워야 합니다:

query fetchLocalUser {
  fetchLocalUser @client {
    name
  }
}
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';

describe('Apollo mock과 함께하는 일부 구성 요소', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([...])
    mockApollo.clients.defaultClient.cache.writeQuery({
      query: fetchLocalUserQuery,
      data: {
        fetchLocalUser: {
          __typename: 'User',
          name: 'Test',
        },
      },
    });

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

모의 Apollo 클라이언트의 캐시 동작을 구성해야 할 경우, 모의 클라이언트 인스턴스를 생성할 때 추가 캐시 옵션을 제공하고 제공된 옵션은 기본 캐시 옵션과 병합됩니다:

const defaultCacheOptions = {
  fragmentMatcher: { match: () => true },
  addTypename: false,
};
mockApollo = createMockApollo(
  requestHandlers,
  {},
  {
    dataIdFromObject: (object) =>
      // eslint-disable-next-line no-underscore-dangle
      object.__typename === 'Requirement' ? object.iid : defaultDataIdFromObject(object),
  },
);

오류 처리

GitLab GraphQL 뮤테이션에는 두 가지 명확한 오류 모드가 있습니다: 상위 수준데이터로서의 오류.

GraphQL 뮤테이션을 사용할 때, 이 두 오류 모드를 모두 처리하는 것을 고려하여 사용자가 오류가 발생했을 때 적절한 피드백을 받을 수 있도록 해야 합니다.

상위 수준 오류

이 오류는 GraphQL 응답의 “상위 수준”에 위치합니다. 이들은 인수 오류 및 구문 오류와 같은 복구할 수 없는 오류이며, 사용자에게 직접 표시되어서는 안 됩니다.

상위 수준 오류 처리

Apollo는 상위 수준 오류를 인식하므로, 이러한 오류를 처리하기 위해 Apollo의 다양한 오류 처리 메커니즘을 활용할 수 있습니다. 예를 들어, mutate 메서드를 호출한 후 Promise 거부를 처리하거나 ApolloMutation 구성 요소에서 발생하는 error 이벤트를 처리합니다.

이러한 오류는 사용자를 위한 것이 아니기 때문에, 상위 수준 오류에 대한 오류 메시지는 클라이언트 측에서 정의해야 합니다.

데이터로서의 오류

이 오류는 GraphQL 응답의 data 객체에 중첩됩니다. 이들은 이상적으로 직접 사용자에게 표시될 수 있는 복구 가능한 오류입니다.

데이터로서의 오류 처리

먼저, 뮤테이션 객체에 errors를 추가해야 합니다:

mutation createNoteMutation($input: String!) {
  createNoteMutation(input: $input) {
    note {
      id
+     errors
    }
  }

이제, 이 뮤테이션을 커밋하고 오류가 발생하면, 응답에는 우리가 처리해야 할 errors가 포함됩니다:

{
  data: {
    mutationName: {
      errors: ["죄송합니다. 노트를 업데이트할 수 없습니다."]
    }
  }
}

데이터로서의 오류를 처리할 때, 응답에 있는 오류 메시지를 사용자에게 표시할지, 또는 클라이언트 측에서 정의된 다른 메시지를 표시할지 결정하기 위해 최선의 판단을 사용하세요.

Vue 외부 사용

쿼리를 사용하여 기본 클라이언트를 직접 가져와서 GraphQL을 Vue 외부에서 사용할 수 있습니다.

import createDefaultClient from '~/lib/graphql';
import query from './query.graphql';

const defaultClient = createDefaultClient();

defaultClient.query({ query })
  .then(result => console.log(result));

Vuex 사용 시 캐시를 비활성화해야 할 때:

  • 데이터가 다른 곳에서 캐시되고 있는 경우
  • 사용 사례에 캐시가 필요하지 않은 경우
    데이터가 다른 곳에서 캐시되고 있거나, 주어진 사용 사례에 필요하지 않은 경우입니다.
import createDefaultClient from '~/lib/graphql';
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';

const defaultClient = createDefaultClient(
  {},
  {
    fetchPolicy: fetchPolicies.NO_CACHE,
  },
);

GraphQL 시작 호출로 초기 쿼리 조기 생성

성능을 개선하기 위해 때로는 초기 GraphQL 쿼리를 조기에 생성하고 싶습니다.

이를 위해 다음 단계를 통해 시작 호출에 추가할 수 있습니다:

  • 애플리케이션에 필요로 하는 모든 쿼리를 app/graphql/queries로 이동합니다.
  • 모든 중첩 쿼리 수준에 __typename 속성을 추가합니다:

    query getPermissions($projectPath: ID!) {
      project(fullPath: $projectPath) {
        __typename
        userPermissions {
          __typename
          pushCode
          forkProject
          createMergeRequestIn
        }
      }
    }
    
  • 쿼리에 프래그먼트가 포함된 경우, 프래그먼트를 가져오는 대신 쿼리 파일에 직접 이동해야 합니다:

    fragment PageInfo on PageInfo {
      __typename
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    
    query getFiles(
      $projectPath: ID!
      $path: String
      $ref: String!
    ) {
      project(fullPath: $projectPath) {
        __typename
        repository {
          __typename
          tree(path: $path, ref: $ref) {
            __typename
              pageInfo {
                ...PageInfo
              }
            }
          }
        }
      }
    }
    
  • 프래그먼트가 한 번만 사용되는 경우, 프래그먼트를 완전히 제거할 수도 있습니다:

    query getFiles(
      $projectPath: ID!
      $path: String
      $ref: String!
    ) {
      project(fullPath: $projectPath) {
        __typename
        repository {
          __typename
          tree(path: $path, ref: $ref) {
            __typename
              pageInfo {
                __typename
                hasNextPage
                hasPreviousPage
                startCursor
                endCursor
              }
            }
          }
        }
      }
    }
    
  • 애플리케이션의 뷰 역할을 하는 HAML 파일에 올바른 변수를 포함한 시작 호출을 추가합니다.
    GraphQL 시작 호출을 추가하기 위해 add_page_startup_graphql_call 헬퍼를 사용하며, 첫 번째 매개변수는 쿼리의 경로이고 두 번째 매개변수는 쿼리 변수를 포함하는 객체입니다. 쿼리의 경로는 app/graphql/queries 폴더에 상대적이며, 예를 들어 app/graphql/queries/repository/files.query.graphql 쿼리가 필요하면 경로는 repository/files입니다.

문제 해결

Mocked 클라이언트가 빈 객체를 반환하는 경우

응답이 빈 객체를 포함하여_mock 데이터_가 아닌 경우 단위 테스트가 실패하는 경우, mocked 응답에 __typename 필드를 추가하세요.

또는, GraphQL 쿼리 기능에서 생성 시 자동으로 __typename을 추가합니다.

캐시 데이터 손실에 대한 경고

때때로 콘솔에서 경고를 볼 수 있습니다: Query 객체의 someProperty 필드를 교체할 때 캐시 데이터가 손실될 수 있습니다. 이 문제를 해결하려면 SomeEntity의 모든 객체에 id가 있거나 사용자 정의 병합 함수를 사용해야 합니다. 문제를 해결하기 위해 다중 쿼리 섹션을 확인하세요.

  - current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
  - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
  - add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
  - add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})