Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic Persisted Queries #269

Open
raymclee opened this issue Jun 21, 2021 · 15 comments
Open

Automatic Persisted Queries #269

raymclee opened this issue Jun 21, 2021 · 15 comments

Comments

@raymclee
Copy link

can we use persisted queries with graphql-request?

@talzion12
Copy link

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.

@rubanraj54
Copy link

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
  );
}

@frederikhors
Copy link

@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
Copy link

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

@frederikhors
Copy link

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

@frederikhors
Copy link

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
Copy link

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.

@rubanraj54
Copy link

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

@jckw
Copy link

jckw commented Mar 23, 2024

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.

@ericfreese
Copy link

@frederikhors It looks like Response.clone() will work to avoid having to manually create a new Response. I'm using const body = await res.clone().json(); and then just returning res itself.

@frederikhors
Copy link

@ericfreese THANKS!

Do you mean this?

// ... other

if (!res.ok) return res;

const body = await res.clone().json();

if (isPersistedQueryNotFoundError(body)) {
	return fetchImpl(requestWithQueryHash.info, requestWithQueryHash.init);
} else {
	return res;
}

@marcoreni
Copy link

Hi @jasonkuhrt, we implemented a wrapper of graphql-request client where we added APQ using an implementation similar to the ones proposed above, but the result is suboptimal and we think there may be improvements in putting all of that into graphql-request itself.

I saw that you're working on an extension system. Do you think that APQ should be part of extensions or can we try implement it in the core?

@frederikhors
Copy link

but the result is suboptimal

What do you mean?

@marcoreni
Copy link

marcoreni commented Jun 3, 2024

Our implementation follows the following flow:

  • perform a GET, adding the hash but removing the Query
  • if it fails, perform a POST adding the hash to the body. (our queries are too big to fit in querystring params)

Because of this, we fall in a scenario similar to the one shown above:

  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,
              },
            },
          }),
        },
      };
    },
  },
};

and as you can see we're "stringifying" and "parsing" the body twice (one in removeQuery, the other in addHash). We think that if we do this inside the client itself, we'll be able to access body and searchParams directly, therefore improving performance.

@jasonkuhrt
Copy link
Owner

jasonkuhrt commented Jun 4, 2024

There should be an extension to achieve the goals of this issue. I'll try to make the extension system support what's needed to make that happen. I am not opposed to being a first party extension.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants