Локализация API бэкенда Node.js без i18next

Думаю, многие разработчики рано или поздно сталкиваются с необходимостью локализации / интернационализации своих приложений, будь то локализация текстов или просто сбор метрик о различных группах пользователей, в этой статье я затрону тему локализации бэкенда на Node.js.
В данном случае я затрону тему отправки сообщений о неудаче запроса, на различных его этапах, начиная от обычных сообщений в теле запроса, и заканчивая локализацией сообщений об ошибках на этапе проверки тела запроса.

Сразу отмечу, что во всех примерах будет использоваться веб-фреймворк Fastify, но они также вполне применимы к Express, Koa и другим веб-фреймворкам.

И так, для начала давайте познакомимся с визуализацией жизненного цикла запроса, и как будет происходить реализация работы по локализации:

Обратите внимание на две основные части:

  • Бизнес-логика — локализация ошибок/текстов, которые будут отправляться в результате работы с бизнес-логикой, сюда же можно отнести простые ошибки из preHandlers, например, авторизация
  • Validation — логика, отвечающая за проверку тела запроса req.body, (проверка авторизации пользователя сюда не входит).

Ниже мы рассмотрим в основном работу с уровнем бизнес-логики, а в конце статьи будет приведен пример локализации валидации.

Перед прочтением этой статьи вы должны иметь представление о том, как работает обработка ошибок в Fastify, Express и т.д.

Стратегия детектора языка

И так, прежде всего, когда мы говорим о локализации, возникает вопрос о стратегии определения языка пользователя, в случае Backend приложения у нас есть два наиболее используемых метода:

  • HTTP-заголовок запроса Accept-Language — который браузер автоматически отправляет, основываясь на языковых настройках браузера (рекомендуемый метод)
  • GEO IP — определение местоположения и, соответственно, языка пользователя на основе его IP-адреса.

Мы будем использовать HTTP-заголовок Accept-Language, так как в моей практике этот метод дает наибольшую точность в определении языка пользователя, а также является самым простым в реализации.

Реализация

И так, для начала, чтобы определить язык по заголовку Accept-Language, нам необходимо установить пакет Locale:

# Install with yarn
yarn add locale

# Install with npm
npm install locale --save
Вход в полноэкранный режим Выйти из полноэкранного режима

А теперь я предлагаю создать в проекте директорию i18n, которая будет содержать функцию-помощник для определения языка, а чуть позже мы добавим туда словари с текстами для локализации:

// i18n/index.ts
import locale from 'locale'

// Constant with the languages that we will support
export enum Language {
  DE = 'de',
  EN = 'en',
}
// fallback language
const defaultLang = Language.DE

const supportedLangs = new locale.Locales(Object.values(Language), defaultLang)
Вход в полноэкранный режим Выход из полноэкранного режима

Далее добавим функцию parseAcceptLanguage, которая будет определять язык в зависимости от заголовка:

// i18n/index.ts
export const parseAcceptLanguage = (acceptLanguage: string): Language => {
  const locales = new locale.Locales(acceptLanguage)
  const language = locales.best(supportedLangs).toString() as Language

  return language
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь я предлагаю создать одинаковые словари с текстами для разных языков.
В качестве примера создадим два словаря для английского языка EN и для немецкого языка DE.

// i18n/dictionaries/en.ts
export default {
  EMAIL_ALREADY_IN_USE: 'This Email is already in use!',
  USERNAME_ALREADY_IN_USE: 'This username is already in use!',
  LOGIN_ERROR: 'Wrong Username/Email/Password',
  FILE_LIMIT_MB: 'The file in the '#fieldName#' field exceeds the file size limit of #mb# MB',
}

// i18n/dictionaries/de.ts
export default {
  EMAIL_ALREADY_IN_USE: 'Diese E-Mail-Adresse wird schon verwendet!',
  USERNAME_ALREADY_IN_USE: 'Dieser Benutzername wird bereits benutzt!',
  LOGIN_ERROR: 'Falscher Benutzername/E-Mail/Passwort',
  FILE_LIMIT_MB: 'Die Datei im Feld '#fieldName#' überschreitet die Dateigrößenbeschränkung von #mb# MB',
}

// i18n/index.ts
import ru from './dictionaries/ru'
import en from './dictionaries/en'

export const dictionaries: Record<Language, typeof en> = {
  ru,
  en,
}
Войти в полноэкранный режим Выход из полноэкранного режима

Возможно, вы заметили, что в некоторых ключах есть слоты типа #fieldName# или #mb#, это сделано не просто так, а для того, чтобы во время выброса ошибки можно было указать дополнительные аргументы, которые будут вводиться туда динамически.

И так теперь напишем свой класс для брошенной ошибки, которую мы в дальнейшем будем ловить и преобразовывать в сообщения с локализацией.

// errors.ts
type Slots = Record<string, string | number> | null

export class HttpError extends Error {
  status: number
  slots: Slots

  constructor(status: number, message = '', slots: Slots = null) {
    super()

    this.status = status
    this.message = message
    this.slots = slots
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

А теперь давайте соберем все вместе и получим легкий и гибкий механизм локализации.
А соберем мы все на месте при обработке ошибок, так как Fastify уже умеет обрабатывать исключения из коробки, а с помощью setErrorHandler мы можем настроить его, что позволит нам проверить, является ли ошибка экземпляром класса HttpError, затем попытаться проверить наличие слотов, и отправить сообщение пользователю.

// server.ts
app.setErrorHandler((error, req, res) => {
  if (typeof req.lang !== 'string') {
    req.lang = parseAcceptLanguage(req.headers['accept-language']!)
  }

  const dictionary = dictionaries[req.lang]
  let message = dictionary[error.message] ?? error.message

  if (error instanceof HttpError) {
    if (error.slots !== null) {
      const slots = Object.keys(error.slots)

      for (const slot of slots) {
        message.replace(`#${slot}#`, slots[slot])
      }
    }

    return res.status(error.status).send({
      status: error.status,
      message,
    })
  }

  res.status(500).send({
    status: 500,
    message,
  })
})
Вход в полноэкранный режим Выйти из полноэкранного режима

Также не забудьте добавить поле lang к объекту FastifyRequest в файлах объявления типов.

// types.ts
declare module 'fastify' {
  interface FastifyRequest {
    lang?: Language
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вот и все!
Теперь на уровне бизнес-логики мы можем бросать исключения, которые будут локализованы для каждого конкретного пользователя:

// someHandler.ts
const emailInDb = await UserModel.exists({ email })
if (emailInDb) {
  throw new HttpError(400, 'EMAIL_ALREADY_IN_USE')
}

// or an example using slots
if (file.size > MBtoBytes(10)) {
  throw new HttpError(400, 'FILE_LIMIT_MB', {
    mb: 10,
    fieldName: 'photo',
  })
}
Вход в полноэкранный режим Выход из полноэкранного режима

Локализация сообщений проверки

А теперь перейдем к валидации, для примера я возьму довольно популярную библиотеку Yup.

Я знаю, что Yup имеет встроенные механизмы для локализации, но цель этой статьи — посмотреть на валидацию не как на готовые ошибки, которые нам выкидывает библиотека, а как на пару error key -> value + metadata, которой так или иначе придерживаются все библиотеки, и поняв это, не составит труда сделать локализацию на основе любой библиотеки.

Вот пример схемы, которая отвечает за валидацию:

// schemas.ts
import { object, string } from 'yup'

const createSchema = object().shape({
  name: string().required(),
  email: string().min(20).required(),
  sessionId: string().required(),
})
Вход в полноэкранный режим Выход из полноэкранного режима

А теперь напишем функцию, которая будет автоматически превращать схему в preHandler:

// errors.ts
export const validateYup = schema => async req => {
  try {
    await schema.validate(req.body, {
      abortEarly: false,
    })
  } catch (e) {
    req.lang = parseAcceptLanguage(req.headers['accept-language']!)

    throw new ValidationError(422, { data: translateYupError(e, req.lang) })
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

И теперь при определении маршрута мы будем просто писать preHandler: validateYup(createSchema).

Далее мы добавим в наш словарь типы ошибок Yup, будь то required или min, но я также рекомендую добавить для них префикс YUP_, чтобы в случае чего их можно было легко отделить от других подобных ошибок, связанных с валидацией:

// i18n/dictionaries/en.ts
export default {
  YUP_MIN: ({ params }) => `Min ${params.min}`,
  YUP_REQUIRED: 'Required text'
}
Войти в полноэкранный режим Выход из полноэкранного режима

Не удивляйтесь, что YUP_MIN является функцией, в данном случае это самый простой способ передать метаданные ошибки, в остальных случаях я рекомендую придерживаться синтаксиса слотов, описанного выше.

Но я думаю, вы заметили, что основная магия кроется в функции translateYupError, вот собственно и все:

// errors.ts
const translateYupError = (errors, lang) => errors.inner
  .map(error => {
    const key = `YUP_${error.type.toUpperCase()}`
    const message = dictionary[lang][key] ?? error.type // or fallback to error.message

    return {
      message: typeof message === 'function'
        ? message(error)
        : message,
      field: error.path,
    }
  })
Вход в полноэкранный режим Выход из полноэкранного режима

И теперь в ответе с ошибкой валидации у нас будут локализованы все поля, так как мы попадем в setErrorHandler, где будет обработана ошибка ValidationError.

И пользователь увидит ошибку на своем родном языке 🙂

Оцените статью
devanswers.ru
Добавить комментарий