How to pass data from the response headers to the ApolloProvider with SSR? - apollo

I work with an application built with Nextjs and Apollo. I receive a token into the Graphql response headers. I can read this token on the server side and I need to pass it to the Apollo Provider on the client side.
cache.writeData({
data: {
isLoggedIn: false
}
});
const afterwareLink = new ApolloLink((operation, forward) => {
return forward(operation).map(response => {
const context = operation.getContext();
const {
response: { headers },
} = context;
if (headers) {
const authorization = headers.get('authorization');
if (authorization) {
console.log(authorization);
// what to do next? I need somehow set isLoggedIn to true...
}
}
return response;
});
});
const link = ApolloLink.from([
afterwareLink,
new RetryLink(),
httpLink
]);
const client = new ApolloClient({
link,
cache
});
function App({ children }){
return (
<ApolloProvider client={client}>
{children}
</ApolloProvider>
);
}
I have tried to set the context inside de ApolloLink:
operation.setContext({ isLoggedIn: true });
I can't write directly on the cache because the page is rendered on the server side.

Related

Modify Cloudfront origin response with Lambda - read-only headers

I have a Cloudfront distribution with a single React site, which is hosting in S3. The origin is connected via REST api. To properly handle queries, I use custom error responses on status 403 and 404 to 200 and route them to root. The root object is index.html and everything seems to be fine.
Now I have a task to add to a distribution an another site, which should be accessible through a subdirectory.
To do this I have to set a root object for a subdirectory and to catch 404 and 403 responses and transfer them to a root object. I've already set up origin and behaviour.
I tried to use theese manuals:
example
source
but it seems that something went wrong
The first approach (CloudFrontSubdirectoryIndex) seems not working at all (the function is not invoked and no rewrite happens), so i tried CloudFront function and it seems to work fine.
The last step is to handle 404 and 403 responses.
Here is the function from the manual:
'use strict';
const http = require('https');
const indexPage = 'index.html';
exports.handler = async (event, context, callback) => {
const cf = event.Records[0].cf;
const request = cf.request;
const response = cf.response;
const statusCode = response.status;
// Only replace 403 and 404 requests typically received
// when loading a page for a SPA that uses client-side routing
const doReplace = request.method === 'GET'
&& (statusCode == '403' || statusCode == '404');
const result = doReplace
? await generateResponseAndLog(cf, request, indexPage)
: response;
callback(null, result);
};
async function generateResponseAndLog(cf, request, indexPage){
const domain = cf.config.distributionDomainName;
const appPath = getAppPath(request.uri);
const indexPath = `/${appPath}/${indexPage}`;
const response = await generateResponse(domain, indexPath);
console.log('response: ' + JSON.stringify(response));
return response;
}
async function generateResponse(domain, path){
try {
// Load HTML index from the CloudFront cache
const s3Response = await httpGet({ hostname: domain, path: path });
const headers = s3Response.headers ||
{
'content-type': [{ value: 'text/html;charset=UTF-8' }]
};
return {
status: '200',
headers: wrapAndFilterHeaders(headers),
body: s3Response.body
};
} catch (error) {
return {
status: '500',
headers:{
'content-type': [{ value: 'text/plain' }]
},
body: 'An error occurred loading the page'
};
}
}
function httpGet(params) {
return new Promise((resolve, reject) => {
http.get(params, (resp) => {
console.log(`Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`);
let result = {
headers: resp.headers,
body: ''
};
resp.on('data', (chunk) => { result.body += chunk; });
resp.on('end', () => { resolve(result); });
}).on('error', (err) => {
console.log(`Couldn't fetch ${params.hostname}${params.path} : ${err.message}`);
reject(err, null);
});
});
}
// Get the app path segment e.g. candidates.app, employers.client etc
function getAppPath(path){
if(!path){
return '';
}
if(path[0] === '/'){
path = path.slice(1);
}
const segments = path.split('/');
// will always have at least one segment (may be empty)
return segments[0];
}
// Cloudfront requires header values to be wrapped in an array
function wrapAndFilterHeaders(headers){
const allowedHeaders = [
'content-type',
'content-length',
'last-modified',
'date',
'etag'
];
const responseHeaders = {};
if(!headers){
return responseHeaders;
}
for(var propName in headers) {
// only include allowed headers
if(allowedHeaders.includes(propName.toLowerCase())){
var header = headers[propName];
if (Array.isArray(header)){
// assume already 'wrapped' format
responseHeaders[propName] = header;
} else {
// fix to required format
responseHeaders[propName] = [{ value: header }];
}
}
}
return responseHeaders;
}
When i try to implement this solution (attach the function to origin response) I get
The Lambda function result failed validation: The function tried to add, delete, or change a read-only header.
Here is a list of restricted headers, but I'm not modifying any of them.
If I try not to attach any headers to a response at all, the message is the same.
If I try to attach all headers, CloudFront says that i'm modifying a black-listed header.
Objects in a bucket have only one customized Cache-Control: no-cache metadata.
It seemed to be a fast task, but I'm stuck for two days already.
Any help will be appreciated.
UPD: I've searched the logs and found
ERROR Validation error: Lambda function result failed validation, the function tried to delete read-only header, headerName : Transfer-Encoding.
I'm a little bit confused. This header is not present in origin response, but CF is telling that I deleted it...
I tried to find the value of the header "Transfer-Encoding" that should come from origin (S3) but it seems that it has been disappeared. And CloudFront says that this header is essential.
So I've just hard-coded it and everything becomes fine.
'use strict';
const http = require('https');
const indexPage = 'index.html';
exports.handler = async (event, context, callback) => {
const cf = event.Records[0].cf;
const request = cf.request;
const response = cf.response;
const statusCode = response.status;
// Only replace 403 and 404 requests typically received
// when loading a page for a SPA that uses client-side routing
const doReplace = request.method === 'GET'
&& (statusCode == '403' || statusCode == '404');
const result = doReplace
? await generateResponseAndLog(cf, request, indexPage)
: response;
callback(null, result);
};
async function generateResponseAndLog(cf, request, indexPage){
const domain = cf.config.distributionDomainName;
const appPath = getAppPath(request.uri);
const indexPath = `/${appPath}/${indexPage}`;
const response = await generateResponse(domain, indexPath);
console.log('response: ' + JSON.stringify(response));
return response;
}
async function generateResponse(domain, path){
try {
// Load HTML index from the CloudFront cache
const s3Response = await httpGet({ hostname: domain, path: path });
const headers = s3Response.headers ||
{
'content-type': [{ value: 'text/html;charset=UTF-8' }]
};
s3Response.headers['transfer-encoding'] = 'chunked';
return {
status: '200',
headers: wrapAndFilterHeaders(headers),
body: s3Response.body
};
} catch (error) {
return {
status: '500',
headers:{
'content-type': [{ value: 'text/plain' }]
},
body: 'An error occurred loading the page'
};
}
}
function httpGet(params) {
return new Promise((resolve, reject) => {
http.get(params, (resp) => {
console.log(`Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`);
let result = {
headers: resp.headers,
body: ''
};
resp.on('data', (chunk) => { result.body += chunk; });
resp.on('end', () => { resolve(result); });
}).on('error', (err) => {
console.log(`Couldn't fetch ${params.hostname}${params.path} : ${err.message}`);
reject(err, null);
});
});
}
// Get the app path segment e.g. candidates.app, employers.client etc
function getAppPath(path){
if(!path){
return '';
}
if(path[0] === '/'){
path = path.slice(1);
}
const segments = path.split('/');
// will always have at least one segment (may be empty)
return segments[0];
}
// Cloudfront requires header values to be wrapped in an array
function wrapAndFilterHeaders(headers){
const allowedHeaders = [
'content-type',
'content-length',
'content-encoding',
'transfer-encoding',
'last-modified',
'date',
'etag'
];
const responseHeaders = {};
if(!headers){
return responseHeaders;
}
for(var propName in headers) {
// only include allowed headers
if(allowedHeaders.includes(propName.toLowerCase())){
var header = headers[propName];
if (Array.isArray(header)){
// assume already 'wrapped' format
responseHeaders[propName] = header;
} else {
// fix to required format
responseHeaders[propName] = [{ value: header }];
}
}
}
return responseHeaders;
}

Setting authorization header from local storage race issue?

Apollo client suggest following code to set authentication header, where data from local storage is shown as synchronous function call.
import { ApolloClient, createHttpLink, InMemoryCache } from '#apollo/client';
import { setContext } from '#apollo/client/link/context';
const httpLink = createHttpLink({
uri: '/graphql',
});
const authLink = setContext((_, { headers }) => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem('token');
// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
In this line,
const token = localStorage.getItem('token');
we mostly use async/await to get data from local storage, in that case authLink will be async function right? eg:
const authLink = setContext(async (_, { headers }) => {
i believe that means,
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
this lines run before we fulfill async httpLink function call, is not this a race issue? will apollo assign httpLink to authLink.concat after promise is fulfilled ??

How to mutate apollo context in next.js?

I've looked in to api-routes-apollo-server-and-client-auth example and not sure how can i mutate context both on ssr and client request.
I want for every graphql resolver (using api) have access to ctx.user object with preparsed JWT token. But where should i parse it?
If i parse it here:
const apolloServer = new ApolloServer({
schema,
context(ctx) {
ctx.user = '123'
return ctx
}
})
export default apolloServer.createHandler({ path: '/api/graphql' })
Then ctx.user will be undefined on SSR request, working only on client request.
You can have something like this:
// server
const apolloServer = new ApolloServer({
schema,
context: async () => {
const jwt = req.headers.authorization
const user = await getUserFromJwt(jwt)
return {
user,
}
}
})
// client
const link = createHttpLink({ uri: 'http://localhost...' })
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: jwt,
},
}
})
const apolloClient = new ApolloClient({
link: authLink.concat(link),
...
})
Check out these docs: client and server

Getting access to apolloClient within getInitialProps through SSR

I was hoping to get information to populate through SSR before the page loads. I've been following this example https://github.com/zeit/next.js/tree/canary/examples/with-apollo-auth/pages but been noticing the apolloClient doesn't exist within getInitialProps.
My withAuth.js
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { createHttpLink } from 'apollo-link-http';
import { setContext } from 'apollo-link-context';
import { ApolloProvider } from 'react-apollo';
import PropTypes from 'prop-types';
import Head from 'next/head';
import Cookies from 'js-cookie';
import fetch from 'isomorphic-unfetch';
export const withApollo = (PageComponent, { ssr = true } = {}) => {
const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => {
const client = apolloClient || initApolloClient(apolloState, { getToken });
return (
<ApolloProvider client={client}>
<PageComponent {...pageProps} />
</ApolloProvider>
);
};
if (process.env.NODE_ENV !== 'production') {
// Find correct display name
const displayName = PageComponent.displayName || PageComponent.name || 'Component';
// Warn if old way of installing apollo is used
if (displayName === 'App') {
console.warn('This withApollo HOC only works with PageComponents.');
}
// Set correct display name for devtools
WithApollo.displayName = `withApollo(${displayName})`;
// Add some prop types
WithApollo.propTypes = {
// Used for getDataFromTree rendering
apolloClient: PropTypes.object,
// Used for client/server rendering
apolloState: PropTypes.object
};
}
if (ssr || PageComponent.getInitialProps) {
WithApollo.getInitialProps = async (ctx) => {
const { AppTree } = ctx;
console.log(AppTree);
// Run all GraphQL queries in the component tree
// and extract the resulting data
const apolloClient = (ctx.apolloClient = initApolloClient(
{},
{
getToken: () => getToken(ctx.req)
}
));
const pageProps = PageComponent.getInitialProps ? await PageComponent.getInitialProps(ctx) : {};
// Only on the server
if (typeof window === 'undefined') {
// When redirecting, the response is finished.
// No point in continuing to render
if (ctx.res && ctx.res.finished) {
return {};
}
if (ssr) {
try {
// Run all GraphQL queries
console.log('trying');
const { getDataFromTree } = await import('#apollo/react-ssr');
await getDataFromTree(
<AppTree
pageProps={{
...pageProps,
apolloClient
}}
/>
);
} catch (error) {
// Prevent Apollo Client GraphQL errors from crashing SSR.
// Handle them in components via the data.error prop:
// https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error
console.error('Error while running `getDataFromTree`', error);
}
}
// getDataFromTree does not call componentWillUnmount
// head side effect therefore need to be cleared manually
Head.rewind();
}
// Extract query data from the Apollo store
const apolloState = apolloClient.cache.extract();
return {
...pageProps,
apolloState
};
};
}
return WithApollo;
};
let apolloClient = null;
/**
* Always creates a new apollo client on the server
* Creates or reuses apollo client in the browser.
*/
const initApolloClient = (...args) => {
// Make sure to create a new client for every server-side request so that data
// isn't shared between connections (which would be bad)
if (typeof window === 'undefined') {
return createApolloClient(...args);
}
// Reuse client on the client-side
if (!apolloClient) {
apolloClient = createApolloClient(...args);
}
return apolloClient;
};
const createApolloClient = (initialState = {}, { getToken }) => {
let fetchOptions = {};
const HTTP_ENDPOINT = 'http://localhost:4000/api';
const httpLink = createHttpLink({
uri: HTTP_ENDPOINT,
credentials: 'same-origin',
fetch,
fetchOptions
});
const authLink = setContext((request, { headers }) => {
const token = getToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ''
}
};
});
return new ApolloClient({
ssrMode: typeof window === 'undefined', // Disables forceFetch on the server (so queries are only run once)
link: authLink.concat(httpLink),
cache: new InMemoryCache().restore(initialState)
});
};
const getToken = () => {
return Cookies.get('token');
};
I'm using it as a HOC in my _app.js file and been trying to get access to the apolloClient in my Signin component hoping to do a check if a person is logged in, in order to redirect them (also would like to know in order to make the navbar dynamic)
Thank you for the help on this one
Try the following code and now you should be able to access apolloClient within getInitialProps.
const apolloClient = (ctx.ctx.apolloClient = initApolloClient({}, {
getToken: () => getToken(ctx.req)}));
I think you just missed one thing i.e. to return the apolloClient while returning the PageProps and ApolloCache when SSR is true.
// Extract query data from the Apollo store
const apolloState = apolloClient.cache.extract();
return {
...pageProps,
apolloState,
// To get access to client while in SSR
apolloClient
};

How to execute an async fetch request and then retry last failed request?

Apollo link offers an error handler onError
Issue:
Currently, we wish to refresh oauth tokens when they expires during an apollo call and we are unable to execute an async fetch request inside the onError properly.
Code:
initApolloClient.js
import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, fromPromise } from 'apollo-link';
//Define Http link
const httpLink = new createHttpLink({
uri: '/my-graphql-endpoint',
credentials: 'include'
});
//Add on error handler for apollo link
return new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
//User access token has expired
if(graphQLErrors[0].message==="Unauthorized") {
//We assume we have both tokens needed to run the async request
if(refreshToken && clientToken) {
//let's refresh token through async request
return fromPromise(
authAPI.requestRefreshToken(refreshToken,clientToken)
.then((refreshResponse) => {
let headers = {
//readd old headers
...operation.getContext().headers,
//switch out old access token for new one
authorization: `Bearer ${refreshResponse.access_token}`,
};
operation.setContext({
headers
});
//Retry last failed request
return forward(operation);
})
.catch(function (error) {
//No refresh or client token available, we force user to login
return error;
})
)
}
}
}
}
}
}),
What happens is:
Initial graphQL query runs and fails due to unauthorization
The onError function of ApolloLink is executed.
The promise to refresh the token is executed.
The onError function of ApolloLink is executed again??
The promise to refresh the token is completed.
The initial graphQL query result is returned and its data is undefined
Between step 5 and 6, apollo doesn't re-run the initial failed graphQL query and hence the result is undefined.
Errors from console:
Uncaught (in promise) Error: Network error: Error writing result to store for query:
query UserProfile($id: ID!) {
UserProfile(id: $id) {
id
email
first_name
last_name
}
__typename
}
}
The solution should allow us to:
Run an async request when an operation fails
Wait for the result of the request
Retry failed operation with data from the request's result
Operation should succeed to return its intended result
I'm refreshing the token this way (updated OP's):
import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable } from 'apollo-link'; // add Observable
// Define Http link
const httpLink = new createHttpLink({
uri: '/my-graphql-endpoint',
credentials: 'include'
});
// Add on error handler for apollo link
return new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, operation, forward }) => {
// User access token has expired
if (graphQLErrors && graphQLErrors[0].message === 'Unauthorized') {
// We assume we have both tokens needed to run the async request
if (refreshToken && clientToken) {
// Let's refresh token through async request
return new Observable(observer => {
authAPI.requestRefreshToken(refreshToken, clientToken)
.then(refreshResponse => {
operation.setContext(({ headers = {} }) => ({
headers: {
// Re-add old headers
...headers,
// Switch out old access token for new one
authorization: `Bearer ${refreshResponse.access_token}` || null,
}
}));
})
.then(() => {
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
};
// Retry last failed request
forward(operation).subscribe(subscriber);
})
.catch(error => {
// No refresh or client token available, we force user to login
observer.error(error);
});
});
}
}
})
])
});
Accepted answer is quite good but it wouldn't work with 2 or more concurrent requests. I've crafted the one below after testing different cases with my token renew workflow that fits my needs.
It's necessary to set errorLink before authLink in link pipeline.
client.ts
import { ApolloClient, from, HttpLink } from '#apollo/client'
import errorLink from './errorLink'
import authLink from './authLink'
import cache from './cache'
const httpLink = new HttpLink({
uri: process.env.REACT_APP_API_URL,
})
const apiClient = new ApolloClient({
link: from([errorLink, authLink, httpLink]),
cache,
credentials: 'include',
})
export default apiClient
Cache shared between 2 apollo client instances for setting user query when my renewal token is expired
cache.ts
import { InMemoryCache } from '#apollo/client'
const cache = new InMemoryCache()
export default cache
authLink.ts
import { ApolloLink } from '#apollo/client'
type Headers = {
authorization?: string
}
const authLink = new ApolloLink((operation, forward) => {
const accessToken = localStorage.getItem('accessToken')
operation.setContext(({ headers }: { headers: Headers }) => ({
headers: {
...headers,
authorization: accessToken,
},
}))
return forward(operation)
})
export default authLink
errorLink.ts
import { ApolloClient, createHttpLink, fromPromise } from '#apollo/client'
import { onError } from '#apollo/client/link/error'
import { GET_CURRENT_USER } from 'queries'
import { RENEW_TOKEN } from 'mutations'
import cache from './cache'
let isRefreshing = false
let pendingRequests: Function[] = []
const setIsRefreshing = (value: boolean) => {
isRefreshing = value
}
const addPendingRequest = (pendingRequest: Function) => {
pendingRequests.push(pendingRequest)
}
const renewTokenApiClient = new ApolloClient({
link: createHttpLink({ uri: process.env.REACT_APP_API_URL }),
cache,
credentials: 'include',
})
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback())
pendingRequests = []
}
const getNewToken = async () => {
const oldRenewalToken = localStorage.getItem('renewalToken')
const {
data: {
renewToken: {
session: { renewalToken, accessToken },
},
},
} = await renewTokenApiClient.mutate({
mutation: RENEW_TOKEN,
variables: { input: { renewalToken: oldRenewalToken } },
})!
localStorage.setItem('renewalToken', renewalToken)
localStorage.setItem('accessToken', accessToken)
}
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err?.message) {
case 'expired':
if (!isRefreshing) {
setIsRefreshing(true)
return fromPromise(
getNewToken().catch(() => {
resolvePendingRequests()
setIsRefreshing(false)
localStorage.clear()
// Cache shared with main client instance
renewTokenApiClient!.writeQuery({
query: GET_CURRENT_USER,
data: { currentUser: null },
})
return forward(operation)
}),
).flatMap(() => {
resolvePendingRequests()
setIsRefreshing(false)
return forward(operation)
})
} else {
return fromPromise(
new Promise((resolve) => {
addPendingRequest(() => resolve())
}),
).flatMap(() => {
return forward(operation)
})
}
}
}
}
})
export default errorLink
We just had the same issues and after a very complicated solution with lots of Observeables we got a simple solution using promises which will be wrapped as an Observable in the end.
let tokenRefreshPromise: Promise = Promise.resolve()
let isRefreshing: boolean
function createErrorLink (store): ApolloLink {
return onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
// this is a helper method where we are checking the error message
if (isExpiredLogin(graphQLErrors) && !isRefreshing) {
isRefreshing = true
tokenRefreshPromise = store.dispatch('authentication/refreshToken')
tokenRefreshPromise.then(() => isRefreshing = false)
}
return fromPromise(tokenRefreshPromise).flatMap(() => forward(operation))
}
if (networkError) {
handleNetworkError(displayErrorMessage)
}
})
}
All pending requests are waiting for the tokenRefreshPromise and will then be forwarded.