Search code examples
node.jsrestexpresscookiesapi-design

When to ask the client their preferred currency if no country code in `accept-language` header?


What my code is doing: Determining Client Currency

I'm getting the clients preferred locale from the accept-language header. From the accept-language header, I get the language and country code to help me figure out their preferred currency. en-US would be US dollars, en-CA Canadian dollars, etc.

Here's the code for my middleware that gets the preferred locale:

const getPreferredLocale = (acceptLanguageHeader) => {
  const locales = acceptLanguageHeader
    .split(/(\b, \b|\b,\b|\b;q=\b)/g)
    .filter((el) => el !== ',' && el !== ', ' && el !== ';q=')
    .reduce(
      (a, c, i, arr) =>
        Number.isNaN(Number(c))
          ? [...a, { locale: c, q: Number.isNaN(Number(arr[i + 1])) ? '1' : arr[i + 1] }]
          : a,
      []
    )
    .sort((a, b) => (a.q > b.q ? -1 : 1));
  return (
    locales.find((el) => el.locale.match(/-[A-Z]{2}/g) && el.locale.match(/-[A-Z]{2}/g)).locale ||
    locales[0].locale
  );
};

const makeLocaleObj = (locale) => ({
  locale,
  countryCode: locale.match(/(?<=\-)[A-Z]*/g)[0],
  languageCode: locale.match(/[^-]*/)[0],
});

const setLocaleCookie = (req, res, next) => {
  const cookieLocale = req.cookies.locale;
  if (!cookieLocale) {
    const locale = getPreferredLocale(req.headers['accept-language']);
    const localeObj = makeLocaleObj(locale);
    res.cookie('locale', JSON.stringify(localeObj), { maxAge: new Date() * 0.001 + 300 });
    req.countryCode = localeObj.countryCode; // set for currency middleware
  }
  next();
};

app.use(setLocaleCookie);

In another middleware I use country code to determine the currency.

The Problem

But sometimes the user might only have a language code in the header and no country code- like en for English. You need the country to determine the currency. So what do you do?

In this case you either have to

  • guess the country based on language- easy for some languages, harder for others
  • get the client to request https://extreme-ip-lookup.com/json/ and get the country code from the response
  • ask the client to specify their currency

I am going with either of the last two. But I'm having trouble figuring out when I do either of those.

What I could do if this was a route

If cookies were set by some route like /setCookie then this would be easy: the response could specify to the client what are the next steps. For example, the server could send a 200 status with a JSON object like {stillNeedCountry: true}. Then the client could know that more steps need to be taken.

But this is a general middleware

But cookies are typically not set in specific route requests. They are set on any first request made from that client to the server in middleware called on every request. This leaves me confused. We can detect in the middleware that there is no countryCode, but then what?

Solutions?

Do I hijack the request and send a response right from the middleware telling the frontend what to do? This seems complicated because we would have to have every fetch request on the front end set up to handle this response.

What are possible solutions to this?


Solution

  • I had assumptions in this post that were incorrect.

    This conversation with Andy Palmer clarified a lot for me.

    • I thought middleware is any function that is passed the request, regardless if it's only on some endpoints. But it turns out middleware is a function that all requests go through. I was calling that "general middleware".
    • I thought cookies need to always be set on the backend in some "middleware magic". They do not. They can be set using specific route requests and on the frontend.
    • I was misunderstanding the jobs of the frontend, backend, and middleware.

    I thought cookies need to always be set on the backend because the only other experience I had with cookies was using express-session where the cookie is set in the backend middleware. It was just an assumption I made based on how I'd seen cookies used before.

    You don't set the cookie in middleware because it's an application/business logic concern, not an infrastructure concern
    ...
    ...currency choice is a user choice, so is set by the user.
    ...
    Use middleware to annotate a request with infrastructure things that the application doesn't need to do.
    ...
    You could use middleware to annotate the request with the selected currency, but it feels a bit application-specific.

    E.g. a middleware to extract country and language from the accept-language headers

    -Andy

    I can use a specific route to query whether the cookie is set and decide how to proceed. I can then do something on the frontend like ask the client to specify their preferred currency from a list of currencies based on the hints we get from the locale.

    app.get('/hint_currency', (req, res) => {
      res.send(req.cookies.locale || req.locale);
    });
    
    function App() {
      const [user, setUser] = useState(null);
    
      const parsedCookies = () => {
        const str = decodeURIComponent(document.cookie).split('; ');
        const result = {};
        for (let i = 0; i < str.length; i++) {
          const cur = str[i].split('=');
          result[cur[0]] = cur[1];
        }
        return result;
      };
    
      const chooseCurrency = (locale) => {
        if (locale.countryCode) {
          const currencies = getCurrencies(locale.countryCode);
          //replace with form to select currency and set document.cookie
          if (currencies.length > 1)
            return alert('Here we would ask the user to pick currency: ' + currencies.join(', '));
    
          document.cookie = `currency= ${currencies[0]}`; // give the user a way to change the currency
        } else {
          //replace with form to select currency based on language and set document.cookie
          alert(
            `Here the user would pick currency from list of currencies. Currencies used in countries where people speak languageCode: "${locale.languageCode}" could be at top of list`
          );
        }
      };
    
      const fetchCurrency = () => {
        if (!user?.currency && !parsedCookies().currency) {
          fetch('/hint_currency')
            .then((res) => {
              if (res.status === 204) return null;
              return res.text();
            })
            .then((text) => {
              const locale = JSON.parse(text);
              chooseCurrency(locale);
            });
        }
      };
    
    
      useEffect(() => {
        fetchCurrency(); 
      }, []);
    //...
    

    Alternatively, I realized I could handle setting the currency after the first fetch request, using document.cookie.locale instead of after the response from '/hint_currency'.

    function App() {
      const [user, setUser] = useState(null);
    
      const parsedCookies = () => {
        const str = decodeURIComponent(document.cookie).split('; ');
        const result = {};
        for (let i = 0; i < str.length; i++) {
          const cur = str[i].split('=');
          result[cur[0]] = cur[1];
        }
        return result;
      };
    
      const chooseCurrency = (locale) => {
        if (locale.countryCode) {
          const currencies = getCurrencies(locale.countryCode);
          //replace with form to select currency and set document.cookie
          if (currencies.length > 1)
            return alert('Here we would ask the user to pick currency: ' + currencies.join(', '));
    
          document.cookie = `currency= ${currencies[0]}`; // give the user a way to change the currency
        } else {
          //replace with form to select currency based on language and set document.cookie
          alert(
            `Here the user would pick currency from list of currencies. Currencies used in countries where people speak languageCode: "${locale.languageCode}" could be at top of list`
          );
        }
      };
    
      const fetchUser = () => {
        return fetch('/users/current')
          .then((res) => {
            if (res.status === 204) return null;
            return res.json();
          })
          .then((user) => {
            setUser(user);
            return user;
          });
      };
    
      useEffect(() => {
        fetchUser().then((usr) => {
          const cookies = parsedCookies();
          if (!usr?.currency || !cookies.currency) chooseCurrency(JSON.parse(cookies.locale));
          else if (usr?.currency) document.cookie.currency = usr.currency;
        });
      }, []);
    //...
    

    You could also store the currency in the the session.

    Generally, you'd probably store the currency (and other user data) in a session store, and the cookie would identify the session.

    Then the session store middleware would retrieve and annotate the request with the user data. That's the usual compromise; the middleware only knows about session state, it's not making business decisions.

    Your application asks for request.session.currency