simple-graphql-client.ts 8.1 KB

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