simple-graphql-client.ts 9.3 KB

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