Lokalise APIv2 in practice

In one of my previous articles I was covering Lokalise APIv2 basics, explaining its key features, and how to get started with it. Today we are going to work a bit more with the API using Node and Ruby SDKs. We are going to cover some real-world scenarios and discuss different code samples. By the end of this article you’ll be able to put your knowledge to work in real projects.

Getting started

As explained in the introductory post, you’ll need to complete the following steps in order to get started with Lokalise API:

  • Register a https://app.lokalise.com/signup.
  • Proceed to your profile by clicking on the avatar in the bottom left corner and choosing Personal profile.
  • Open the API tokens section and generate a new read/write token. Alternatively, you can obtain an API token via our OAuth 2 flow and act on other user’s behalf.

In this article I’ll be using Node and Ruby API clients, so install them by running:

npm install @lokalise/node-api

gem install ruby-lokalise-api

In this tutorial I’ll be using Node 16 and Ruby 3.0.

Of course, we provide official SDKs for other programming languages, namely PHP, Go, Python, and Elixir.

Next, let’s create two folders to host our sample projects: ts and ruby.

You can find the source code for this article at github.com/bodrovis-learning/Lokalise-APIv2-Samples.

Ruby

Here’s the contents of the ruby folder:

  • Gemfile
  • .env
  • src
  • src/main.rb
  • src/i18n
  • src/i18n/en.yaml

Put the following code inside your Gemfile:

source 'https://rubygems.org'

gem 'dotenv', '~> 2.7'
gem 'lokalise_manager', '~> 1.2'

You might be wondering why we’re installing the lokalise_manager gem instead of ruby-lokalise-api. This is because lokalise_manager will automatically hook up Ruby SDK, and we’ll need both solutions, as you’ll see next. Dotenv will be utilized to load environment variables.

Run bundle install to install all the necessary dependencies.

Next, open the .env file and place your API token inside:

API_KEY=123456abc

Now, let’s take care of the main.rb file:

require 'dotenv/load'
require 'lokalise_manager'
require 'ruby-lokalise-api'

dotenv/load will automatically fetch all environment variables from the .env file so you’ll be able to access them easily: ENV['API_TOKEN'].

Finally, here’s the contents of the en.yaml file:

en:
  demo: This is just a demo...
  welcome: Welcome to the app!

Great, our setup for Ruby is finished!

Node

Our ts folder is going to have a very similar structure:

  • src
  • src/api_demo.ts
  • src/i18n
  • src/i18n/en.json
  • .env
  • package.json

First, populate your package.json file:

{
  "name": "Lokalise APIv2 demo",
  "version": "1.0.0",
  "scripts": {
    "start": "node src/api_demo.ts"
  },
  "dependencies": {
    "@lokalise/node-api": "^7.0.0",
    "adm-zip": "^0.5.9",
    "dotenv": "^10.0.0",
    "got": "^11.8.2"
  },
  "devDependencies": {
    "@types/node": "^16.11.6"
  }
}

We are going to install Node SDK as well as adm-zip to unzip downloaded translations, dotenv to load environment variables, and got to send HTTP requests.

Run npm install to install all the necessary dependencies.

Put your API key inside the .env file:

API_KEY=7890zyxf

Now, edit the api_demo.ts file:

require("dotenv").config()

const { LokaliseApi } = require('@lokalise/node-api')
const fs = require('fs')
const path = require('path')
const AdmZip = require("adm-zip")
const got = require('got')

async function main() {
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error)
    process.exit(1)
  })

Finally, provide some sample translations inside the en.json file:

{
  "welcome": "Welcome to the app!",
  "demo": "This is just a demo..."
}

Nice job!

Let’s create a new project

In Ruby

To create a new translation project in Lokalise, paste the following Ruby code to the main.rb file:

require 'dotenv/load'
require 'lokalise_manager'
require 'ruby-lokalise-api'

@client = Lokalise.client ENV['API_KEY']

puts 'Creating project...'

project = @client.create_project name: 'Ruby Sample Project',
                                 description: 'My Ruby project',
                                 languages: [
                                   {
                                     lang_iso: 'en'
                                   },
                                   {
                                     lang_iso: 'fr'
                                   }
                                 ],
                                 base_lang_iso: 'en'

project_id = project.project_id

puts project_id
puts project.name
puts project.description

So,Lokalise.client creates a new API client to send API requests. Please note that if you are using a token obtained via OAuth 2 flow, you’ll have to instantiate the client in a different way:

@client = Lokalise.oauth_client 'YOUR_OAUTH_TOKEN_HERE'

create_project is the method you’ll need to call. It accepts a hash with project parameters: please note that these parameters have names identical to the ones listed in the API docs. In the code sample above we are specifying a project name, description, provide an array of languages (English and French), and set the base project language to English.

The project variable will contain a Ruby object representing this newly created project (we call such objects as “models”). project responds to methods named after the project attributes, for example project_id, name, team_id, settings, and others. In the example above we store project ID in a separate variable as we will need it later.

In TypeScript

Let’s see how to create a new project in TypeScript. Place the following code into your api_demo.ts:

require("dotenv").config()

const { LokaliseApi } = require('@lokalise/node-api')
const fs = require('fs')
const path = require('path')
const AdmZip = require("adm-zip")
const got = require('got')

async function main() {
  const lokaliseApi = new LokaliseApi({ apiKey: process.env.API_KEY })

  console.log("Creating project...")

  const project = await lokaliseApi.projects().create({
    name: "Node.js Sample Project",
    description: "Here's my Node.js project",
    languages: [
      {
          "lang_iso": "en"
      },
      {
          "lang_iso": "fr"
      }
    ],
    "base_lang_iso": "en"
  })

  const projectId = project.project_id
  console.log(projectId)
  console.log(project.name)
}

A few things to note:

  • new LokaliseApi instantiates a new client object. If you are using a token obtained via OAuth 2 flow, the client has to be created in a different way: new LokaliseApiOAuth({ apiKey: 'YOUR_OAUTH_TOKEN' }).
  • process.env.API_KEY fetches an environment variable containing your API token.
  • The projects().create method adds a new Lokalise project. It accepts an object with project attributes, and these attributes are named exactly the same as the ones listed in the API docs. Our project will host two languages: English (set as the base language) and French.
  • The project constant will contain an object representing the newly created project. You can find a full list of project attributes in the API docs.

Invite contributors

Okay, so we have just created a new project, and it’s time to invite some contributors!

In Ruby

To add project contributors, use the create_contributors method. It accepts a project ID and a hash or array of hashes (if you are creating multiple contributors in one go). The hash should contain contributor attributes:

# ...

puts 'Inviting contributors...'

contributors = @client.create_contributors project_id,
                                           email: 'sample_ms_translator@example.com',
                                           fullname: 'Ms. Translator',
                                           languages: [
                                             {
                                               lang_iso: 'en',
                                               is_writable: false
                                             },
                                             {
                                               lang_iso: 'fr',
                                               is_writable: true
                                             }
                                           ]

contributor = contributors.collection.first

puts contributor.fullname
puts contributor.user_id

Key points:

  • We are adding one contributor, therefore the second argument is a hash, not an array of hashes (though you could provide an array with a single hash inside).
  • This contributor will have read-only access to English translations and will be able to read and modify French translations.
  • create_contributors method always returns a collection of objects representing newly added contributors. Collections are usually paginated and respond to methods like total_pages, next_page? (is there a next page available), prev_page (fetch items on the previous page), and some others. To get access to the actual data (that is, our new contributor), you’ll have to use the collection method. It returns an array of models, and to fetch the first contributor we simply say first.
  • Contributor model responds to methods named after the attributes that you can find in the API docs. For example, you can call fullname, email, is_admin, and so on.

In TypeScript

Next let’s achieve the same goal in TypeScript. Use the contributors().create() method and pass an array of objects with contributor attributes:

// ...

console.log("Inviting contributors...")

const contributors = await lokaliseApi.contributors().create(
  [
    {
      email: "translator@example.com",
      fullname: "Mr. Translator",
      is_admin: false,
      is_reviewer: true,
      languages: [
        {
          lang_iso: "en",
          is_writable: false,
        },
        {
          lang_iso: "fr",
          is_writable: true,
        },
      ],
    },
  ],
  { project_id: projectId }
)

console.log(contributors[0].email)
console.log(contributors[0].user_id)

We are adding a new contributor “Mr. Translator” who is going to have full access to French language and read-only access to English. Don’t forget to provide your project ID as the last argument to the create method.

contributors constant will contain an array of newly created contributors (even if you are inviting only one person). Therefore, to grab the first contributor we’re saying [0]. You can find contributor attributes in the API docs.

Uploading translation files

Translation files uploading via API is a slightly more complex task:

  • You have to encode translation files content in base64.
  • The actual uploading process happens in the background, so you’ll have to poll the API to update the process status.
  • Lokalise API has rate limits: you cannot send more than six requests per second.

But, fear not, we’ll overcome these issues in no time!

In Ruby

If you are using Ruby then you’re in luck because all the heavy lifting will be done by the lokalise_manager gem. This solution allows us to exchange translation files between your project and Lokalise TMS easily. On top of that, there’s a dedicated Rails integration. Cool, eh?

All you need to do is configure lokalise_manager:

LokaliseManager::GlobalConfig.config do |c|
  c.api_token = ENV['API_KEY']
  c.locales_path = "#{Dir.getwd}/src/i18n"
end

This gem enables you to set many other options that you can find in the docs (for example, you can choose i18n directory, use other file formats, provide timeouts, and more). Options can also be set on a per-client basis, not globally.

Now perform the actual export:

puts 'Uploading translations...'

exporter = LokaliseManager.exporter project_id: project_id

processes = exporter.export!

processes will contain a collection of queued processes. Each process will take care of uploading a single translation file.

Lokalise_manager will automatically encode your files in base64 format and even take care of the rate limiting: if the limit is hit, an exponential backoff mechanism will be applied.

Now we need to update the process status:

puts 'Uploading translations...'

exporter = LokaliseManager.exporter project_id: project_id

processes = exporter.export!

def uploaded?(process)
  5.times do # try to check the status 5 times
    process = process.reload_data # load new data
    return(true) if process.status == 'finished' # return true is the upload has finished

    sleep 1 # wait for 1 second, adjust this number with regards to the upload size
  end

  false # if all 5 checks failed, return false (probably something is wrong)
end

uploaded? processes.first
  • We are doing five checks with a 1 second delay (if your translation files are large, you’ll probably need to increase this delay).
  • reload_data will update the process status.
  • As long as the processes variable contains a collection, we fetch the first process by saying .first.
  • Once process.status returns finished, we exit from the loop.

In TypeScript

To upload translation files in TypeScript, we have to encode them in base64 format first:

console.log("Uploading translations...")

const i18nFolder = path.resolve(__dirname, 'i18n')

const i18nFile = path.join(i18nFolder, 'en.json')

const data = fs.readFileSync(i18nFile, 'utf8')

const buff = Buffer.from(data, 'utf8')

const base64I18n = buff.toString('base64')

Great, now the base64I18n constant contains properly encoded translations. Next upload this data to Lokalise:

const bgProcess = await lokaliseApi.files().upload(projectId, {
  data: base64I18n,
  filename: "en.json",
  lang_iso: "en",
})

data, filename, and lang_iso are required attributes but the upload method accepts some other options like convert_placeholders, tags, apply_tm, and so on.

Finally, we have to wait until the background process status changes to finished:

console.log("Updating process status...")

await waitUntilUploadingDone(lokaliseApi, bgProcess.process_id, projectId)

console.log("Uploading is done!")

Here we are using a waitUntilUploadingDone function so let’s create it:

async function waitUntilUploadingDone(lokaliseApi, processId, projectId) {
  return await new Promise(resolve => {
    const interval = setInterval(async () => {
      const reloadedProcess = await lokaliseApi.queuedProcesses().get(processId, {
        project_id: projectId,
      })
  
      if (reloadedProcess.status === 'finished') {
        resolve(reloadedProcess.status)
        clearInterval(interval)
      }
    }, 1000)
  })
}

Here we take advantage of the queuedProcesses().get() method to load information about the background process. Once it changes to finished, we resolve the promise and clear the internal. Of course, you can enhance this code further.

Listing translation keys

The next thing I would like to do is assign a new translation task to our contributors: specifically, I would like them to translate English texts into French. However, in order to do that, we firstly have to fetch translation key ids to add to the task. Therefore, let’s perform this step now.

In Ruby

To list translation keys with Ruby SDK, use the following approach:

puts 'Getting translation keys...'

key_ids = @client.keys(project_id).collection.map(&:key_id)

puts key_ids

The keys method returns a collection of keys (it can also accept a hash with options as a second argument), therefore we have to say collection to gain access to the actual data. As long as we are interested only in key ids (please note that each key responds to other methods named after the key attributes), we are calling key_id on each object.

In TypeScript

Now let’s fetch keys using Node SDK:

console.log("Getting created translation keys...")

const keys = await lokaliseApi.keys().list({
  project_id: projectId
})

const keyIds = keys.items.map(function(currentValue) {
  return currentValue.key_id
})

console.log(keyIds)

The keys constant will contain an array of translation keys, and each key has attributes listed in the API docs. In our case we are interested only in the key ids, so we call key_id on each object.

Assigning translation tasks

Once you have translation key ids, it’s time to assign tasks to our contributors.

In Ruby

puts 'Assigning translation task...'

task = @client.create_task project_id,
                           title: 'Translate French',
                           keys: key_ids,
                           languages: [
                             {
                               language_iso: 'fr',
                               users: [contributor.user_id]
                             }
                           ]

puts task.title

The create_task method accepts a project ID and a hash with task attributes. We provide the task title, the key ids that should be added to this task (please note that the keys attribute must contain an array of integers or strings representing ids), and the language to translate into. Each language is represented as a hash, and you must provide an array of user ids to assign to this language. The contributor variable was already defined when we were inviting new contributors to the project, so here we simply fetch the id of this person.

In TypeScript

console.log("Assinging a translation task...")

const task = await lokaliseApi.tasks().create(
  {
    title: "Translate French",
    keys: keyIds, // use ids obtained on the previous step
    languages: [
      {
        language_iso: "fr",
        users: [contributors[0].user_id], // an array of task assignee, we add the previously invited user
      },
    ],
  },
  { project_id: projectId }
)

console.log(task.title)
console.log(task.languages[0].language_iso)

The create method accepts an object with task attributes (title, an array of key ids to add to the task, and an array of languages). Please note that for each language you have to specify its ISO code and an array of user ids. The contributors constant is already defined so we simply get the first user id. Also don’t forget to specify the project id.

The task constant contains an object with the newly assigned task. Please find a full list of task attributes in the API docs.

Downloading translation files

Once translations are completed, you’ll probably want to download them back to your project. Let’s see how to tackle this task.

In Ruby

To download translation files in Ruby, we’ll take advantage of the lokalise_manager gem once again:

puts 'Downloading translations...'

importer = LokaliseManager.importer project_id: '5812150561782cfc34d058.67319047',
                                    import_opts: { filter_langs: ['fr'] }

importer.import!

Lokalise_manager will automatically unpack the downloaded ZIP archive with your translations and paste files into the i18n folder as dictated by the global configuration. Please note that in the example above I’m adding a custom option import_opts to download only the French translations. Please find other available options in the API docs.

In TypeScript

Downloading translations in TypeScript is a more involved task but all in all there’s nothing too complex. First, we are going to send a download request:

console.log("Downloading translations...")

const downloadResponse = await lokaliseApi.files().download(projectId, {
  format: "json",
  original_filenames: true,
  directory_prefix: '',
  filter_langs: ['fr'],
  indentation: '2sp',
})

The downloadResponse constant will contain an object with two properties: project_id and bundle_url. bundle_url contains a URL to the archive with your translation files, so it’s our job to properly download and unpack this archive:

const translationsUrl = downloadResponse.bundle_url
const archive = path.resolve(i18nFolder, 'archive.zip')

await download(translationsUrl, archive)

archive contains a path to download our archive to.

Code the download function:

async function download(translationsUrl, archive) {
  try {
    const response = await got.get(translationsUrl).buffer()
    // Perhaps, you might want to use fs-promises and writeFile instead (await/async version)
    fs.writeFileSync(archive, response)
  } catch (error) {
    console.log(error)
  }
}

Finally, unzip the downloaded archive and optionally remove it afterwards:

const zip = new AdmZip(archive)
zip.extractAllTo(i18nFolder, true)

fs.unlink(archive, (err) => {
  if (err) throw err
})

It’s important to mention that the AdmZip package actually allows you to unpack archives on the fly without downloading them locally. To achieve that, tweak the download function:

async function download(url) {
  return await got.get(url).buffer()
}

Now you can pass this data to AdmZip and perform unpacking as before:

const zip = new AdmZip(archive)
zip.extractAllTo(i18nFolder, true)

As long as the archive won’t be downloaded locally, you don’t need to call the unlink function anymore. Brilliant!

Conclusion

In this article we have learned how to work with Lokalise APIv2 using Ruby and Node SDKs. We have discussed how to create projects, invite contributors, assign tasks, and exchange translation files. Please note that Lokalise API allows you to perform many other actions like adding comments, uploading screenshots, creating snapshots, and so on. Therefore, make sure to check the API docs.

I thank you for staying with me today and until next time!

Related posts

Learn something new every week

Get the latest in localization delivered straight to your inbox.

Read also
Localization made easy. Why wait?
The preferred localization tool of 2000+ companies