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.landing.test.lokalise.cloud/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 an array of objects. These object respond to the following methods:

    • success — returns true or false. If true, the uploading was successfully scheduled on Lokalise.
    • path — full path to the file being uploaded (instance of the Pathname class). Please note that each process will take care of uploading a single translation file.
    • process — an object representing the actual queued process.

    LokaliseManager will automatically encode your files in base64 format, upload them in parallel (up to 6 concurrent threads), 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
    
    puts "Checking status for the #{processes.first.path} file..." uploaded? processes.first.process
    • 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.

    Please note that by default if something goes wrong during the exporting process, the script will raise an exception and exit. However, you can set the raise_on_export_fail option to false, and in this case even if one or more files cannot be uploaded, LokaliseManager will still try to process other files. In this case you’ll have to make sure that success is true:

    if processes.first.success
      puts "Checking status for the #{processes.first.path} file..."
      uploaded? processes.first.process
    end

    Of course, you can manually check each process:

    processes = exporter.export!
    
    processes.each do |proc_data|
      if proc_data.success
        # Everything is good, the uploading is queued
        puts "#{proc_data.path} is sent to Lokalise!"
        process = proc_data.process
        puts "Current process status is #{process.status}"
      else
        # Something bad has happened
        puts "Could not send #{proc_data.path} to Lokalise"
        puts "Error #{proc_data.error.class}: #{proc_data.error.message}"
        # Or you could re-raise this exception:
        # raise proc_data.error.class
      end
    end

    You can make this example even more complex. For example, you can collect all the files that were successfully queued, then re-create the exporter object and adjust the skip_file_export option (please check the docs to learn more about it). This option allows you to provide exclusion criteria, and you can instruct the exporter to skip all the files that were already uploaded. Then, just run the exporter! method again to restart the whole process.

    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!

    Rate this post

    Average rating 0 / 5. Vote count: 0


    Related articles

    To understand what localization is, begin with what it isn’t. It’s not “translation”. That’s just where it starts. True localization means communicating across different markets in accordance with their cultures…

    June 16, 2022
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.