graphql-request icon indicating copy to clipboard operation
graphql-request copied to clipboard

Automatic Persisted Queries

Open raymclee opened this issue 3 years ago • 21 comments

can we use persisted queries with graphql-request?

raymclee avatar Jun 21 '21 07:06 raymclee

I wrote a fetch implementation that implements graphql persisted queries and can be used with graphql-request (and probably also with other clients but I haven't tested this).

Use it like this:

const client = new GraphQLClient(
  graphUrl,
  {
    fetch: createPersistedQueryFetch(fetch)
  }
);

It'd be nice if graphql-request added support for request transformers, but this approach works well for me in my project.

talzion12 avatar Sep 18 '21 12:09 talzion12

Hi, Thanks for the fetch implementation @talzion12.

Unfortunately, the sha256 function in your snippet didn't work in my case (iOS), so I had to replace it with the help of native implementation (https://github.com/itinance/react-native-sha256). Then it worked.

I'm pasting here my version, so that it could help others if they get the same issue.

const VERSION = 1;

import { sha256 } from 'react-native-sha256';
type Fetch = typeof fetch;

/**
 * Creates a fetch implementation that sends GraphQL persisted query requests.
 */
export const createPersistedQueryFetch =
  (fetchImpl: Fetch): Fetch =>
  async (info, init) => {
    const request = { info, init };

    const processor = getRequestProcessor(request);

    const requestWithQueryHash = await processor.addHash(request);
    const requestWithoutQuery = processor.removeQuery(requestWithQueryHash);

    // send a request without the query
    const res = await fetchImpl(
      requestWithoutQuery.info,
      requestWithoutQuery.init,
    );
    const body = await res.json();

    // if the query was not found in the server,
    // send another request with the query
    if (isPersistedQueryNotFoundError(body)) {
      return fetchImpl(requestWithQueryHash.info, requestWithQueryHash.init);
    } else {
      res.json = () => Promise.resolve(body);
      return res;
    }
  };

/**
 * Manipulates a fetch request, implemented per HTTP method type.
 */
interface RequestProcessor {
  /**
   * Removes the GraphQL query argument from the request
   */
  removeQuery(request: Request): Request;

  /**
   * Adds the GraphQL request query hash to the request
   */
  addHash(request: Request): Promise<Request>;
}

function getRequestProcessor(request: Request) {
  const method = (request.init?.method ?? 'GET').toUpperCase();
  const requestProcessor = requestProcessorByMethod[method];

  if (!requestProcessor) {
    throw new Error('Unsupported request method: ' + method);
  }

  return requestProcessor;
}

const requestProcessorByMethod: Record<string, RequestProcessor> = {
  GET: {
    removeQuery: request => {
      const [url, params] = splitUrlAndSearchParams(
        getRequestInfoUrl(request.info),
      );
      params.delete('query');
      return {
        ...request,
        info: requestInfoWithUpdatedUrl(
          request.info,
          `${url}?${params.toString()}`,
        ),
      };
    },
    addHash: async request => {
      const [url, params] = splitUrlAndSearchParams(
        getRequestInfoUrl(request.info),
      );

      const query = params.get('query');
      if (!query) {
        throw new Error('GET request must contain a query parameter');
      }

      const hash = await sha256(query);

      params.append(
        'extensions',
        JSON.stringify({
          persistedQuery: {
            version: VERSION,
            sha256Hash: hash,
          },
        }),
      );

      return {
        ...request,
        info: requestInfoWithUpdatedUrl(
          request.info,
          `${url}?${params.toString()}`,
        ),
      };
    },
  },
  POST: {
    removeQuery: request => {
      if (typeof request.init?.body !== 'string') {
        throw new Error('POST request must contain a body');
      }

      const body = JSON.parse(request.init.body);
      const { query, ...bodyWithoutQuery } = body;

      return {
        ...request,
        init: {
          ...request.init,
          body: JSON.stringify(bodyWithoutQuery),
        },
      };
    },
    addHash: async request => {
      if (typeof request.init?.body !== 'string') {
        throw new Error('POST request must contain a body');
      }

      const body = JSON.parse(request.init.body);

      if (typeof body.query !== 'string') {
        throw new Error('POST request body must contain a query');
      }

      const hash = await sha256(body.query);

      return {
        ...request,
        init: {
          ...request.init,
          body: JSON.stringify({
            ...body,
            extensions: {
              persistedQuery: {
                version: VERSION,
                sha256Hash: hash,
              },
            },
          }),
        },
      };
    },
  },
};

interface Request {
  info: RequestInfo;
  init?: RequestInit;
}

function requestInfoWithUpdatedUrl(
  info: RequestInfo,
  url: string,
): RequestInfo {
  return typeof info === 'string'
    ? url
    : {
        ...info,
        url,
      };
}

function getRequestInfoUrl(info: RequestInfo) {
  return typeof info === 'string' ? info : info.url;
}

function splitUrlAndSearchParams(
  url: string,
): [urlWithoutSearchParams: string, params: URLSearchParams] {
  const startOfSearchParams = url.indexOf('?');

  return startOfSearchParams === -1
    ? [url, new URLSearchParams()]
    : [
        url.slice(0, startOfSearchParams),
        new URLSearchParams(url.slice(startOfSearchParams)),
      ];
}

interface GraphQLResponse {
  errors?: {
    message?: string;
  }[];
}

function isPersistedQueryNotFoundError(resBody: GraphQLResponse) {
  return (
    resBody.errors &&
    resBody.errors.length > 0 &&
    resBody.errors.find(err => err.message === 'PersistedQueryNotFound') != null
  );
}

rubanraj54 avatar Apr 27 '22 11:04 rubanraj54

@jasonkuhrt May I ask if this feature interests you?

Can we at least put the @talzion12 and @rubanraj54 code in the Readme and in a "plugins" folder so the magic of open source can infect all future users better than us who will make improvements?

frederikhors avatar Sep 02 '23 18:09 frederikhors

@talzion12, @rubanraj54 do you have new code? I think this is not usable with today graphql-request.

frederikhors avatar Sep 02 '23 19:09 frederikhors

I'm getting the error Failed to execute 'text' on 'Response': body stream already read in console.

frederikhors avatar Sep 02 '23 19:09 frederikhors

This is the issue:

export const createPersistedQueryFetch =
  (fetchImpl: Fetch): Fetch =>
  async (info, init) => {
    // ...

    // send a request without the query
    const res = await fetchImpl(
      requestWithoutQuery.info,
      requestWithoutQuery.init
    );
    const body = await res.json();

    // if the query was not found in the server, send another request with the query
    if (isPersistedQueryNotFoundError(body)) {
      return fetchImpl(requestWithQueryHash.info, requestWithQueryHash.init);
    } else {
      res.json = () => Promise.resolve(body);
      return res;
    }
  };

graphql-request is trying to re-read the body already read in createPersistedQueryFetch function.

Why the re-assignment to res.json (with res.json = () => Promise.resolve(body)) is not working?

frederikhors avatar Sep 02 '23 19:09 frederikhors

I found a way to make it work:

export const createPersistedQueryFetch = (fetchImpl: Fetch): Fetch =>
  async (info, init) => {
    const request = { info, init };

    const processor = getRequestProcessor(request);

    const requestWithQueryHash = await processor.addHash(request);
    const requestWithoutQuery = processor.removeQuery(requestWithQueryHash);

    // send a request without the query
    const res = await fetchImpl(requestWithoutQuery.info, requestWithoutQuery.init);
    const body = await res.json();

    // if the query was not found in the server,
    // send another request with the query
    if (isPersistedQueryNotFoundError(body)) {
      return fetchImpl(requestWithQueryHash.info, requestWithQueryHash.init);
    } else {
      // ---------> Here the changes! <-----------
      // I'm returning a new Response here
      return new Response(JSON.stringify(body), {
        status: res.status,
        statusText: res.statusText,
        headers: res.headers
      });
    }
  };

I would like to know your thoughts and if there is room for improvement.

I don't think it's the best solution, but it's working great so far.

frederikhors avatar Sep 02 '23 20:09 frederikhors

@frederikhors sorry man, I moved to a new organization. So not sure what they are doing now with the code now.

rubanraj54 avatar Sep 04 '23 08:09 rubanraj54

I just contributed to the bounty on this issue:

https://until.dev/bounty/jasonkuhrt/graphql-request/269

The current bounty for completing it is $20.00 if it is closed within 1 month, and decreases after that.

jckw avatar Mar 23 '24 21:03 jckw