simple-graphql-client.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /// <reference types="../typings" />
  2. import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
  3. import { DocumentNode } from 'graphql';
  4. import gql from 'graphql-tag';
  5. import { print } from 'graphql/language/printer';
  6. import fetch, { Response } from 'node-fetch';
  7. import { Curl } from 'node-libcurl';
  8. import { stringify } from 'querystring';
  9. import { ImportInfo } from '../e2e/graphql/generated-e2e-admin-types';
  10. import { getConfig } from '../src/config/config-helpers';
  11. import { createUploadPostData } from './create-upload-post-data';
  12. const LOGIN = gql`
  13. mutation($username: String!, $password: String!) {
  14. login(username: $username, password: $password) {
  15. user {
  16. id
  17. identifier
  18. channels {
  19. token
  20. }
  21. }
  22. }
  23. }
  24. `;
  25. export type QueryParams = { [key: string]: string | number };
  26. // tslint:disable:no-console
  27. /**
  28. * A minimalistic GraphQL client for populating and querying test data.
  29. */
  30. export class SimpleGraphQLClient {
  31. private authToken: string;
  32. private channelToken: string;
  33. private headers: { [key: string]: any } = {};
  34. constructor(private apiUrl: string = '') {}
  35. setAuthToken(token: string) {
  36. this.authToken = token;
  37. this.headers.Authorization = `Bearer ${this.authToken}`;
  38. }
  39. getAuthToken(): string {
  40. return this.authToken;
  41. }
  42. setChannelToken(token: string) {
  43. this.headers[getConfig().channelTokenKey] = token;
  44. }
  45. /**
  46. * Performs both query and mutation operations.
  47. */
  48. async query<T = any, V = Record<string, any>>(
  49. query: DocumentNode,
  50. variables?: V,
  51. queryParams?: QueryParams,
  52. ): Promise<T> {
  53. const response = await this.request(query, variables, queryParams);
  54. const result = await this.getResult(response);
  55. if (response.ok && !result.errors && result.data) {
  56. return result.data;
  57. } else {
  58. const errorResult = typeof result === 'string' ? { error: result } : result;
  59. throw new ClientError(
  60. { ...errorResult, status: response.status },
  61. { query: print(query), variables },
  62. );
  63. }
  64. }
  65. async queryStatus<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<number> {
  66. const response = await this.request(query, variables);
  67. return response.status;
  68. }
  69. importProducts(csvFilePath: string): Promise<{ importProducts: ImportInfo }> {
  70. return this.fileUploadMutation({
  71. mutation: gql`
  72. mutation ImportProducts($csvFile: Upload!) {
  73. importProducts(csvFile: $csvFile) {
  74. imported
  75. processed
  76. errors
  77. }
  78. }
  79. `,
  80. filePaths: [csvFilePath],
  81. mapVariables: () => ({ csvFile: null }),
  82. });
  83. }
  84. async asUserWithCredentials(username: string, password: string) {
  85. // first log out as the current user
  86. if (this.authToken) {
  87. await this.query(
  88. gql`
  89. mutation {
  90. logout
  91. }
  92. `,
  93. );
  94. }
  95. const result = await this.query(LOGIN, { username, password });
  96. return result.login;
  97. }
  98. async asSuperAdmin() {
  99. await this.asUserWithCredentials(SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD);
  100. }
  101. async asAnonymousUser() {
  102. await this.query(
  103. gql`
  104. mutation {
  105. logout
  106. }
  107. `,
  108. );
  109. }
  110. private async request(
  111. query: DocumentNode,
  112. variables?: { [key: string]: any },
  113. queryParams?: QueryParams,
  114. ): Promise<Response> {
  115. const queryString = print(query);
  116. const body = JSON.stringify({
  117. query: queryString,
  118. variables: variables ? variables : undefined,
  119. });
  120. const url = queryParams ? this.apiUrl + `?${stringify(queryParams)}` : this.apiUrl;
  121. const response = await fetch(url, {
  122. method: 'POST',
  123. headers: { 'Content-Type': 'application/json', ...this.headers },
  124. body,
  125. });
  126. const authToken = response.headers.get(getConfig().authOptions.authTokenHeaderKey || '');
  127. if (authToken != null) {
  128. this.setAuthToken(authToken);
  129. }
  130. return response;
  131. }
  132. private async getResult(response: Response): Promise<any> {
  133. const contentType = response.headers.get('Content-Type');
  134. if (contentType && contentType.startsWith('application/json')) {
  135. return response.json();
  136. } else {
  137. return response.text();
  138. }
  139. }
  140. /**
  141. * Uses curl to post a multipart/form-data request to the server. Due to differences between the Node and browser
  142. * environments, we cannot just use an existing library like apollo-upload-client.
  143. *
  144. * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
  145. * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
  146. */
  147. private fileUploadMutation(options: {
  148. mutation: DocumentNode;
  149. filePaths: string[];
  150. mapVariables: (filePaths: string[]) => any;
  151. }): Promise<any> {
  152. const { mutation, filePaths, mapVariables } = options;
  153. return new Promise((resolve, reject) => {
  154. const curl = new Curl();
  155. const postData = createUploadPostData(mutation, filePaths, mapVariables);
  156. const processedPostData = [
  157. {
  158. name: 'operations',
  159. contents: JSON.stringify(postData.operations),
  160. },
  161. {
  162. name: 'map',
  163. contents:
  164. '{' +
  165. Object.entries(postData.map)
  166. .map(([i, path]) => `"${i}":["${path}"]`)
  167. .join(',') +
  168. '}',
  169. },
  170. ...postData.filePaths,
  171. ];
  172. curl.setOpt(Curl.option.URL, this.apiUrl);
  173. curl.setOpt(Curl.option.VERBOSE, false);
  174. curl.setOpt(Curl.option.TIMEOUT_MS, 30000);
  175. curl.setOpt(Curl.option.HTTPPOST, processedPostData);
  176. curl.setOpt(Curl.option.HTTPHEADER, [
  177. `Authorization: Bearer ${this.authToken}`,
  178. `${getConfig().channelTokenKey}: ${this.channelToken}`,
  179. ]);
  180. curl.perform();
  181. curl.on('end', (statusCode: any, body: any) => {
  182. curl.close();
  183. const response = JSON.parse(body);
  184. if (response.errors && response.errors.length) {
  185. const error = response.errors[0];
  186. console.log(JSON.stringify(error.extensions, null, 2));
  187. throw new Error(error.message);
  188. }
  189. resolve(response.data);
  190. });
  191. curl.on('error', (err: any) => {
  192. curl.close();
  193. console.log(err);
  194. reject(err);
  195. });
  196. });
  197. }
  198. }
  199. export class ClientError extends Error {
  200. constructor(public response: any, public request: any) {
  201. super(ClientError.extractMessage(response));
  202. }
  203. private static extractMessage(response: any): string {
  204. if (response.errors) {
  205. return response.errors[0].message;
  206. } else {
  207. return `GraphQL Error (Code: ${response.status})`;
  208. }
  209. }
  210. }