feat(): Introduce translation module and preliminary application of them (#14189)
* feat(): Translation first steps * feat(): locale middleware * feat(): readonly links * feat(): feature flag * feat(): modules sdk * feat(): translation module re export * start adding workflows * update typings * update typings * test(): Add integration tests * test(): centralize filters preparation * test(): centralize filters preparation * remove unnecessary importy * fix workflows * Define StoreLocale inside Store Module * Link definition to extend Store with supported_locales * store_locale migration * Add supported_locales handling in Store Module * Tests * Accept supported_locales in Store endpoints * Add locales to js-sdk * Include locale list and default locale in Store Detail section * Initialize local namespace in js-sdk * Add locales route * Make code primary key of locale table to facilitate upserts * Add locales routes * Show locale code as is * Add list translations api route * Batch endpoint * Types * New batchTranslationsWorkflow and various updates to existent ones * Edit default locale UI * WIP * Apply translation agnostically * middleware * Apply translation agnostically * fix Apply translation agnostically * apply translations to product list * Add feature flag * fetch translations by batches of 250 max * fix apply * improve and test util * apply to product list * dont manage translations if no locale * normalize locale * potential todo * Protect translations routes with feature flag * Extract normalize locale util to core/utils * Normalize locale on write * Normalize locale for read * Use feature flag to guard translations UI across the board * Avoid throwing incorrectly when locale_code not present in partial updates * move applyTranslations util * remove old tests * fix util tests * fix(): product end points * cleanup * update lock * remove unused var * cleanup * fix apply locale * missing new dep for test utils * Change entity_type, entity_id to reference, reference_id * Remove comment * Avoid registering translations route if ff not enabled * Prevent registering express handler for disabled route via defineFileConfig * Add tests * Add changeset * Update test * fix integration tests, module and internals * Add locale id plus fixed * Allow to pass array of reference_id * fix unit tests * fix link loading * fix store route * fix sales channel test * fix tests --------- Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fea3d4ec49
commit
6dc0b8bed8
112
packages/core/framework/src/http/__tests__/apply-locale.spec.ts
Normal file
112
packages/core/framework/src/http/__tests__/apply-locale.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { MedusaRequest, MedusaResponse } from "../types"
|
||||
import { applyLocale } from "../middlewares/apply-locale"
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
|
||||
describe("applyLocale", () => {
|
||||
let mockRequest: Partial<MedusaRequest>
|
||||
let mockResponse: MedusaResponse
|
||||
let nextFunction: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
query: {},
|
||||
get: jest.fn(),
|
||||
scope: {
|
||||
resolve: jest.fn().mockReturnValue({
|
||||
graph: jest.fn().mockResolvedValue({
|
||||
data: [{ supported_locales: [{ locale_code: "en-US" }] }],
|
||||
}),
|
||||
}),
|
||||
} as unknown as MedusaContainer,
|
||||
}
|
||||
mockResponse = {} as MedusaResponse
|
||||
nextFunction = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should set locale from query parameter", async () => {
|
||||
mockRequest.query = { locale: "en-US" }
|
||||
|
||||
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
|
||||
|
||||
expect(mockRequest.locale).toBe("en-US")
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should set locale from Content-Language header when query param is not present", async () => {
|
||||
mockRequest.query = {}
|
||||
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
|
||||
if (header === "content-language") {
|
||||
return "fr-FR"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
|
||||
|
||||
expect(mockRequest.locale).toBe("fr-FR")
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should prioritize query parameter over Content-Language header", async () => {
|
||||
mockRequest.query = { locale: "de-DE" }
|
||||
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
|
||||
if (header === "content-language") {
|
||||
return "fr-FR"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
|
||||
|
||||
expect(mockRequest.locale).toBe("de-DE")
|
||||
expect(mockRequest.get).not.toHaveBeenCalled()
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not set locale when neither query param nor header is present", async () => {
|
||||
mockRequest.query = {}
|
||||
;(mockRequest.get as jest.Mock).mockReturnValue(undefined)
|
||||
|
||||
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
|
||||
|
||||
expect(mockRequest.locale).toBeUndefined()
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle empty string in query parameter", async () => {
|
||||
mockRequest.query = { locale: "" }
|
||||
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
|
||||
if (header === "content-language") {
|
||||
return "es-ES"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
|
||||
|
||||
// Empty string is falsy, so it should fall back to header
|
||||
expect(mockRequest.locale).toBe("es-ES")
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle various locale formats", async () => {
|
||||
const locales = ["en", "en-US", "zh-Hans-CN", "pt-BR"]
|
||||
|
||||
for (const locale of locales) {
|
||||
mockRequest.query = { locale }
|
||||
mockRequest.locale = undefined
|
||||
|
||||
await applyLocale(
|
||||
mockRequest as MedusaRequest,
|
||||
mockResponse,
|
||||
nextFunction
|
||||
)
|
||||
|
||||
expect(mockRequest.locale).toBe(locale)
|
||||
}
|
||||
})
|
||||
})
|
||||
64
packages/core/framework/src/http/middlewares/apply-locale.ts
Normal file
64
packages/core/framework/src/http/middlewares/apply-locale.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ContainerRegistrationKeys, normalizeLocale } from "@medusajs/utils"
|
||||
import type {
|
||||
MedusaNextFunction,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../types"
|
||||
|
||||
const CONTENT_LANGUAGE_HEADER = "content-language"
|
||||
|
||||
/**
|
||||
* Middleware that resolves the locale for the current request.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Query parameter `?locale=en-US`
|
||||
* 2. Content-Language header
|
||||
*
|
||||
* The resolved locale is set on `req.locale`.
|
||||
*/
|
||||
export async function applyLocale(
|
||||
req: MedusaRequest,
|
||||
_: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) {
|
||||
// 1. Check query parameter
|
||||
const queryLocale = req.query.locale as string | undefined
|
||||
if (queryLocale) {
|
||||
req.locale = normalizeLocale(queryLocale)
|
||||
return next()
|
||||
}
|
||||
|
||||
// 2. Check Content-Language header
|
||||
const headerLocale = req.get(CONTENT_LANGUAGE_HEADER)
|
||||
if (headerLocale) {
|
||||
req.locale = normalizeLocale(headerLocale)
|
||||
return next()
|
||||
}
|
||||
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||
const {
|
||||
data: [store],
|
||||
} = await query.graph(
|
||||
{
|
||||
entity: "store",
|
||||
fields: ["id", "supported_locales"],
|
||||
pagination: {
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
cache: {
|
||||
enable: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (store?.supported_locales?.length) {
|
||||
req.locale = store.supported_locales.find(
|
||||
(locale) => locale.is_default
|
||||
)?.locale_code
|
||||
return next()
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -3,5 +3,6 @@ export * from "./error-handler"
|
||||
export * from "./exception-formatter"
|
||||
export * from "./apply-default-filters"
|
||||
export * from "./apply-params-as-filters"
|
||||
export * from "./apply-locale"
|
||||
export * from "./clear-filters-by-key"
|
||||
export * from "./set-context"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
FeatureFlag,
|
||||
isFileDisabled,
|
||||
parseCorsOrigins,
|
||||
} from "@medusajs/utils"
|
||||
import cors, { CorsOptions } from "cors"
|
||||
import type {
|
||||
ErrorRequestHandler,
|
||||
@@ -20,6 +25,7 @@ import type {
|
||||
} from "./types"
|
||||
|
||||
import { Logger, MedusaContainer } from "@medusajs/types"
|
||||
import { join } from "path"
|
||||
import { configManager } from "../config"
|
||||
import { MiddlewareFileLoader } from "./middleware-file-loader"
|
||||
import { authenticate, AuthType } from "./middlewares"
|
||||
@@ -109,6 +115,38 @@ export class ApiLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a route file is disabled for a given matcher and method
|
||||
* by trying to find the corresponding route file path
|
||||
*/
|
||||
#isRouteFileDisabled(matcher: string): boolean {
|
||||
const routePathSegments = matcher
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
if (segment.startsWith(":")) {
|
||||
return `[${segment.slice(1)}]`
|
||||
}
|
||||
return segment
|
||||
})
|
||||
|
||||
for (const sourceDir of this.#sourceDirs) {
|
||||
for (const ext of [".ts", ".js"]) {
|
||||
const routeFilePath = join(
|
||||
sourceDir,
|
||||
...routePathSegments,
|
||||
`route${ext}`
|
||||
)
|
||||
|
||||
if (isFileDisabled(routeFilePath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a middleware or a route handler with Express
|
||||
*/
|
||||
@@ -145,6 +183,14 @@ export class ApiLoader {
|
||||
? route.methods
|
||||
: [route.methods]
|
||||
methods.forEach((method) => {
|
||||
const isDisabled = this.#isRouteFileDisabled(route.matcher)
|
||||
if (isDisabled) {
|
||||
this.#logger.debug(
|
||||
`skipping disabled route middleware registration for ${method} ${route.matcher}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.#logger.debug(
|
||||
`registering route middleware ${method} ${route.matcher}`
|
||||
)
|
||||
|
||||
@@ -183,6 +183,14 @@ export interface MedusaRequest<
|
||||
* requests that allows for additional_data
|
||||
*/
|
||||
additionalDataValidator?: ZodOptional<ZodNullable<ZodObject<any, any>>>
|
||||
|
||||
/**
|
||||
* The locale for the current request, resolved from:
|
||||
* 1. Query parameter `?locale=`
|
||||
* 2. Content-Language header
|
||||
* 3. Store's default locale
|
||||
*/
|
||||
locale?: string
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IStockLocationService,
|
||||
IStoreModuleService,
|
||||
ITaxModuleService,
|
||||
ITranslationModuleService,
|
||||
IUserModuleService,
|
||||
IWorkflowEngineService,
|
||||
Logger,
|
||||
@@ -34,8 +35,8 @@ import {
|
||||
RemoteQueryFunction,
|
||||
} from "@medusajs/types"
|
||||
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
|
||||
import { Knex } from "../deps/mikro-orm-knex"
|
||||
import { AwilixContainer, ResolveOptions } from "../deps/awilix"
|
||||
import { Knex } from "../deps/mikro-orm-knex"
|
||||
|
||||
declare module "@medusajs/types" {
|
||||
export interface ModuleImplementations {
|
||||
@@ -80,6 +81,7 @@ declare module "@medusajs/types" {
|
||||
[Modules.SETTINGS]: ISettingsModuleService
|
||||
[Modules.CACHING]: ICachingModuleService
|
||||
[Modules.INDEX]: IIndexService
|
||||
[Modules.TRANSLATION]: ITranslationModuleService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user