Prechádzať zdrojové kódy

feat(server): Add i18n support

Michael Bromley 7 rokov pred
rodič
commit
7c01e9140a

+ 10 - 0
README.md

@@ -36,6 +36,16 @@ Vendure uses [TypeORM](http://typeorm.io), so it compatible will any database wh
 * `yarn start`
 * Go to http://localhost:4200 and log in with "admin@test.com", "test"
 
+## User Guide
+
+### Localization
+
+Vendure server will detect the most suitable locale based on the `Accept-Language` header of the client.
+This can be overridden by appending a `lang` query parameter to the url (e.g. `http://localhost:3000/api?lang=de`). 
+
+All locales in Vendure are represented by 2-character [ISO 639-1 language codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
+
+
 ## License
 
 MIT

+ 5 - 0
server/package.json

@@ -41,6 +41,9 @@
     "body-parser": "^1.18.3",
     "graphql": "^0.13.2",
     "graphql-tools": "^3.0.2",
+    "i18next": "^11.3.3",
+    "i18next-express-middleware": "^1.2.0",
+    "i18next-node-fs-backend": "^1.0.0",
     "jsonwebtoken": "^8.2.2",
     "mysql": "^2.15.0",
     "passport": "^0.4.0",
@@ -54,6 +57,8 @@
     "@types/bcrypt": "^2.0.0",
     "@types/express": "^4.0.39",
     "@types/faker": "^4.1.2",
+    "@types/i18next": "^8.4.3",
+    "@types/i18next-express-middleware": "^0.0.33",
     "@types/jest": "^21.1.8",
     "@types/jsonwebtoken": "^7.2.7",
     "@types/node": "^9.3.0",

+ 15 - 2
server/src/app.module.ts

@@ -12,6 +12,7 @@ import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
 import { getConfig } from './config/vendure-config';
+import { I18nRequest, I18nService } from './i18n/i18n.service';
 import { TranslationUpdaterService } from './locale/translation-updater.service';
 import { AdministratorService } from './service/administrator.service';
 import { ConfigService } from './service/config.service';
@@ -33,6 +34,7 @@ const connectionOptions = getConfig().dbConnectionOptions;
         AuthService,
         ConfigService,
         JwtStrategy,
+        I18nService,
         IdCodecService,
         PasswordService,
         CustomerService,
@@ -48,7 +50,11 @@ const connectionOptions = getConfig().dbConnectionOptions;
     ],
 })
 export class AppModule implements NestModule {
-    constructor(private readonly graphQLFactory: GraphQLFactory, private configService: ConfigService) {}
+    constructor(
+        private readonly graphQLFactory: GraphQLFactory,
+        private configService: ConfigService,
+        private i18nService: I18nService,
+    ) {}
 
     configure(consumer: MiddlewareConsumer) {
         const schema = this.createSchema();
@@ -61,7 +67,14 @@ export class AppModule implements NestModule {
                 }),
             )
             .forRoutes('/graphiql')
-            .apply(graphqlExpress(req => ({ schema, rootValue: req })))
+            .apply([
+                this.i18nService.handle(),
+                graphqlExpress(req => ({
+                    schema,
+                    context: req,
+                    formatError: this.i18nService.translateError(req),
+                })),
+            ])
             .forRoutes(this.configService.apiPath);
     }
 

+ 17 - 0
server/src/i18n/i18n-error.ts

@@ -0,0 +1,17 @@
+/**
+ * All errors thrown in the Vendure server must use this error class. This allows the
+ * error message to be translated before being served to the client.
+ *
+ * The message should be of the form `Could not find user {{ id }}`, with the variables argument
+ * being used to provide interpolation values.
+ *
+ * @example
+ * ```
+ * throw new I18nError(`Could not find user {{ id }}`, { id });
+ * ```
+ */
+export class I18nError extends Error {
+    constructor(public message: string, public variables: { [key: string]: string } = {}) {
+        super(message);
+    }
+}

+ 58 - 0
server/src/i18n/i18n.service.ts

@@ -0,0 +1,58 @@
+import { Injectable } from '@nestjs/common';
+import { Handler, Request } from 'express';
+import { GraphQLError } from 'graphql-request/dist/src/types';
+import * as i18next from 'i18next';
+import { TranslationFunction } from 'i18next';
+import * as i18nextMiddleware from 'i18next-express-middleware';
+import * as Backend from 'i18next-node-fs-backend';
+import * as path from 'path';
+import { I18nError } from './i18n-error';
+
+export interface I18nRequest extends Request {
+    t: TranslationFunction;
+}
+
+export interface WrappedGraphQLError extends GraphQLError {
+    originalError: Error;
+}
+
+/**
+ * This service is responsible for translating messages from the server before they reach the client.
+ * The `i18next-express-middleware` middleware detects the client's preferred language based on
+ * the `Accept-Language` header or "lang" query param and adds language-specific translation
+ * functions to the Express request / response objects.
+ */
+@Injectable()
+export class I18nService {
+    constructor() {
+        i18next
+            .use(i18nextMiddleware.LanguageDetector)
+            .use(Backend)
+            .init({
+                preload: ['en', 'de'],
+                detection: {
+                    lookupQuerystring: 'lang',
+                },
+                backend: {
+                    loadPath: path.join(__dirname, 'translations/{{lng}}.json'),
+                    jsonIndent: 2,
+                },
+            });
+    }
+
+    handle(): Handler {
+        return i18nextMiddleware.handle(i18next);
+    }
+
+    translateError(req?: any) {
+        return (error: WrappedGraphQLError) => {
+            const originalError = error.originalError;
+            if (req && req.t && originalError instanceof I18nError) {
+                const t: TranslationFunction = req.t;
+                error.message = t(originalError.message, originalError.variables);
+            }
+
+            return error;
+        };
+    }
+}

+ 3 - 0
server/src/i18n/translations/en.json

@@ -0,0 +1,3 @@
+{
+  "No customer with the id {{ customerId }} was found": "No customer with the id {{ customerId }} was found"
+}

+ 4 - 6
server/src/locale/translate-entity.ts

@@ -1,3 +1,4 @@
+import { I18nError } from '../i18n/i18n-error';
 import { LanguageCode } from './language-code';
 import { Translatable } from './locale-types';
 
@@ -26,8 +27,6 @@ export type NestedTranslatableRelationKeys<T> = NestedTranslatableRelations<T>[k
 
 export type DeepTranslatableRelations<T> = Array<TranslatableRelationsKeys<T> | NestedTranslatableRelationKeys<T>>;
 
-export class NotTranslatedError extends Error {}
-
 /**
  * Converts a Translatable entity into the public-facing entity by unwrapping
  * the translated strings from the matching Translation entity.
@@ -37,10 +36,9 @@ export function translateEntity<T extends Translatable>(translatable: T, languag
         translatable.translations && translatable.translations.find(t => t.languageCode === languageCode);
 
     if (!translation) {
-        throw new NotTranslatedError(
-            `Translatable entity "${
-                translatable.constructor.name
-            }" has not been translated into the requested language (${languageCode})`,
+        throw new I18nError(
+            `Translatable entity '{{ entityName }}' has not been translated into the requested language ({{ languageCode }})`,
+            { entityName: translatable.constructor.name, languageCode },
         );
     }
 

+ 2 - 1
server/src/service/customer.service.ts

@@ -9,6 +9,7 @@ import { Address } from '../entity/address/address.entity';
 import { CreateCustomerDto } from '../entity/customer/customer.dto';
 import { Customer } from '../entity/customer/customer.entity';
 import { User } from '../entity/user/user.entity';
+import { I18nError } from '../i18n/i18n-error';
 
 @Injectable()
 export class CustomerService {
@@ -55,7 +56,7 @@ export class CustomerService {
         const customer = await this.connection.manager.findOne(Customer, customerId, { relations: ['addresses'] });
 
         if (!customer) {
-            throw new Error(`No customer with the id "${customerId}" was found`);
+            throw new I18nError(`No customer with the id {{ customerId }} was found`, { customerId });
         }
 
         const address = new Address(createAddressDto);

+ 60 - 0
server/yarn.lock

@@ -88,6 +88,17 @@
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/@types/faker/-/faker-4.1.2.tgz#f8ab50c9f9af68c160dd71b63f83e24b712d0df5"
 
+"@types/i18next-express-middleware@^0.0.33":
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/@types/i18next-express-middleware/-/i18next-express-middleware-0.0.33.tgz#1c5625f123eaae126de3b43626ef9a04bc6ad482"
+  dependencies:
+    "@types/express" "*"
+    "@types/i18next" "*"
+
+"@types/i18next@*", "@types/i18next@^8.4.3":
+  version "8.4.3"
+  resolved "https://registry.yarnpkg.com/@types/i18next/-/i18next-8.4.3.tgz#9136a9551bf5bf7169aa9f3125c1743f1f8dd6de"
+
 "@types/jest@^21.1.8":
   version "21.1.8"
   resolved "https://registry.yarnpkg.com/@types/jest/-/jest-21.1.8.tgz#d497213725684f1e5a37900b17a47c9c018f1a97"
@@ -336,6 +347,12 @@ are-we-there-yet@~1.1.2:
     delegates "^1.0.0"
     readable-stream "^2.0.6"
 
+argparse@^1.0.2:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+  dependencies:
+    sprintf-js "~1.0.2"
+
 argparse@^1.0.7:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86"
@@ -1203,6 +1220,13 @@ cookiejar@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a"
 
+cookies@0.7.1:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.1.tgz#7c8a615f5481c61ab9f16c833731bcb8f663b99b"
+  dependencies:
+    depd "~1.1.1"
+    keygrip "~1.0.2"
+
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
@@ -1582,6 +1606,10 @@ escodegen@^1.6.1:
   optionalDependencies:
     source-map "~0.5.6"
 
+esprima@^2.6.0:
+  version "2.7.3"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
+
 esprima@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
@@ -2438,6 +2466,23 @@ husky@^0.14.3:
     normalize-path "^1.0.0"
     strip-indent "^2.0.0"
 
+i18next-express-middleware@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.2.0.tgz#1622e79110806ea6132de30476394d56b2812fc7"
+  dependencies:
+    cookies "0.7.1"
+
+i18next-node-fs-backend@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-1.0.0.tgz#f5a625a3b287c1d098c7171b7dd376bb07299b59"
+  dependencies:
+    js-yaml "3.5.4"
+    json5 "0.5.0"
+
+i18next@^11.3.3:
+  version "11.3.3"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-11.3.3.tgz#a6ca3c2a93237c94e242bda7df3411588ac37ea1"
+
 iconv-lite@0.4.19:
   version "0.4.19"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
@@ -3106,6 +3151,13 @@ js-tokens@^3.0.0, js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
 
+js-yaml@3.5.4:
+  version "3.5.4"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.5.4.tgz#f64f16dcd78beb9ce8361068e733ebe47b079179"
+  dependencies:
+    argparse "^1.0.2"
+    esprima "^2.6.0"
+
 js-yaml@^3.11.0:
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
@@ -3185,6 +3237,10 @@ json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
 
+json5@0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.0.tgz#9b20715b026cbe3778fd769edccd822d8332a5b2"
+
 json5@^0.5.1:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
@@ -3242,6 +3298,10 @@ jws@^3.1.5:
     jwa "^1.1.5"
     safe-buffer "^5.0.1"
 
+keygrip@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.0.2.tgz#ad3297c557069dea8bcfe7a4fa491b75c5ddeb91"
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"