Skip to content

Commit

Permalink
feat(registry): templates/localized API (#610)
Browse files Browse the repository at this point in the history
* refactor(registry): move updateTemplate logic into the repo

* feat(registry): add routes to manage localized templates

 - `PUT /api/v1/templates/{name}/localizedVersions/{locale}` body: `{ content: string }`
 - `DELETE /api/v1/templates/{name}/localizedVersions/{locale}`

* fix(regitsry/tests): remove `.only`

* refactor(registry): move createTemplate logic to the repository

* fix(registry): CR

* fix(registry): CR - remove ts-exhaustive-check

* fix(registry): CR - do an explicit assert on `req.user` instead of TS non-null assertion operator (`!`)

* fix(registry): CR - remove the assert over req.user, because it is allowed to be undefined (in the tests); add missing expects in the tests

* fix: remove .only

* fix(registry): CR - implement unsupported locales validation MW as discussed

* fix: increase tests coverage; ignore by-design unreachable code

* chore: formatting

* fix(registry): CR - throw custom error

---------

Co-authored-by: Mark Harnyk <[email protected]>
  • Loading branch information
harnyk and harnyk authored Oct 2, 2024
1 parent 99024c9 commit d2794c3
Show file tree
Hide file tree
Showing 14 changed files with 523 additions and 116 deletions.
2 changes: 1 addition & 1 deletion registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,4 @@
"node": "^20.8.0"
},
"engineStrict": true
}
}
46 changes: 46 additions & 0 deletions registry/server/middleware/validatelocales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Request, Response } from 'express';
import Joi from 'joi';

import { SettingKeys } from '../settings/interfaces';
import settingsService from '../settings/services/SettingsService';
import { getJoiErr, joiErrorToResponse } from '../util/helpers';

export const validateLocalesMiddleware =
(
localesFromRequest: (req: Request) => string[],
unsupportedLocalesToFieldName: (unsupportedLocales: string[]) => string,
) =>
async (req: Request, res: Response, next: () => void) => {
const locales = localesFromRequest(req);

if (locales.length === 0) {
next();
return;
}

const unsupportedLocales = await getUnsupportedLocales(locales);
if (unsupportedLocales.length > 0) {
res.status(422).send(
joiErrorToResponse(
unsupportedLocalesToJoiError(unsupportedLocales, unsupportedLocalesToFieldName(unsupportedLocales)),
),
);
return;
}

next();
};

async function getUnsupportedLocales(locales: string[]): Promise<string[]> {
const supportedLocales = await settingsService.get(SettingKeys.I18nSupportedLocales);
return locales.filter((l) => !supportedLocales.includes(l));
}

function unsupportedLocalesToJoiError(unsupportedLocales: string[], field: string): Joi.ValidationError {
return getJoiErr(
`localizedVersions.${unsupportedLocales[0]}`,
`Next locales are not supported ${unsupportedLocales.join(',')}. Either change request or change ${
SettingKeys.I18nSupportedLocales
} setting.`,
);
}
4 changes: 4 additions & 0 deletions registry/server/templates/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ import { extendError } from '../util/extendError';
export default {
FetchIncludeError: extendError('FetchIncludeError', { defaultData: {} }),
InvalidTemplateError: extendError('InvalidTemplateError', { defaultData: {} }),
TemplateNotFoundError: extendError('TemplateNotFoundError', {
defaultMessage: 'Template not found',
defaultData: {},
}),
};
16 changes: 15 additions & 1 deletion registry/server/templates/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ export default interface Template {
content: string;
}

export interface LocalizedTemplate {
export interface LocalizedTemplateRow {
templateName: string;
content: string;
locale: string;
}

export interface LocalizedVersion {
content: string;
}

export interface TemplateWithLocalizedVersions extends Template {
localizedVersions: Record<string, LocalizedVersion>;
}

export type UpdateTemplatePayload = Omit<Template, 'name' | 'localizedVersions'> &
Partial<Pick<TemplateWithLocalizedVersions, 'localizedVersions'>>;

export type CreateTemplatePayload = Omit<Template, 'localizedVersions'> &
Partial<Pick<TemplateWithLocalizedVersions, 'localizedVersions'>>;
51 changes: 10 additions & 41 deletions registry/server/templates/routes/createTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Request, Response } from 'express';

import db from '../../db';
import validateRequestFactory from '../../common/services/validateRequest';
import Template, { LocalizedTemplate } from '../interfaces';
import { Tables } from '../../db/structure';
import { templatesRepository } from '../services/templatesRepository';
import { templateSchema, validateLocalesAreSupported } from './validation';
import { templateSchema } from './validation';
import { validateLocalesMiddleware } from '../../middleware/validatelocales';

const validateRequestBeforeCreateTemplate = validateRequestFactory([
{
Expand All @@ -14,47 +12,18 @@ const validateRequestBeforeCreateTemplate = validateRequestFactory([
},
]);

const validateLocale = validateLocalesMiddleware(
(req) => Object.keys(req.body.localizedVersions ?? {}),
(unsupportedLocales) => `body.localizedVersions.${unsupportedLocales[0]}`,
);

const createTemplate = async (req: Request, res: Response): Promise<void> => {
try {
const request = req.body;
const template: Template = {
name: request.name,
content: request.content,
};

const locales = Object.keys(request.localizedVersions || {});
let localesAreValid = await validateLocalesAreSupported(locales, res);
if (!localesAreValid) {
return;
}

await db.versioning(req.user, { type: 'templates', id: template.name }, async (trx) => {
await db('templates').insert(template).transacting(trx);
});

if (locales.length > 0) {
await insertLocalizedVersions(locales, template, request);
}

const savedTemplate = await templatesRepository.readTemplateWithAllVersions(template.name);
res.status(200).send(savedTemplate);
const result = await templatesRepository.createTemplate(req.body, req.user);
res.status(200).send(result);
} catch (e) {
res.status(500).send(JSON.stringify(e));
}
};

function insertLocalizedVersions(locales: string[], template: Template, request: Record<string, any>) {
return Promise.all(
locales.map((locale) => {
const localizedTemplate: LocalizedTemplate = {
templateName: template.name,
content: request.localizedVersions[locale].content,
locale: locale,
};

return db(Tables.TemplatesLocalized).insert(localizedTemplate);
}),
);
}

export default [validateRequestBeforeCreateTemplate, createTemplate];
export default [validateRequestBeforeCreateTemplate, validateLocale, createTemplate];
44 changes: 44 additions & 0 deletions registry/server/templates/routes/deleteTemplateLocalizedVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Request, Response } from 'express';
import Joi from 'joi';

import validateRequestFactory from '../../common/services/validateRequest';
import { exhaustiveCheck } from '../../util/exhaustiveCheck';
import { templatesRepository } from '../services/templatesRepository';
import { localeNameSchema, templateNameSchema } from './validation';

const validateRequestBeforeDeleteTemplateLocalizedVersion = validateRequestFactory([
{
schema: Joi.object({
name: templateNameSchema.required(),
locale: localeNameSchema.required(),
}),
selector: 'params',
},
]);

type Params = {
name: string;
locale: string;
};

const deleteTemplateLocalizedVersion = async (req: Request<Params>, res: Response): Promise<void> => {
const { name: templateName, locale } = req.params;

const result = await templatesRepository.deleteLocalizedVersion(templateName, locale);
switch (result.type) {
case 'notFound': {
res.status(404).send('Not found');
return;
}
case 'ok': {
res.status(204).send();
return;
}
default: {
/* istanbul ignore next */
exhaustiveCheck(result);
}
}
};

export default [validateRequestBeforeDeleteTemplateLocalizedVersion, deleteTemplateLocalizedVersion];
4 changes: 2 additions & 2 deletions registry/server/templates/routes/getRenderedTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Joi from 'joi';

import noticeError from '../../errorHandler/noticeError';
import db from '../../db';
import Template, { LocalizedTemplate } from '../interfaces';
import Template, { LocalizedTemplateRow } from '../interfaces';
import validateRequestFactory from '../../common/services/validateRequest';
import renderTemplate from '../services/renderTemplate';
import errors from '../errors';
Expand Down Expand Up @@ -89,7 +89,7 @@ async function getRenderedTemplate(req: Request<GetTemplateRenderedRequestParams
if (locale) {
const [localizedTemplate] = await db
.select()
.from<LocalizedTemplate>(Tables.TemplatesLocalized)
.from<LocalizedTemplateRow>(Tables.TemplatesLocalized)
.where('templateName', templateName)
.andWhere('locale', locale as string);

Expand Down
4 changes: 4 additions & 0 deletions registry/server/templates/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import getTemplates from './getTemplates';
import updateTemplate from './updateTemplate';
import createTemplate from './createTemplate';
import deleteTemplate from './deleteTemplate';
import upsertTemplateLocalizedVersion from './upsertTemplateLocalizedVersion';
import deleteTemplateLocalizedVersion from './deleteTemplateLocalizedVersion';

export default (authMw: RequestHandler[]) => {
const templatesRouter = express.Router();
Expand All @@ -16,6 +18,8 @@ export default (authMw: RequestHandler[]) => {
templatesRouter.get('/:name', ...getTemplate);
templatesRouter.put('/:name', authMw, ...updateTemplate);
templatesRouter.delete('/:name', authMw, ...deleteTemplate);
templatesRouter.put('/:name/localized/:locale', authMw, ...upsertTemplateLocalizedVersion);
templatesRouter.delete('/:name/localized/:locale', authMw, ...deleteTemplateLocalizedVersion);

return templatesRouter;
};
52 changes: 24 additions & 28 deletions registry/server/templates/routes/updateTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Request, Response } from 'express';
import Joi from 'joi';

import db from '../../db';
import validateRequestFactory from '../../common/services/validateRequest';
import { validateLocalesMiddleware } from '../../middleware/validatelocales';
import { exhaustiveCheck } from '../../util/exhaustiveCheck';
import { templatesRepository } from '../services/templatesRepository';
import { partialTemplateSchema, templateNameSchema, validateLocalesAreSupported } from './validation';
import { partialTemplateSchema, templateNameSchema } from './validation';

type UpdateTemplateRequestParams = {
name: string;
Expand All @@ -23,33 +24,28 @@ const validateRequestBeforeUpdateTemplate = validateRequestFactory([
},
]);

const updateTemplate = async (req: Request<UpdateTemplateRequestParams>, res: Response): Promise<void> => {
const template = {
content: req.body.content,
};
const templateName = req.params.name;

const templatesToUpdate = await db('templates').where({
name: templateName,
});
if (!templatesToUpdate.length) {
res.status(404).send('Not found');
return;
}
const validateLocale = validateLocalesMiddleware(
(req) => Object.keys(req.body.localizedVersions ?? {}),
(unsupportedLocales) => `body.localizedVersions.${unsupportedLocales[0]}`,
);

const localizedVersions = req.body.localizedVersions || {};
const localesAreValid = await validateLocalesAreSupported(Object.keys(localizedVersions), res);
if (!localesAreValid) {
return;
const updateTemplate = async (req: Request<UpdateTemplateRequestParams>, res: Response): Promise<void> => {
const result = await templatesRepository.updateTemplate(req.params.name, req.body, req.user);

switch (result.type) {
case 'notFound': {
res.status(404).send('Not found');
return;
}
case 'ok': {
res.status(200).send(result.template);
return;
}
default: {
/* istanbul ignore next */
exhaustiveCheck(result);
}
}

await db.versioning(req.user, { type: 'templates', id: templateName }, async (trx) => {
await db('templates').where({ name: templateName }).update(template).transacting(trx);
await templatesRepository.upsertLocalizedVersions(templateName, localizedVersions, trx);
});

const updatedTemplate = await templatesRepository.readTemplateWithAllVersions(templateName);
res.status(200).send(updatedTemplate);
};

export default [validateRequestBeforeUpdateTemplate, updateTemplate];
export default [validateRequestBeforeUpdateTemplate, validateLocale, updateTemplate];
59 changes: 59 additions & 0 deletions registry/server/templates/routes/upsertTemplateLocalizedVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Request, Response } from 'express';
import Joi from 'joi';

import validateRequestFactory from '../../common/services/validateRequest';
import { validateLocalesMiddleware } from '../../middleware/validatelocales';
import { exhaustiveCheck } from '../../util/exhaustiveCheck';
import { LocalizedVersion } from '../interfaces';
import { templatesRepository } from '../services/templatesRepository';
import { localeNameSchema, localizedVersionSchema, templateNameSchema } from './validation';

const validateRequestBeforeUpsertLocalizedVersion = validateRequestFactory([
{
schema: Joi.object({
name: templateNameSchema.required(),
locale: localeNameSchema.required(),
}),
selector: 'params',
},
{
schema: localizedVersionSchema.required(),
selector: 'body',
},
]);

const validateLocale = validateLocalesMiddleware(
(req) => [req.params.locale],
() => 'params.locale',
);

type Params = {
name: string;
locale: string;
};

const upsertTemplateLocalizedVersion = async (
req: Request<Params, any, LocalizedVersion>,
res: Response,
): Promise<void> => {
const { name: templateName, locale } = req.params;
const localizedVersion = req.body;

const result = await templatesRepository.upsertLocalizedVersion(templateName, locale, localizedVersion);
switch (result.type) {
case 'notFound': {
res.status(404).send('Not found');
return;
}
case 'ok': {
res.status(200).send(result.localizedVersion);
return;
}
default: {
/* istanbul ignore next */
exhaustiveCheck(result);
}
}
};

export default [validateRequestBeforeUpsertLocalizedVersion, validateLocale, upsertTemplateLocalizedVersion];
Loading

0 comments on commit d2794c3

Please sign in to comment.