diff --git a/CHANGELOG.md b/CHANGELOG.md index 173acbc..6b4eb83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added + +- Languange resolution via Content Negotiation + ## [3.0.6] - 2023-12-14 ### Versioning diff --git a/package-lock.json b/package-lock.json index e622dd5..f2726b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", + "@fastify/accepts": "^4.3.0", "@mia-platform/custom-plugin-lib": "^6.0.0", "ajv": "^8.12.0", "commander": "^11.0.0", @@ -17,6 +18,8 @@ "glob": "^10.3.10", "js-yaml": "^4.1.0", "jsonpath-plus": "^7.2.0", + "lodash.clonedeepwith": "^4.5.0", + "lodash.get": "^4.4.2", "mkdirp": "^3.0.1" }, "bin": { @@ -31,6 +34,8 @@ "@types/chai-as-promised": "^7.1.6", "@types/glob": "^8.1.0", "@types/js-yaml": "^4.0.6", + "@types/lodash.clonedeepwith": "^4.5.9", + "@types/lodash.get": "^4.4.9", "@types/mocha": "^10.0.2", "@types/node": "^20.7.1", "@types/sinon": "^17.0.3", @@ -1156,6 +1161,15 @@ "node": ">=14" } }, + "node_modules/@fastify/accepts": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/accepts/-/accepts-4.3.0.tgz", + "integrity": "sha512-QK4FoqXdwwPmaPOLL6NrxsyaXVvdviYVoS6ltHyOLdFlUyREIaMykHQIp+x0aJz9hB3B3n/Ht6QRdvBeGkptGQ==", + "dependencies": { + "accepts": "^1.3.5", + "fastify-plugin": "^4.0.0" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz", @@ -3390,6 +3404,24 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.clonedeepwith": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.9.tgz", + "integrity": "sha512-bruhfxIJlj36oWYmYQ7KFbylCGgzyIi+TLypub+wcAd29mV4llKdvru8Pp9qwILX//I5vK3FIcJ0VzszElhLuA==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.get": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.get/-/lodash.get-4.4.9.tgz", + "integrity": "sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/memcached": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", @@ -4091,6 +4123,18 @@ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -8498,6 +8542,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "node_modules/lodash.clonedeepwith": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", + "integrity": "sha512-QRBRSxhbtsX1nc0baxSkkK5WlVTTm/s48DSukcGcWZwIyI8Zz+lB+kFiELJXtzfH4Aj6kMWQ1VWW4U5uUDgZMA==" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -8910,6 +8959,14 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", @@ -13370,6 +13427,15 @@ "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==" }, + "@fastify/accepts": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/accepts/-/accepts-4.3.0.tgz", + "integrity": "sha512-QK4FoqXdwwPmaPOLL6NrxsyaXVvdviYVoS6ltHyOLdFlUyREIaMykHQIp+x0aJz9hB3B3n/Ht6QRdvBeGkptGQ==", + "requires": { + "accepts": "^1.3.5", + "fastify-plugin": "^4.0.0" + } + }, "@fastify/ajv-compiler": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz", @@ -15060,6 +15126,24 @@ "@types/lodash": "*" } }, + "@types/lodash.clonedeepwith": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.9.tgz", + "integrity": "sha512-bruhfxIJlj36oWYmYQ7KFbylCGgzyIi+TLypub+wcAd29mV4llKdvru8Pp9qwILX//I5vK3FIcJ0VzszElhLuA==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.get": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.get/-/lodash.get-4.4.9.tgz", + "integrity": "sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/memcached": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", @@ -15534,6 +15618,15 @@ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, "acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -18800,6 +18893,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "lodash.clonedeepwith": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", + "integrity": "sha512-QRBRSxhbtsX1nc0baxSkkK5WlVTTm/s48DSukcGcWZwIyI8Zz+lB+kFiELJXtzfH4Aj6kMWQ1VWW4U5uUDgZMA==" + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -19119,6 +19217,11 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, "next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", diff --git a/package.json b/package.json index e81801b..5ecfc55 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", + "@fastify/accepts": "^4.3.0", "@mia-platform/custom-plugin-lib": "^6.0.0", "ajv": "^8.12.0", "commander": "^11.0.0", @@ -41,6 +42,8 @@ "glob": "^10.3.10", "js-yaml": "^4.1.0", "jsonpath-plus": "^7.2.0", + "lodash.clonedeepwith": "^4.5.0", + "lodash.get": "^4.4.2", "mkdirp": "^3.0.1" }, "devDependencies": { @@ -52,6 +55,8 @@ "@types/chai-as-promised": "^7.1.6", "@types/glob": "^8.1.0", "@types/js-yaml": "^4.0.6", + "@types/lodash.clonedeepwith": "^4.5.9", + "@types/lodash.get": "^4.4.9", "@types/mocha": "^10.0.2", "@types/node": "^20.7.1", "@types/sinon": "^17.0.3", diff --git a/src/config.ts b/src/config.ts index b055983..bcb7d46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from 'fs' +import { existsSync, readFileSync, readdirSync } from 'fs' +import path from 'path' import * as defaults from './defaults' import type { ContentTypeMap } from './schemas' @@ -6,12 +7,40 @@ import type { EnvironmentVariables } from './schemas/environmentVariablesSchema' type HeadersMap = Record<`/${string}`, Record>; +interface LanguageConfig { + labelsMap: Record + languageId: string +} + interface RuntimeConfig extends Required { CONTENT_TYPE_MAP: ContentTypeMap + LANGUAGES_CONFIG: LanguageConfig[] PUBLIC_HEADERS_MAP: HeadersMap USER_PROPERTIES_HEADER_KEY: string | undefined } +const validateLanguages = (languageDirPath: string): LanguageConfig[] => { + if (!existsSync(languageDirPath)) { + return [] + } + + const languageFilenames = readdirSync(languageDirPath) + return languageFilenames.map((filename) => { + const filepath = path.join(languageDirPath, filename) + const fileContent = JSON.parse(readFileSync(filepath).toString()) as unknown + if (!fileContent + || typeof fileContent !== 'object' + || Array.isArray(fileContent)) { + throw new Error(`${filename} is not a valid language configuration`) + } + + return { + labelsMap: fileContent as Record, + languageId: path.basename(filename, '.json'), + } + }) +} + const validateContentTypeMap = (contentTypeMap: unknown) => { if (contentTypeMap === null || typeof contentTypeMap !== 'object') { return defaults.CONTENT_TYPE_MAP @@ -74,7 +103,10 @@ const getPublicHeadersMap = (input: unknown): HeadersMap => { } const parseConfig = (config: EnvironmentVariables & Record): RuntimeConfig => { - const { SERVICE_CONFIG_PATH = defaults.SERVICE_CONFIG_PATH } = config + const { + LANGUAGES_DIRECTORY_PATH = defaults.LANGUAGES_DIRECTORY_PATH, + SERVICE_CONFIG_PATH = defaults.SERVICE_CONFIG_PATH, + } = config let serviceConfig: unknown = defaults.PUBLIC_HEADERS_MAP let configPath: string | undefined @@ -103,6 +135,8 @@ const parseConfig = (config: EnvironmentVariables & Record): Run return { CONTENT_TYPE_MAP: validateContentTypeMap(contentTypeMap), + LANGUAGES_CONFIG: validateLanguages(LANGUAGES_DIRECTORY_PATH), + LANGUAGES_DIRECTORY_PATH, PUBLIC_DIRECTORY_PATH: config.PUBLIC_DIRECTORY_PATH ?? defaults.PUBLIC_DIRECTORY_PATH, PUBLIC_HEADERS_MAP: getPublicHeadersMap(publicHeadersMap), RESOURCES_DIRECTORY_PATH: config.RESOURCES_DIRECTORY_PATH ?? defaults.RESOURCES_DIRECTORY_PATH, @@ -111,5 +145,5 @@ const parseConfig = (config: EnvironmentVariables & Record): Run } } -export type { RuntimeConfig } +export type { LanguageConfig, RuntimeConfig } export { parseConfig } diff --git a/src/defaults.ts b/src/defaults.ts index 33bfb7d..f7ad255 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -4,6 +4,8 @@ const RESOURCES_DIRECTORY_PATH = '/usr/static/configurations' const PUBLIC_DIRECTORY_PATH = '/usr/static/public' +const LANGUAGES_DIRECTORY_PATH = '/usr/static/languages' + const SERVICE_CONFIG_PATH = '/usr/src/app/config/config.json' const PUBLIC_HEADERS_MAP = {} @@ -24,4 +26,5 @@ export { SERVICE_CONFIG_PATH, PUBLIC_DIRECTORY_PATH, PUBLIC_HEADERS_MAP, + LANGUAGES_DIRECTORY_PATH, } diff --git a/src/lib/configurations.ts b/src/lib/configurations.ts index e4a6e7c..bd27436 100644 --- a/src/lib/configurations.ts +++ b/src/lib/configurations.ts @@ -24,16 +24,26 @@ import * as yaml from 'js-yaml' import type { RuntimeConfig } from '../config' import { evaluateAcl, resolveReferences } from '../sdk' import type { Json } from '../sdk' +import { evaluateLanguage } from '../sdk/evaluate-language' +import type { AclContext } from './extract-acl-context' import { extractAclContext } from './extract-acl-context' +import type { LanguageContext } from './extract-language-context' +import { extractLanguageContext } from './extract-language-context' type ExtensionOutput = '' | `.${string}` type Extension = '.json' | '.yml' | '.yaml' -const manipulateJson = async (json: Json, aclGroups: string[], aclPermissions: string[]): Promise => { - const filteredContent = evaluateAcl(json, aclGroups, aclPermissions) - const resolvedJson = await resolveReferences(filteredContent) +interface ConfigurationResponse { + fileBuffer: Buffer + language?: string +} + +const manipulateJson = async (json: Json, aclContext: AclContext, languageContext: LanguageContext): Promise => { + const filteredContent = evaluateAcl(json, aclContext.groups, aclContext.permissions) + const translatedJson = evaluateLanguage(filteredContent, languageContext.labelsMap) + const resolvedJson = await resolveReferences(translatedJson) if (resolvedJson && typeof resolvedJson === 'object' && 'definitions' in resolvedJson) { delete (resolvedJson as { definitions?: unknown }).definitions @@ -42,21 +52,21 @@ const manipulateJson = async (json: Json, aclGroups: string[], aclPermissions: s return resolvedJson } -const asJson = async (buffer: Buffer, ...args: string[][]): Promise => { +const asJson = async (buffer: Buffer, aclContext: AclContext, languageContext: LanguageContext): Promise => { const json = JSON.parse(buffer.toString('utf-8')) as Json - return manipulateJson(json, args[0], args[1]) + return manipulateJson(json, aclContext, languageContext) } -const asYaml = async (buffer: Buffer, ...args: string[][]): Promise => { +const asYaml = async (buffer: Buffer, aclContext: AclContext, languageContext: LanguageContext): Promise => { const json = yaml.load(buffer.toString('utf-8')) as Json - return manipulateJson(json, args[0], args[1]) + return manipulateJson(json, aclContext, languageContext) } const fsCache = new Map>() const fileLoader = async (filepath: string) => fs.promises.readFile(filepath) -const loadAs = (extension: Extension): (buffer: Buffer, ...args: string[][]) => Promise => { +const loadAs = (extension: Extension): (buffer: Buffer, aclContext: AclContext, languageContext: LanguageContext) => Promise => { switch (extension) { case '.json': return asJson @@ -79,23 +89,25 @@ const getDumper = (extension: Extension): (content: Json) => string => { const shouldManipulate = (extension: ExtensionOutput): extension is Extension => ['.json', '.yaml', '.yml'].includes(extension) -async function configurationsHandler(request: FastifyRequest, filename: string, config: RuntimeConfig): Promise { - const fileExtension = path.extname(filename) as ExtensionOutput - const aclContext = extractAclContext(config, request) - +async function configurationsHandler(request: FastifyRequest, filename: string, config: RuntimeConfig): Promise { const bufferPromise = fsCache.get(filename) ?? fileLoader(filename) fsCache.set(filename, bufferPromise) - const buffer = await bufferPromise + const fileExtension = path.extname(filename) as ExtensionOutput if (!shouldManipulate(fileExtension)) { - return buffer + return { fileBuffer: buffer } } + const aclContext = extractAclContext(config, request) + const languageContext = extractLanguageContext(config, request.languages()) const dump = getDumper(fileExtension) - const json = await loadAs(fileExtension)(buffer, aclContext.groups, aclContext.permissions) + const json = await loadAs(fileExtension)(buffer, aclContext, languageContext) - return Buffer.from(dump(json), 'utf-8') + return { + fileBuffer: Buffer.from(dump(json), 'utf-8'), + language: languageContext.chosenLanguage, + } } export { configurationsHandler } diff --git a/src/lib/extract-language-context.ts b/src/lib/extract-language-context.ts new file mode 100644 index 0000000..58cb649 --- /dev/null +++ b/src/lib/extract-language-context.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2022 Mia srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LanguageConfig, RuntimeConfig } from '../config' + +export interface LanguageContext { + chosenLanguage: string + labelsMap?: Record +} + +const noLanguageContext: LanguageContext = { chosenLanguage: '' } + +const extractExactLanguage = (languagesConfig: LanguageConfig[], acceptedLanguages: string[]): LanguageContext | undefined => { + for (const language of acceptedLanguages) { + for (const langConfig of languagesConfig) { + if (langConfig.languageId === language) { + return { + chosenLanguage: language, + labelsMap: langConfig.labelsMap, + } + } + } + } +} + +const extractRelaxedLanguage = (languagesConfig: LanguageConfig[], acceptedLanguages: string[]): LanguageContext | undefined => { + const relaxedAcceptedLanguages = acceptedLanguages.map(language => language.split('-')[0]) + for (const relaxedLanguage of relaxedAcceptedLanguages) { + for (const langConfig of languagesConfig) { + if (langConfig.languageId.split('-')[0] === relaxedLanguage) { + return { + chosenLanguage: relaxedLanguage, + labelsMap: langConfig.labelsMap, + } + } + } + } +} + +export const extractLanguageContext = (config: RuntimeConfig, acceptedLanguages: string[]): LanguageContext => { + const { LANGUAGES_CONFIG: languagesConfig } = config + + return extractExactLanguage(languagesConfig, acceptedLanguages) + ?? extractRelaxedLanguage(languagesConfig, acceptedLanguages) + ?? noLanguageContext +} diff --git a/src/lib/onSendHandler.ts b/src/lib/onSendHandler.ts index 784247e..fc12eb2 100644 --- a/src/lib/onSendHandler.ts +++ b/src/lib/onSendHandler.ts @@ -34,6 +34,7 @@ const isPublic = (url: string): url is `/public${string}` => url.startsWith('/pu const isConfigurations = (url: string): url is `/configurations/${string}` => url.startsWith('/configurations/') +// eslint-disable-next-line max-statements const staticFileHandler = (context: FastifyContext) => async ( request: FastifyRequest, reply: FastifyReply, @@ -73,9 +74,11 @@ const staticFileHandler = (context: FastifyContext) => async ( const headers = phMap[url as `/${string}`] as Record | undefined if (isConfigurations(url) && filename) { - const fileBuffer = await configurationsHandler(request, filename, config) + const { fileBuffer, language } = await configurationsHandler(request, filename, config) // eslint-disable-next-line @typescript-eslint/no-floating-promises reply.header('content-length', fileBuffer.length) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + language && reply.header('content-language', language) buffer = fileBuffer } else if (isPublic(url)) { const fileBuffer = await publicHandler(filename, injectNonce) diff --git a/src/lib/registerHandlers.ts b/src/lib/registerHandlers.ts index bdfb6c5..2b04043 100644 --- a/src/lib/registerHandlers.ts +++ b/src/lib/registerHandlers.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from 'fs' import path from 'path' +import fastifyAcceptsPlugin from '@fastify/accepts' import fastifyStaticPlugin from '@fastify/static' import type { FastifyContext } from '../server' @@ -16,7 +17,7 @@ async function registerPublic(this: FastifyContext) { ) } -function registerConfigurations(this: FastifyContext) { +async function registerConfigurations(this: FastifyContext) { const { config: { RESOURCES_DIRECTORY_PATH }, service } = this service.addRawCustomPlugin('GET', '/configurations/*', async (request, reply) => { const { url } = request @@ -28,6 +29,8 @@ function registerConfigurations(this: FastifyContext) { return reply.callNotFound() }) + + return service.register(fastifyAcceptsPlugin) } export { registerConfigurations, registerPublic } diff --git a/src/lib/test/extract-language-context.test.ts b/src/lib/test/extract-language-context.test.ts new file mode 100644 index 0000000..d57fc32 --- /dev/null +++ b/src/lib/test/extract-language-context.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2022 Mia srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai' + +import type { LanguageConfig, RuntimeConfig } from '../../config' +import type { LanguageContext } from '../extract-language-context' +import { extractLanguageContext } from '../extract-language-context' + +describe('Extract Language Context', () => { + const config: RuntimeConfig = { + CONTENT_TYPE_MAP: {}, + LANGUAGES_CONFIG: [ + { + labelsMap: { hello: 'hello' }, + languageId: 'en-UK', + }, + { + labelsMap: { hello: 'ciao' }, + languageId: 'it-IT', + }, + { + labelsMap: { hello: 'hallo' }, + languageId: 'de', + }, + ], + LANGUAGES_DIRECTORY_PATH: '', + PUBLIC_DIRECTORY_PATH: '', + PUBLIC_HEADERS_MAP: {}, + RESOURCES_DIRECTORY_PATH: '', + SERVICE_CONFIG_PATH: '', + USER_PROPERTIES_HEADER_KEY: '', + } + + interface Test { + acceptedLanguages: string[] + expected: LanguageContext + languagesConfig?: LanguageConfig[] + message: string + } + + const tests: Test[] = [ + { + acceptedLanguages: ['jp-JP', 'it-IT', 'en-UK'], + expected: { + chosenLanguage: 'it-IT', + labelsMap: { hello: 'ciao' }, + }, + message: 'should extract context by exact match (order of acceptedLanguages matters)', + }, + { + acceptedLanguages: ['de-DE', 'it'], + expected: { + chosenLanguage: 'de', + labelsMap: { hello: 'hallo' }, + }, + message: 'should extract context by relaxed match', + }, + { + acceptedLanguages: ['en-US'], + expected: { + chosenLanguage: '', + }, + languagesConfig: [], + message: 'should return no context', + }, + ] + + tests.forEach(({ acceptedLanguages, expected, languagesConfig, message }, index) => { + it(`#${index} - ${message}`, () => { + const testConfig: RuntimeConfig = { + ...config, + LANGUAGES_CONFIG: languagesConfig ?? config.LANGUAGES_CONFIG, + } + + const result = extractLanguageContext(testConfig, acceptedLanguages) + expect(result).to.deep.equal(expected) + }) + }) +}) diff --git a/src/lib/test/serve-files.test.ts b/src/lib/test/serve-files.test.ts index db97eed..2166ee0 100644 --- a/src/lib/test/serve-files.test.ts +++ b/src/lib/test/serve-files.test.ts @@ -25,6 +25,7 @@ import { createSandbox } from 'sinon' import type { EnvironmentVariables } from '../../schemas/environmentVariablesSchema' import * as evaluateAcl from '../../sdk/evaluate-acl' +import * as evaluateLanguage from '../../sdk/evaluate-language' import * as resolveReferences from '../../sdk/resolve-references' import { baseVariables, createConfigFile, createTmpDir, setupFastify } from '../../utils/test-utils' @@ -32,6 +33,7 @@ describe('Serve files', () => { const sandbox = createSandbox() let evaluateAclStub: SinonStub + let evaluateLanguageStub: SinonStub let resolveReferencesStub: SinonStub let random: string @@ -40,6 +42,7 @@ describe('Serve files', () => { before(async () => { evaluateAclStub = sandbox.stub(evaluateAcl, 'evaluateAcl').returns({ evaluate: 'acl' }) + evaluateLanguageStub = sandbox.stub(evaluateLanguage, 'evaluateLanguage').returns({ evaluate: 'language' }) resolveReferencesStub = sandbox.stub(resolveReferences, 'resolveReferences').resolves({ resolve: 'references' }) random = randomUUID() const { name: configurations, cleanup: confCleanup } = await createTmpDir({ @@ -114,28 +117,11 @@ describe('Serve files', () => { expect(evaluateAclStub.calledOnce).to.be.true expect(evaluateAclStub.args[0]).to.deep.equal([{ foo: 'bar' }, ['admin', 'user'], ['users.post.write']]) - expect(resolveReferencesStub.calledOnce).to.be.true - expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'acl' }]) - }) - - it('should serve manipulated .yaml file', async () => { - const { payload, headers } = await fastify.inject({ - headers: { - [baseVariables.GROUPS_HEADER_KEY]: 'admin,user', - [baseVariables.USER_PROPERTIES_HEADER_KEY]: JSON.stringify({ permissions: ['users.post.write'] }), - }, - method: 'GET', - url: '/configurations/config.yaml', - }) - - expect(payload).to.deep.equal(yaml.dump({ resolve: 'references' })) - expect(headers['content-type']).to.equal('text/yaml') - - expect(evaluateAclStub.calledOnce).to.be.true - expect(evaluateAclStub.args[0]).to.deep.equal([{ foo: 'bar' }, ['admin', 'user'], ['users.post.write']]) + expect(evaluateLanguageStub.calledOnce).to.be.true + expect(evaluateLanguageStub.args[0]).to.deep.equal([{ evaluate: 'acl' }, undefined]) expect(resolveReferencesStub.calledOnce).to.be.true - expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'acl' }]) + expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'language' }]) }) it('should serve manipulated .yaml file', async () => { @@ -154,8 +140,11 @@ describe('Serve files', () => { expect(evaluateAclStub.calledOnce).to.be.true expect(evaluateAclStub.args[0]).to.deep.equal([{ foo: 'bar' }, ['admin', 'user'], ['users.post.write']]) + expect(evaluateLanguageStub.calledOnce).to.be.true + expect(evaluateLanguageStub.args[0]).to.deep.equal([{ evaluate: 'acl' }, undefined]) + expect(resolveReferencesStub.calledOnce).to.be.true - expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'acl' }]) + expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'language' }]) }) it('should remove "definitions" key from manipulated file', async () => { @@ -179,8 +168,11 @@ describe('Serve files', () => { expect(evaluateAclStub.calledOnce).to.be.true expect(evaluateAclStub.args[0]).to.deep.equal([{ foo: 'bar' }, ['admin', 'user'], ['users.post.write']]) + expect(evaluateLanguageStub.calledOnce).to.be.true + expect(evaluateLanguageStub.args[0]).to.deep.equal([{ evaluate: 'acl' }, undefined]) + expect(resolveReferencesStub.calledOnce).to.be.true - expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'acl' }]) + expect(resolveReferencesStub.args[0]).to.deep.equal([{ evaluate: 'language' }]) }) it('should serve non-JSON file with proper `Content-Type` headers', async () => { diff --git a/src/schemas/environmentVariablesSchema.ts b/src/schemas/environmentVariablesSchema.ts index e9c065f..3342507 100644 --- a/src/schemas/environmentVariablesSchema.ts +++ b/src/schemas/environmentVariablesSchema.ts @@ -19,6 +19,10 @@ import type { FromSchema } from 'json-schema-to-ts' export const environmentVariablesSchema = { additionalProperties: false, properties: { + LANGUAGES_DIRECTORY_PATH: { + description: 'Absolute path of the directory containing files to be used for translation', + type: 'string', + }, PUBLIC_DIRECTORY_PATH: { description: 'Absolute path of the directory containing static files to be served', type: 'string', diff --git a/src/sdk/evaluate-language.ts b/src/sdk/evaluate-language.ts new file mode 100644 index 0000000..d42bf89 --- /dev/null +++ b/src/sdk/evaluate-language.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Mia srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cloneDeepWith from 'lodash.clonedeepwith' +import lget from 'lodash.get' + +import type { Json } from './types' + +const buildCustomizer = (labelsMap: Record) => { + return (value: unknown) => { + if (typeof value === 'string') { + const translation = lget(labelsMap, value) + return translation + } + } +} + +export const evaluateLanguage = (json: Json, labelsMap: Record | undefined): Json => { + if (labelsMap === undefined) { + return json + } + + const customizer = buildCustomizer(labelsMap) + const clonedJson = cloneDeepWith(json, customizer) as Json + return clonedJson +} diff --git a/src/sdk/test/evaluate-language.test.ts b/src/sdk/test/evaluate-language.test.ts new file mode 100644 index 0000000..3d12810 --- /dev/null +++ b/src/sdk/test/evaluate-language.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2022 Mia srl + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai' + +import { evaluateLanguage } from '../evaluate-language' +import type { Json } from '../types' + +describe('Evaluate Language', () => { + interface Test { + expected: Json + json: Json + labelsMap?: Record + message: string + } + + const tests: Test[] = [ + { + expected: { foo: 'bar' }, + json: { foo: 'bar' }, + message: 'should return input when no labels map', + }, + { + expected: { + array: [0, false, 'translated array value'], + foo: 'bar', + parent: { + key: 'value', + nestedArray: ['first', 'translated nested array value'], + nestedParent: { + anotherNestedText: 'translated nested text', + nestedProp: true, + nestedText: 'translated nested text', + }, + text: 'translated text', + }, + title: 'translated title', + }, + json: { + array: [0, false, 'main.array.value'], + foo: 'bar', + parent: { + key: 'value', + nestedArray: ['first', 'main.parent.array.value'], + nestedParent: { + anotherNestedText: 'main.parent.nested-parent.text', + nestedProp: true, + nestedText: 'main.parent.nested-parent.text', + }, + text: 'main.parent.text', + }, + title: 'main.title', + }, + labelsMap: { + main: { + parent: { + text: 'translated text', + }, + }, + 'main.array.value': 'translated array value', + 'main.parent.array.value': 'translated nested array value', + 'main.parent.nested-parent.text': 'translated nested text', + 'main.title': 'translated title', + }, + message: 'should replace label values', + }, + ] + + tests.forEach(({ expected, json, labelsMap, message }, index) => { + it(`#${index} - ${message}`, () => { + const result = evaluateLanguage(json, labelsMap) + expect(result).to.deep.equal(expected) + }) + }) +}) diff --git a/src/server.ts b/src/server.ts index e4828ad..e4d586f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,7 +36,7 @@ const initFunction: AsyncInitFunction = async servi service.addHook('onSend', staticFileHandler(context)) - registerConfigurations.call(context) + await registerConfigurations.call(context) return registerPublic.call(context) } diff --git a/src/test/config.test.ts b/src/test/config.test.ts index aef1e90..c794c1f 100644 --- a/src/test/config.test.ts +++ b/src/test/config.test.ts @@ -28,6 +28,8 @@ const createEnvVars = (configPath: string): EnvironmentVariables => ({ const defaults = { CONTENT_TYPE_MAP: defaultConfigs.CONTENT_TYPE_MAP, + LANGUAGES_CONFIG: [], + LANGUAGES_DIRECTORY_PATH: '/usr/static/languages', PUBLIC_DIRECTORY_PATH: '/usr/static/public', RESOURCES_DIRECTORY_PATH: '/usr/static/configurations', USER_PROPERTIES_HEADER_KEY: 'miauserproperties',