Skip to content

Instantly share code, notes, and snippets.

@genox
Created July 14, 2023 12:37
Show Gist options
  • Save genox/b482cb806df1ab1abf4f136cd1468619 to your computer and use it in GitHub Desktop.
Save genox/b482cb806df1ab1abf4f136cd1468619 to your computer and use it in GitHub Desktop.
qwik graphql requests with server side in-memory cache
import { graphql } from '~/library/saleor/gql';
import { server$ } from '@builder.io/qwik-city';
import { gqlQuery } from '~/library/gql-fetch/gql-fetch';
import { FetchProductBySlugDocument } from '~/library/saleor/gql/graphql';
import type { VariablesOf } from '@graphql-typed-document-node/core';
export const queryFetchProductBySlug = server$(async function (
variables: VariablesOf<typeof FetchProductBySlugDocument>
) {
const tokens = extractTokens(this);
return await gqlQuery(SALEOR_API_URL, FetchProductBySlugDocument, variables, {
...tokens,
allowCache: true,
});
});
export const FetchProductBySlug = graphql(`
query FetchProductBySlug($slug: String!, $channel: String!, $languageCode: LanguageCodeEnum!) {
product(channel: $channel, slug: $slug) {
...ProductFragment
description
isAvailableForPurchase
}
}
`);
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
import { useSpeakLocale } from 'qwik-speak';
import { SALEOR_API_CHANNEL, SITE_URL } from '~/config/env';
import { useRequestLocale, useRouteQueryParams } from '~/routes/layout';
import { saleorLanguageCode } from '~/components/saleor/utils/saleor-language-code';
import { queryFetchProductBySlug } from '~/library/saleor/queries/products';
import type { AwaitedQuery } from '~/components/saleor/products/products-grid-view';
import { getTranslatedName } from '~/components/saleor/utils/get-translated-properties';
import { ProductPageItem } from '~/components/saleor/products/product-page-item';
export default component$(() => {
const routeData = useRouteQueryParams();
const productData = useSignal<AwaitedQuery<typeof queryFetchProductBySlug>>(null);
const isBusy = useSignal(false);
const locale = useSpeakLocale();
useRequestLocale();
useTask$(async ({ track }) => {
const route = track(() => routeData);
const productSlug = route.value.params.catchall;
isBusy.value = true;
productData.value = await queryFetchProductBySlug({
slug: productSlug,
languageCode: saleorLanguageCode(locale.lang),
channel: SALEOR_API_CHANNEL,
});
isBusy.value = false;
});
const product = productData.value?.data?.product;
return (
<>
{product && (
<div>
<div class={'prose-sm prose !max-w-full sm:prose lg:prose-lg xl:prose-xl'}>
<h1>{getTranslatedName(product)}</h1>
</div>
<div class={'mt-16 flex gap-8'}>
<ProductPageItem item={product} />
</div>
</div>
)}
</>
);
});
import NodeCache from 'node-cache';
class FetchCache {
private instance: NodeCache | null = null;
getInstance() {
if (!this.instance) {
this.instance = new NodeCache({ stdTTL: 60 * 60 * 24, checkperiod: 60 * 60 });
}
return this.instance;
}
}
export const FetchCacheInstance = new FetchCache();
import type { DocumentNode } from 'graphql';
import { print } from 'graphql';
import { logger } from '~/utils/logger';
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import type { RequestEventBase } from '@builder.io/qwik-city';
import { mutateTokenRefresh } from '~/library/saleor/queries/user';
import { getMd5Hash } from '~/library/gql-fetch/md5-hash';
import { FetchCacheInstance } from '~/library/gql-fetch/fetch-cache';
export type OperationVariables = Record<string, any>;
export const extractTokens = (event: RequestEventBase<QwikCityPlatform>) => {
const token = event?.cookie?.get('token')?.value;
const refreshToken = event?.cookie?.get('refreshToken')?.value;
if (!token || !refreshToken) {
return { token: '', refreshToken: '' };
}
return { token, refreshToken };
};
type Options = {
token?: string;
refreshToken?: string;
allowCache?: boolean;
};
export const gqlQuery = async function <T, V extends OperationVariables>(
endpoint: string,
query: DocumentNode | TypedDocumentNode<T, V>,
variables: V,
options?: Options
): Promise<{ data: T | null; error: Error | null; newToken: string | null }> {
const { token, refreshToken, allowCache = false } = options || {};
const start = Date.now();
const cache = FetchCacheInstance.getInstance();
let newToken: string | null = null;
const queryString = print(query);
const queryType = queryString.startsWith('mutation') ? 'mutation' : 'query';
logger.log(
`----------------------------------- ${queryType} -----------------------------------`
);
const queryInfo = `${'operation' in query.definitions[0] && query.definitions[0].operation} ${
('name' in query.definitions[0] && query.definitions[0].name?.value) || ''
}`;
const body = JSON.stringify({
query: queryString,
variables,
});
const queryHash = getMd5Hash(body);
if (cache.has(queryHash) && allowCache) {
const cachedData = cache.get(queryHash);
logger.log(`gqlQuery::Cache ${queryInfo}`, 'Cache: HIT');
return { data: JSON.parse(cachedData as string) as T | null, error: null, newToken };
}
if (allowCache) {
logger.log(cache.getStats());
}
const chooseToken = () => {
if (newToken) {
return newToken;
}
return token;
};
const refreshInvalidToken = async () => {
logger.log('gqlQuery::RefreshToken');
if (!refreshToken) {
logger.log('gqlQuery::RefreshToken::Error: No refresh token provided');
return false;
}
const data = await mutateTokenRefresh({ refreshToken });
if (data.data?.tokenRefresh?.token) {
newToken = data.data?.tokenRefresh?.token;
} else {
logger.log('gqlQuery::RefreshToken::Error: No token received while trying to refresh');
}
};
const fetchData = () =>
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(chooseToken() ? { Authorization: `Bearer ${chooseToken()}` } : {}),
},
body,
})
.then(async (result) => {
const data = await result.json();
const requestDuration = `${(Date.now() - start) / 100}ms`;
const cleanedVariables = Object.keys(variables).map((key) => {
if (
key.includes('password') ||
key.includes('token') ||
key.includes('Token') ||
key.includes('email')
) {
return { [key]: '*****' };
}
return { [key]: variables[key] };
});
logger.log(`gqlQuery:: ${queryInfo}`, {
variables: cleanedVariables,
data: data.data,
extensions: data.extensions,
requestDuration,
});
return data;
})
.then(async (response) => {
if (response.errors) {
logger.log(`gqlQuery::Errors ${queryInfo}`, response.errors);
if (
response.errors.some(
(error: any) => error.extensions.exception.code === 'ExpiredSignatureError'
)
) {
await refreshInvalidToken();
response = await fetchData();
}
// return { data: null, error: data.errors, newToken };
}
if (allowCache && response.data) {
cache.set(queryHash, JSON.stringify(response.data));
}
return { data: response.data as T, error: null, newToken };
});
return await fetchData();
};
import { createHash } from 'crypto';
export const getMd5Hash = (str: string) => createHash('md5').update(str).digest('hex');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment