simple-graphql-client.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
  2. import { VendureConfig } from '@vendure/core';
  3. import FormData from 'form-data';
  4. import fs from 'fs';
  5. import { DocumentNode } from 'graphql';
  6. import gql from 'graphql-tag';
  7. import { print } from 'graphql/language/printer';
  8. import fetch, { RequestInit, Response } from 'node-fetch';
  9. import { stringify } from 'querystring';
  10. import { QueryParams } from './types';
  11. import { createUploadPostData } from './utils/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. // tslint:disable:no-console
  26. /**
  27. * @description
  28. * A minimalistic GraphQL client for populating and querying test data.
  29. *
  30. * @docsCategory testing
  31. */
  32. export class SimpleGraphQLClient {
  33. private authToken: string;
  34. private channelToken: string | null = null;
  35. private headers: { [key: string]: any } = {};
  36. constructor(private vendureConfig: Required<VendureConfig>, private apiUrl: string = '') {}
  37. /**
  38. * @description
  39. * Sets the authToken to be used in each GraphQL request.
  40. */
  41. setAuthToken(token: string) {
  42. this.authToken = token;
  43. this.headers.Authorization = `Bearer ${this.authToken}`;
  44. }
  45. /**
  46. * @description
  47. * Sets the authToken to be used in each GraphQL request.
  48. */
  49. setChannelToken(token: string | null) {
  50. this.channelToken = token;
  51. if (this.vendureConfig.apiOptions.channelTokenKey) {
  52. this.headers[this.vendureConfig.apiOptions.channelTokenKey] = this.channelToken;
  53. }
  54. }
  55. /**
  56. * @description
  57. * Returns the authToken currently being used.
  58. */
  59. getAuthToken(): string {
  60. return this.authToken;
  61. }
  62. /**
  63. * @description
  64. * Performs both query and mutation operations.
  65. */
  66. async query<T = any, V = Record<string, any>>(
  67. query: DocumentNode,
  68. variables?: V,
  69. queryParams?: QueryParams,
  70. ): Promise<T> {
  71. const response = await this.makeGraphQlRequest(query, variables, queryParams);
  72. const result = await this.getResult(response);
  73. if (response.ok && !result.errors && result.data) {
  74. return result.data;
  75. } else {
  76. const errorResult = typeof result === 'string' ? { error: result } : result;
  77. throw new ClientError(
  78. { ...errorResult, status: response.status },
  79. { query: print(query), variables },
  80. );
  81. }
  82. }
  83. /**
  84. * @description
  85. * Performs a raw HTTP request to the given URL, but also includes the authToken & channelToken
  86. * headers if they have been set. Useful for testing non-GraphQL endpoints, e.g. for plugins
  87. * which make use of REST controllers.
  88. */
  89. async fetch(url: string, options: RequestInit = {}): Promise<Response> {
  90. const headers = { 'Content-Type': 'application/json', ...this.headers, ...options.headers };
  91. const response = await fetch(url, {
  92. ...options,
  93. headers,
  94. });
  95. const authToken = response.headers.get(this.vendureConfig.authOptions.authTokenHeaderKey || '');
  96. if (authToken != null) {
  97. this.setAuthToken(authToken);
  98. }
  99. return response;
  100. }
  101. /**
  102. * @description
  103. * Performs a query or mutation and returns the resulting status code.
  104. */
  105. async queryStatus<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<number> {
  106. const response = await this.makeGraphQlRequest(query, variables);
  107. return response.status;
  108. }
  109. /**
  110. * @description
  111. * Attemps to log in with the specified credentials.
  112. */
  113. async asUserWithCredentials(username: string, password: string) {
  114. // first log out as the current user
  115. if (this.authToken) {
  116. await this.query(
  117. gql`
  118. mutation {
  119. logout
  120. }
  121. `,
  122. );
  123. }
  124. const result = await this.query(LOGIN, { username, password });
  125. if (result.login.user.channels.length === 1) {
  126. this.setChannelToken(result.login.user.channels[0].token);
  127. }
  128. return result.login;
  129. }
  130. /**
  131. * @description
  132. * Logs in as the SuperAdmin user.
  133. */
  134. async asSuperAdmin() {
  135. const { superadminCredentials } = this.vendureConfig.authOptions;
  136. await this.asUserWithCredentials(
  137. superadminCredentials?.identifier ?? SUPER_ADMIN_USER_IDENTIFIER,
  138. superadminCredentials?.password ?? SUPER_ADMIN_USER_PASSWORD,
  139. );
  140. }
  141. /**
  142. * @description
  143. * Logs out so that the client is then treated as an anonymous user.
  144. */
  145. async asAnonymousUser() {
  146. await this.query(
  147. gql`
  148. mutation {
  149. logout
  150. }
  151. `,
  152. );
  153. }
  154. private async makeGraphQlRequest(
  155. query: DocumentNode,
  156. variables?: { [key: string]: any },
  157. queryParams?: QueryParams,
  158. ): Promise<Response> {
  159. const queryString = print(query);
  160. const body = JSON.stringify({
  161. query: queryString,
  162. variables: variables ? variables : undefined,
  163. });
  164. const url = queryParams ? this.apiUrl + `?${stringify(queryParams)}` : this.apiUrl;
  165. return this.fetch(url, {
  166. method: 'POST',
  167. body,
  168. });
  169. }
  170. private async getResult(response: Response): Promise<any> {
  171. const contentType = response.headers.get('Content-Type');
  172. if (contentType && contentType.startsWith('application/json')) {
  173. return response.json();
  174. } else {
  175. return response.text();
  176. }
  177. }
  178. /**
  179. * @description
  180. * Perform a file upload mutation.
  181. *
  182. * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
  183. * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
  184. */
  185. async fileUploadMutation(options: {
  186. mutation: DocumentNode;
  187. filePaths: string[];
  188. mapVariables: (filePaths: string[]) => any;
  189. }): Promise<any> {
  190. const { mutation, filePaths, mapVariables } = options;
  191. const postData = createUploadPostData(mutation, filePaths, mapVariables);
  192. const body = new FormData();
  193. body.append('operations', JSON.stringify(postData.operations));
  194. body.append(
  195. 'map',
  196. '{' +
  197. Object.entries(postData.map)
  198. .map(([i, path]) => `"${i}":["${path}"]`)
  199. .join(',') +
  200. '}',
  201. );
  202. for (const filePath of postData.filePaths) {
  203. const file = fs.readFileSync(filePath.file);
  204. body.append(filePath.name, file, { filename: filePath.file });
  205. }
  206. const result = await fetch(this.apiUrl, {
  207. method: 'POST',
  208. body,
  209. headers: {
  210. ...this.headers,
  211. },
  212. });
  213. const response = await result.json();
  214. if (response.errors && response.errors.length) {
  215. const error = response.errors[0];
  216. console.log(JSON.stringify(error.extensions, null, 2));
  217. throw new Error(error.message);
  218. }
  219. return response.data;
  220. }
  221. }
  222. export class ClientError extends Error {
  223. constructor(public response: any, public request: any) {
  224. super(ClientError.extractMessage(response));
  225. }
  226. private static extractMessage(response: any): string {
  227. if (response.errors) {
  228. return response.errors[0].message;
  229. } else {
  230. return `GraphQL Error (Code: ${response.status})`;
  231. }
  232. }
  233. }