diff --git a/packages/core/src/queries/custom-phrase.ts b/packages/core/src/queries/custom-phrase.ts new file mode 100644 index 000000000..8f1c01a6d --- /dev/null +++ b/packages/core/src/queries/custom-phrase.ts @@ -0,0 +1,14 @@ +import { CustomPhrase, CustomPhrases } from '@logto/schemas'; +import { sql } from 'slonik'; + +import { convertToIdentifiers } from '@/database/utils'; +import envSet from '@/env-set'; + +const { table, fields } = convertToIdentifiers(CustomPhrases); + +export const findCustomPhraseByLanguageKey = async (languageKey: string): Promise => + envSet.pool.one(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.languageKey} = ${languageKey} + `); diff --git a/packages/core/src/routes/custom-phrase.test.ts b/packages/core/src/routes/custom-phrase.test.ts new file mode 100644 index 000000000..eb502a036 --- /dev/null +++ b/packages/core/src/routes/custom-phrase.test.ts @@ -0,0 +1,62 @@ +import { CustomPhrase } from '@logto/schemas'; + +import RequestError from '@/errors/RequestError'; +import phraseRoutes from '@/routes/custom-phrase'; +import { createRequester } from '@/utils/test-utils'; + +const mockLanguageKey = 'en-US'; + +const mockCustomPhrases: Record = { + [mockLanguageKey]: { + languageKey: mockLanguageKey, + translation: { + input: { + username: 'Username', + password: 'Password', + email: 'Email', + phone_number: 'Phone number', + confirm_password: 'Confirm password', + }, + }, + }, +}; + +const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => { + const mockCustomPhrase = mockCustomPhrases[languageKey]; + + if (!mockCustomPhrase) { + throw new RequestError({ code: 'entity.not_found', status: 404 }); + } + + return mockCustomPhrase; +}); + +jest.mock('@/queries/custom-phrase', () => ({ + findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key), +})); + +describe('customPhraseRoutes', () => { + const phraseRequest = createRequester({ authedRoutes: phraseRoutes }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /custom-phrases/:languageKey', () => { + it('should call findCustomPhraseByLanguageKey once', async () => { + await phraseRequest.get(`/custom-phrases/${mockLanguageKey}`); + expect(findCustomPhraseByLanguageKey).toBeCalledTimes(1); + }); + + it('should return the specified custom phrase existing in the database', async () => { + const response = await phraseRequest.get(`/custom-phrases/${mockLanguageKey}`); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]); + }); + + it('should return 404 status code when there is no specified custom phrase in the database', async () => { + const response = await phraseRequest.get('/custom-phrases/en-UK'); + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/packages/core/src/routes/custom-phrase.ts b/packages/core/src/routes/custom-phrase.ts new file mode 100644 index 000000000..41bb34e42 --- /dev/null +++ b/packages/core/src/routes/custom-phrase.ts @@ -0,0 +1,26 @@ +import { CustomPhrases } from '@logto/schemas'; + +import koaGuard from '@/middleware/koa-guard'; +import { findCustomPhraseByLanguageKey } from '@/queries/custom-phrase'; + +import { AuthedRouter } from './types'; + +export default function phraseRoutes(router: T) { + router.get( + '/custom-phrases/:languageKey', + koaGuard({ + // Next up: guard languageKey by enum LanguageKey (that will be provided by @sijie later.) + params: CustomPhrases.createGuard.pick({ languageKey: true }), + response: CustomPhrases.guard, + }), + async (ctx, next) => { + const { + params: { languageKey }, + } = ctx.guard; + + ctx.body = await findCustomPhraseByLanguageKey(languageKey); + + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 997755810..1c535804f 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -10,6 +10,7 @@ import adminUserRoutes from '@/routes/admin-user'; import applicationRoutes from '@/routes/application'; import authnRoutes from '@/routes/authn'; import connectorRoutes from '@/routes/connector'; +import customPhraseRoutes from '@/routes/custom-phrase'; import dashboardRoutes from '@/routes/dashboard'; import logRoutes from '@/routes/log'; import resourceRoutes from '@/routes/resource'; @@ -39,6 +40,7 @@ const createRouters = (provider: Provider) => { logRoutes(managementRouter); roleRoutes(managementRouter); dashboardRoutes(managementRouter); + customPhraseRoutes(managementRouter); const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, provider); diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 78b1fdda7..de3414ac5 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -155,3 +155,15 @@ export const adminConsoleConfigGuard = z.object({ }); export type AdminConsoleConfig = z.infer; + +/** + * Phrases + */ + +export type Translation = { + [key: string]: string | Translation; +}; + +export const translationGuard: z.ZodType = z.lazy(() => + z.record(z.string().or(translationGuard)) +); diff --git a/packages/schemas/tables/custom_phrases.sql b/packages/schemas/tables/custom_phrases.sql new file mode 100644 index 000000000..7104721d3 --- /dev/null +++ b/packages/schemas/tables/custom_phrases.sql @@ -0,0 +1,5 @@ +create table custom_phrases ( + language_key varchar(16) not null, + translation jsonb /* @use Translation */ not null, + primary key(language_key) +);