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:

  1. Connect from React + Apollo

  2. Create CID – client (browser) identifier and write it to Cookie

  3. 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:

  1. Remove the CID token,

  2. update the tab

  3. check the CID entry in the Cookie

  4. 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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *