React Apollo + Gqlgen + Websocket – The Complete Guide. Part 2

In the previous part, we prepared the GraphQL service to work with the business logic layer.
Challenges ahead:
Connect from React + Apollo
Create CID – client (browser) identifier and write it to Cookie
Reliably bind websocket to client CID
Authorization processing
The Gqlgen package itself does not provide tools for working with HTTP. This is not a minus, he does not need it.
Of course, our application must be able to set cookies and read headers.
All this is solved with the help of middleware, in Gorilla mux it is done like this:
// Инициализируем стор
st := store.NewStore(store.Options{})
// Создадим роутер
router := mux.NewRouter()
// Инициализируем middleware
// Передадим Store в качестве параметра
router.Use(middleware.AuthMiddleware(st))
router.Use(middleware.CorsMiddleware(st))
AuthMiddleware code, standard middleware:
func AuthMiddleware(store *store.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Метод из Store, обрабатывает логику авторизации
r = store.AuthorizationHTTP(w, r)
next.ServeHTTP(w, r)
})
}
}
The CorsMiddleware code, we will not execute any logic, we will hardcode everything:
func CorsMiddleware(store *store.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers",
"Accept, Content-Type, Content-Length, Accept-Encoding")
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
})
}
}
AuthorizationHTTP Method
The same method that is called in AuthMiddleware. Here we check tokens, perform authorization, work with cookies. Official example here…
func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
ctx := r.Context()
return r.WithContext(ctx)
}
Metadata: UID, CID, etc. will save in context. Let’s write a little logic so that you can easily refer to them in the future.
package model
// Структура мета данных
type Meta struct {
Uid int
Cid string
Role string
Reconnect bool
Authorized bool
}
// Приватный ключ контекста
type key struct {
name string
}
// Читаем Meta из контекста
func (m *Meta) Value(ctx context.Context) *Meta {
meta := ctx.Value(key{"meta"})
if meta == nil {
return m
}
return meta.(*Meta)
}
// Пишем Meta в контекст
func (m *Meta) WithContext(ctx context.Context) context.Context {
ctx = context.WithValue(ctx, key{"meta"}, m)
return ctx
}
Now we have all the tools for authorizing further user actions. Let’s finalize AuthorizationHTTP:
func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
ctx := r.Context()
// Создадим мету
meta := &model.Meta{}
// Проверим наличие токена Cid
cidCookie, err := r.Cookie("_cid")
if err != nil {
// Токена нет
cid := uuid.New().String()
cidCookie = &http.Cookie{
Name: "_cid",
Value: cid,
HttpOnly: true,
}
http.SetCookie(w, cidCookie)
}
// Прочитаем Cid и запишем в Meta
if cidCookie != nil {
meta.Cid = cidCookie.Value
}
return r.WithContext(
// Запишем Meta в контекст
meta.WithContext(ctx),
)
}
Let me remind you about the authorization methods that the client must to pull cause in the first place.
Auth(ctx context.Context) (*model.Auth, error)
If it returns reconnect, it means React Apollo should restart the connection.
– Why is it so difficult?
– Sometimes it is not possible to reliably identify the websocket client.
He is not very interesting to us now.
Websocket authorization subscription method.
Case: the user enters his username in the form and then receives a message with a “Login button” to the email.
The “login button clicked” conditions on the front are not known, nevertheless React needs to figure out that somewhere – that button has been pressed. When React receives a message that it has authorization, all it needs to do is call any POST or GET request, and a cookie is set with the user’s token.
Method:
AuthSubscription(ctx context.Context) (<-chan *model.Auth, error)
Returns a channel, everything written to it will be sent via websocket.
Note that each new browser tab creates an additional chan * model.Auth channel. In fact, different clients are created, but linked by a single CID.
Unsubscribing a channel (closing a tab), you can find out about this in the ctx.Done method:
func AuthSubscription(ctx context.Context) (<-chan *model.Auth, error) {
// Получим мета из контекста
meta := &model.Meta{}
meta = meta.Value(ctx)
// Создадим websocket id
wsid := uuid.New().String()
// Создадим канал
ch := make(chan *model.Auth)
// Логика по добавлению слушателя
fmt.Printf("Connect CID: %v, WSID: %vn", meta.Cid, wsid)
// Логика по удалению слушателя
go func() {
<- ctx.Done()
fmt.Printf("Disconnect CID: %v, WSID: %vn", meta.Cid, wsid)
}()
return ch, nil
}
Create a front
Install React
npx create-react-app front --template typescript
Install Apollo Client and dependencies
npm install @apollo/client graphql subscriptions-transport-ws
To simplify the process of creating interfaces install MUI We will not analyze this moment, since it is not included in the main task.
Apollo Client Connection
Let’s put together the function of connecting to the API, documentation:
const connect = () => {
const httpLink = new HttpLink({
uri: 'http://localhost:2000/graphql',
credentials: 'include',
});
const wsLink = new WebSocketLink({
uri: 'ws://localhost:2000/graphql',
options: {
reconnect: true,
}
});
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
return new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
}
We want to be able to manage the connection, reconnect if necessary. The context is best for this task. documentation:
interface ContextTypes {}
export const ConnectContext = createContext<Partial<ContextTypes>>({})
export const ConnectProvider: FC = ({ children }) => {
const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)
return(
<ConnectContext.Provider value={{
}}>
<ApolloProvider client={state}>
{ children }
</ApolloProvider>
</ConnectContext.Provider>
)
}
Now we will make an authorization request and connect via websocket:
type TAuth = {
authorized: boolean
method: string
reconnect: boolean
}
interface ContextTypes {
auth: TAuth
logout: () => void
}
export const AuthorizationContext = createContext<Partial<ContextTypes>>({})
export const AuthorizationProvider: FC = ({ children }) => {
// GET запрос Auth
const { loading, error, data } = useQuery(QUERY);
useEffect(() => {
console.log("GET Auth", loading, error, data)
}, [loading, error, data])
// Слушаем Auth websocket
const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
SUBSCR
);
useEffect(() => {
console.log("Websocket Auth", wsLoading, wsError, wsData)
}, [wsLoading, wsError, wsData])
return (
<AuthorizationContext.Provider value={{
}}>
{children}
</AuthorizationContext.Provider>
)
}
const SUBSCR = gql`
subscription{
authSubscription{
authorized,
method,
reconnect
}
}
`;
const QUERY = gql`
query {
auth{
authorized,
method,
reconnect
}
}
`;
Experiments
When the client connects, the terminal running the Go service tells us something like this:
# При подключении
Connect CID: UUID_1, WSID: UUID_2
# При закрытии соединения
Disconnect CID: UUID_1, WSID: UUID_2
UUID_1 must match cookie CID, otherwise delivery will fail.
Case for a different cookie CID with a websocket ID
Reproduction steps:
Remove the CID token,
update the tab
check the CID entry in the Cookie
look at the CID record of the client connected via websocket (terminal Go)
Entries vary, linked to Apollo, move towards ping-pong
What happened?
The websocket connection is established before the client (browser) receives the CID cookie.
PS: I’m almost sure that this is a crutch and the solution is in Apollo. If someone knows how to decide, I will be glad in the comments.
How to decide?
It is necessary to inform the front that the CID has just been set, for this we will pass the Reconnect flag.
Let’s add the AuthorizationHTTP logic:
// ...
cidCookie, err := r.Cookie("_cid")
if err != nil {
meta.Reconnect = true
// ...
}
Let’s fix the AuthQuery method:
func AuthQuery(ctx context.Context) (*model.Auth, error) {
// Получим мета из контекста
meta := &model.Meta{}
meta = meta.Value(ctx)
auth := &model.Auth{}
// Отправим флаг Reconnect, если он есть
auth.Reconnect = meta.Reconnect
return auth, nil
}
React ConnectProvider.
We will hang up a method that allows you to restart the connection
interface ContextTypes {
reconnect: () => void
}
export const ConnectContext = createContext<Partial<ContextTypes>>({})
export const ConnectProvider: FC = ({ children }) => {
const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)
const reconnect = () => setState(connect)
return(
<ConnectContext.Provider value={{
reconnect
}}>
<ApolloProvider client={state}>
{ children }
</ApolloProvider>
</ConnectContext.Provider>
)
}
AuthorizationProvider by pulling the reconnect method out of context
export const AuthorizationProvider: FC = ({ children }) => {
const { reconnect } = useContext(ConnectContext)
// GET запрос Auth
const { loading, error, data } = useQuery(QUERY);
useEffect(() => {
if (!loading && !error && data) {
const { reconnect: rc } = data.auth
if (rc === true && reconnect) {
// Если получен флаг reconnect
reconnect()
}
}
}, [loading, error, data])
// Слушаем Auth websocket
const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
SUBSCR
);
useEffect(() => {
console.log("Websocket Auth", wsLoading, wsError, wsData)
}, [wsLoading, wsError, wsData])
return (
<AuthorizationContext.Provider value={{
}}>
{children}
</AuthorizationContext.Provider>
)
}
Ready
Now we always get the same client ID in HTTP and WS requests.
Let’s finish this stage. Sources here
Next, you need to collect the websocket listeners, and tighten the authorization via SMS