Configuring webhook
Setup webhook to automatically import Phrase translations back into Uniform.
You would need Phrase integration to be installed and configured.
Webhook has to be publicly available
Phrase integration settings has Webhook URL input where you need to provide publicly avaiable url where Phrase is going to push completed translation job.
You can find a full example of implementation in this examples repo
Setup#
Lets create a webhook that can handle composition or entry translations coming back from Phrase TMS.
First we need Phrase and Uniform credentials:
# Authentication to Phrase # https://cloud.memsource.com or https://us.cloud.memsource.com PHRASE_API_HOST= PHRASE_PROJECT_UID= PHRASE_USER_NAME= PHRASE_USER_PASSWORD= PHRASE_WEBHOOK_SECRET_TOKEN= # Authentication to Uniform UNIFORM_API_KEY=
Uniform API Keys
Here you can find how to create Uniform API Keys. Phrase integration requires at least Create and Update permissions for Compositions / Entries / Assets
We would also need Uniform dependencies
npm i @uniformdev/canvas @uniformdev/tms-sdk @uniformdev/tms-phraseLets validate Phrase webhook
const token = req.headers['x-memsource-token']; if (token !== process.env.PHRASE_WEBHOOK_SECRET_TOKEN) { // eslint-disable-next-line no-console console.log('Secret token is invalid'); res.status(403).json({ message: 'Secret token is invalid' }); return; } const payload = req.body as PhraseWebhookPayload; if (payload.event !== 'JOB_STATUS_CHANGED') { // eslint-disable-next-line no-console console.log(`skip: event !== 'JOB_STATUS_CHANGED'`); res.status(200).json({ updated: false }); return; } const job = payload.jobParts.at(0); if (!job || !shouldProcessJob(job)) { res.status(200).json({ updated: false }); return; } const shouldProcessJob = (job: PhraseWebhookPayload['jobParts'][number]): boolean => { if (job.status !== 'COMPLETED_BY_LINGUIST') { // eslint-disable-next-line no-console console.log('skip: job status !== COMPLETED_BY_LINGUIST'); return false; } const phraseProjectUid = job.project.uid; if (!process.env.PHRASE_PROJECT_UID || phraseProjectUid !== process.env.PHRASE_PROJECT_UID) { if (!process.env.PHRASE_PROJECT_UID) { // eslint-disable-next-line no-console console.log(`skip: missing 'PHRASE_PROJECT_UID' env`); } else { // eslint-disable-next-line no-console console.log( `skip: Phrase project id mismatch (expected: ${process.env.PHRASE_PROJECT_UID}, current: ${phraseProjectUid})` ); } return false; } return true; };Lets retrieve target file from the job
const phraseClient = new PhraseTmsClient({ apiHost: process.env.PHRASE_API_HOST || assert('missing PHRASE_API_HOST'), userName: process.env.PHRASE_USER_NAME || assert('missing PHRASE_USER_NAME'), password: process.env.PHRASE_USER_PASSWORD || assert('missing PHRASE_USER_PASSWORD'), }); const translationPayload = job.uid ? await phraseClient.downloadTargetFile<TranslationPayload>({ projectUid: job.project.uid, jobUid: job.uid, }) : null; if (!translationPayload) { // eslint-disable-next-line no-console console.log('skip: no translation payload'); res.status(200).json({ updated: false }); return; } function assert(msg: string): never { throw new Error(msg); }Now lets merge translation into Uniform entities
const uniformProjectId = translationPayload.metadata.uniformProjectId; const uniformReleaseId = translationPayload.metadata.uniformReleaseId; const uniformEntityType = translationPayload.metadata.entityType; const uniformEntityId = translationPayload.metadata.entity.id; const canvasClient = new CanvasClient({ projectId: uniformProjectId, apiKey: process.env.UNIFORM_API_KEY || assert('missing UNIFORM_API_KEY'), bypassCache: true, }); const contentClient = new ContentClient({ projectId: uniformProjectId, apiKey: process.env.UNIFORM_API_KEY || assert('missing UNIFORM_API_KEY'), bypassCache: true, }); const { translationMerged } = await mergeTranslationToUniform({ canvasClient, contentClient, translationPayload, updateComposition: async ({ canvasClient, composition }) => { // eslint-disable-next-line no-console console.log('update composition: start'); await canvasClient.updateComposition(composition); // eslint-disable-next-line no-console console.log('update composition: done'); return true; }, updateEntry: async ({ contentClient, entry }) => { // eslint-disable-next-line no-console console.log('update entry: start'); await contentClient.upsertEntry(entry); // eslint-disable-next-line no-console console.log('update entry: done'); return true; }, onNotFound: ({ translationPayload }) => { const entityType = translationPayload.metadata.entityType; const entityId = translationPayload.metadata.entity.id; // eslint-disable-next-line no-console console.log(`skip: can not find ${entityType} (${entityId})`); }, onNotTranslatedResult: ({ updated, errorKind, errorText }) => { if (errorKind !== undefined) { // eslint-disable-next-line no-console console.warn(errorText || 'Unknown error'); } else if (!updated) { // eslint-disable-next-line no-console console.log('Translation has no updates'); } }, });If everything goes fine - lets mark Phrase job as delivered:
if (translationMerged) { // eslint-disable-next-line no-console console.log('update job status: start'); await phraseClient.setJobStatus({ projectUid: job.project.uid, jobUid: job.uid, jobStatus: 'DELIVERED', }); // eslint-disable-next-line no-console console.log('update job status: done'); }
Register Webhook in Phrase TMS Dashboard#
- Open Phrase TMS dashdoard
- Go to Settings and click WebhooksPhrase webhook settings location.
- Click Add webhook
- Define webjook's name
- Provide publicly avaiable url where Phrase is going to push job status notifications
- Define webhook's secret token to validate income requests
- Select Job status changed eventCreate Phrase webhook.