Skip to content

Commit

Permalink
support for multiple endpoints, weighted routing (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
evq committed Jan 10, 2024
1 parent 09e9788 commit 78a637e
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 52 deletions.
197 changes: 162 additions & 35 deletions controller/controller.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package controller

import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/brave-intl/bat-go/libs/logging"
Expand All @@ -16,26 +21,144 @@ import (
"github.com/go-chi/chi"
)

// LnxEndpoint specifies the remote Lnx translate server used by
// brave-core, and it can be set to a mock server during testing.
var LnxEndpoint = os.Getenv("LNX_HOST")
var LnxAPIKey = os.Getenv("LNX_API_KEY")
var languagePath = "/get-languages"
var translatePath = "/translate"
var (
LnxEndpoint *LnxEndpointConfiguration
LnxAPIKey = os.Getenv("LNX_API_KEY")
languagePath = "/get-languages"
translatePath = "/translate"
)

// LnxEndpointConfiguration describes a configuration of lingvanex endpoints, their supported
// languages and weights.
type LnxEndpointConfiguration struct {
// A list of endpoint URLs.
Endpoints []string
// A list of default endpoint weights.
DefaultWeights []float64
// A GoogleLanguageList containing source language descriptions and target language descriptions.
LanguagePairList language.GoogleLanguageList
// A nested map of endpoint weights for a language pair.
// The first key represents the source language, the second key represents the target language, the
// third key represents the endpoint URL and the value the corresponding weight for that endpoint.
LanguagePairWeights map[string]map[string]map[string]float64
}

// NewLnxEndpointConfiguration returns a new endpoint configuration based on a list of endpoints, weights and list of supported languages
func NewLnxEndpointConfiguration(endpoints []string, weights []float64, languageLists []language.GoogleLanguageList) (*LnxEndpointConfiguration, error) {
if len(endpoints) != len(weights) || len(weights) != len(languageLists) {
return nil, fmt.Errorf("Number of endpoints must match number of weights and number of language lists")
}

conf := LnxEndpointConfiguration{
Endpoints: endpoints,
DefaultWeights: weights,
LanguagePairList: language.GoogleLanguageList{Sl: make(map[string]string), Tl: make(map[string]string)},
LanguagePairWeights: make(map[string]map[string]map[string]float64),
}

for i, endpoint := range endpoints {
// get the list of supported languages for the current endpoint
list := languageLists[i]

// iterate through the source languages the current endpoint supports
for sl, sldesc := range list.Sl {
// add the source language description to the merged language pair list
conf.LanguagePairList.Sl[sl] = sldesc

// check if the source language weight map already exists in the language pair weights
if _, ok := conf.LanguagePairWeights[sl]; !ok {
// if not, create a new weight map for the source language
conf.LanguagePairWeights[sl] = make(map[string]map[string]float64)
}

for tl, tldesc := range list.Tl {
// add the target language description to the merged language pair list
conf.LanguagePairList.Tl[tl] = tldesc

// check if the weight map for the source / target language pair already exists
if _, ok := conf.LanguagePairWeights[sl][tl]; !ok {
// if not, create a new weight map for it
conf.LanguagePairWeights[sl][tl] = make(map[string]float64)
}
// set the default weight for the current endpoint for the source-target language pair
conf.LanguagePairWeights[sl][tl][endpoint] = conf.DefaultWeights[i]
}
}
}
return &conf, nil
}

// GetEndpoint returns the endpoint which should be used based on the weights and languages supported.
func (c *LnxEndpointConfiguration) GetEndpoint(from, to string) string {
// initialize total weight and incrementals.
total := 0.0
incrementals := []float64{}

// retrieve the nested map of language pair weights.
weights := c.LanguagePairWeights[from][to]

// iterate through the Endpoints array, accumulating the total weight and storing the intermediate sums in incrementals.
for _, endpoint := range c.Endpoints {
total += weights[endpoint]
incrementals = append(incrementals, total)
}

// generate a random number between 0 and total.
r := rand.Float64() * total

// find the endpoint with the smallest incremental weight greater than r.
for i, incremental := range incrementals {
if r < incremental {
return c.Endpoints[i]
}
}
// otherwise default to the first endpoint
return c.Endpoints[0]
}

// TranslateRouter add routers for translate requests and translate script
// requests.
func TranslateRouter() chi.Router {
func TranslateRouter(ctx context.Context) (chi.Router, error) {
r := chi.NewRouter()

var weights []float64
endpoints := strings.Split(os.Getenv("LNX_HOST"), ",")
for _, weight := range strings.Split(os.Getenv("LNX_WEIGHTS"), ",") {
if len(weight) > 0 {
weight, err := strconv.ParseFloat(weight, 64)
if err != nil {
return r, fmt.Errorf("Must pass at least one endpoint via LNX_HOST and one weight via LNX_WEIGHTS: %v", err)
}
weights = append(weights, weight)
}
}
if len(endpoints) == 1 && len(weights) == 0 {
weights = append(weights, 1)
}

var lists []language.GoogleLanguageList
for _, endpoint := range endpoints {
list, err := getLanguageList(ctx, endpoint)
if err != nil {
panic(err)
}
lists = append(lists, *list)
}

var err error
LnxEndpoint, err = NewLnxEndpointConfiguration(endpoints, weights, lists)
if err != nil {
return r, fmt.Errorf("Failed to setup endpoint configuration: %v", err)
}

r.Post("/translate_a/t", middleware.InstrumentHandler("Translate", http.HandlerFunc(Translate)).ServeHTTP)
r.Get("/translate_a/l", middleware.InstrumentHandler("GetLanguageList", http.HandlerFunc(GetLanguageList)).ServeHTTP)

r.Get("/static/v1/element.js", middleware.InstrumentHandler("ServeStaticFile", http.HandlerFunc(ServeStaticFile)).ServeHTTP)
r.Get("/static/v1/js/element/main.js", middleware.InstrumentHandler("ServeStaticFile", http.HandlerFunc(ServeStaticFile)).ServeHTTP)
r.Get("/static/v1/css/translateelement.css", middleware.InstrumentHandler("ServeStaticFile", http.HandlerFunc(ServeStaticFile)).ServeHTTP)

return r
return r, nil
}

func ServeStaticFile(w http.ResponseWriter, r *http.Request) {
Expand All @@ -59,25 +182,21 @@ func getHTTPClient() *http.Client {
}
}

// GetLanguageList send a request to Lingvanex server and convert the response
// into google format and reply back to the client.
func GetLanguageList(w http.ResponseWriter, r *http.Request) {
logger := logging.FromContext(r.Context())
func getLanguageList(ctx context.Context, endpoint string) (*language.GoogleLanguageList, error) {
logger := logging.FromContext(ctx)

// Send a get language list request to Lnx
req, err := http.NewRequest("GET", LnxEndpoint+languagePath, nil)
req, err := http.NewRequest("GET", endpoint+languagePath, nil)
req.Header.Add("Authorization", "Bearer "+LnxAPIKey)

if err != nil {
http.Error(w, fmt.Sprintf("Error creating Lnx request: %v", err), http.StatusInternalServerError)
return
return nil, fmt.Errorf("Error creating Lnx request: %v", err)
}

client := getHTTPClient()
lnxResp, err := client.Do(req)
if err != nil {
http.Error(w, fmt.Sprintf("Error sending request to Lnx server: %v", err), http.StatusInternalServerError)
return
return nil, fmt.Errorf("Error sending request to Lnx server: %v", err)
}
defer func() {
err := lnxResp.Body.Close()
Expand All @@ -86,30 +205,31 @@ func GetLanguageList(w http.ResponseWriter, r *http.Request) {
}
}()

// Set response header
w.Header().Set("Content-Type", lnxResp.Header["Content-Type"][0])
w.WriteHeader(lnxResp.StatusCode)

// Copy resonse body if status is not OK
if lnxResp.StatusCode != http.StatusOK {
_, err = io.Copy(w, lnxResp.Body)
if err != nil {
http.Error(w, fmt.Sprintf("Error copying Lnx response body: %v", err), http.StatusInternalServerError)
}
return
}

// Convert to google format language list and write it back
lnxBody, err := ioutil.ReadAll(lnxResp.Body)
if err != nil {
http.Error(w, fmt.Sprintf("Error reading Lnx response body: %v", err), http.StatusInternalServerError)
return
return nil, fmt.Errorf("Error reading Lnx response body: %v", err)
}
body, err := language.ToGoogleLanguageList(lnxBody)
list, err := language.ToGoogleLanguageList(lnxBody)
if err != nil {
http.Error(w, fmt.Sprintf("Error converting to google language list: %v", err), http.StatusInternalServerError)
return nil, fmt.Errorf("Error converting to google language list: %v", err)
}

return list, nil
}

// GetLanguageList send a request to Lingvanex server and convert the response
// into google format and reply back to the client.
func GetLanguageList(w http.ResponseWriter, r *http.Request) {
logger := logging.FromContext(r.Context())

body, err := json.Marshal(LnxEndpoint.LanguagePairList)

if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

_, err = w.Write(body)
if err != nil {
logger.Error().Err(err).Msg("Error writing response body for translate requests")
Expand All @@ -124,7 +244,14 @@ func Translate(w http.ResponseWriter, r *http.Request) {

w.Header().Set("Access-Control-Allow-Origin", "*") // same as Google response

req, isAuto, err := translate.ToLingvanexRequest(r, LnxEndpoint+translatePath)
to, from, err := translate.GetLanguageParams(r)
if err != nil {
http.Error(w, fmt.Sprintf("Error converting to LnxEndpoint request: %v", err), http.StatusBadRequest)
return
}

endpoint := LnxEndpoint.GetEndpoint(from, to)
req, isAuto, err := translate.ToLingvanexRequest(r, endpoint+translatePath)
if err != nil {
http.Error(w, fmt.Sprintf("Error converting to LnxEndpoint request: %v", err), http.StatusBadRequest)
return
Expand Down
100 changes: 100 additions & 0 deletions controller/controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package controller

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/brave/go-translate/language"
)

func TestNewLnxEndpointConfiguration(t *testing.T) {
lists := []language.GoogleLanguageList{
language.GoogleLanguageList{
Sl: map[string]string{"en":"English", "es":"Spanish", "it": "Italian"},
Tl: map[string]string{"en":"English", "es":"Spanish", "it": "Italian"},
},
language.GoogleLanguageList{
Sl: map[string]string{"en":"English", "es":"Spanish", "de": "Deutsch"},
Tl: map[string]string{"en":"English", "es":"Spanish", "de": "Deutsch"},
},
}
endpoints := []string{"endpoint1.com", "endpoint2.com"}
weights := []float64{0.5, 0.5}

conf, err := NewLnxEndpointConfiguration(endpoints, weights, lists)
assert.NoError(t, err)

assert.Equal(t, endpoints, conf.Endpoints)
assert.Equal(t, weights, conf.DefaultWeights)

assert.NotNil(t, conf.LanguagePairList)
assert.NotEmpty(t, conf.LanguagePairWeights)

assert.Equal(t, conf.LanguagePairWeights["en"]["it"], map[string]float64{"endpoint1.com": 0.5})
assert.Equal(t, conf.LanguagePairWeights["it"]["en"], map[string]float64{"endpoint1.com": 0.5})
assert.Equal(t, conf.LanguagePairWeights["en"]["de"], map[string]float64{"endpoint2.com": 0.5})
assert.Equal(t, conf.LanguagePairWeights["de"]["en"], map[string]float64{"endpoint2.com": 0.5})
assert.Equal(t, conf.LanguagePairWeights["en"]["es"], map[string]float64{"endpoint1.com": 0.5, "endpoint2.com": 0.5})
assert.Equal(t, conf.LanguagePairWeights["es"]["en"], map[string]float64{"endpoint1.com": 0.5, "endpoint2.com": 0.5})
}

func TestLnxEndpointConfiguration_GetEndpoint(t *testing.T) {
lists := []language.GoogleLanguageList{
language.GoogleLanguageList{
Sl: map[string]string{"en":"English", "es":"Spanish", "it": "Italian"},
Tl: map[string]string{"en":"English", "es":"Spanish", "it": "Italian"},
},
language.GoogleLanguageList{
Sl: map[string]string{"en":"English", "es":"Spanish", "de": "Deutsch"},
Tl: map[string]string{"en":"English", "es":"Spanish", "de": "Deutsch"},
},
}
endpoints := []string{"endpoint1.com", "endpoint2.com"}
weights := []float64{0.5, 0.5}

conf, err := NewLnxEndpointConfiguration(endpoints, weights, lists)
assert.NoError(t, err)

t.Run("random selection", func(t *testing.T) {
from := "en"
to := "es"
countOne := 0
countTwo := 0

for i := 0; i<2000; i++ {
got := conf.GetEndpoint(from, to)
if got == "endpoint1.com" {
countOne++
} else if got == "endpoint2.com" {
countTwo++
}
}
assert.Less(t, 900, countTwo)
assert.Greater(t, 1100, countTwo)
assert.Less(t, 900, countOne)
assert.Greater(t, 1100, countOne)
})

t.Run("first endpoint", func(t *testing.T) {
from := "en"
to := "it"
expected := "endpoint1.com"

for i := 0; i<100; i++ {
got := conf.GetEndpoint(from, to)
assert.Equal(t, expected, got)
}
})

t.Run("second endpoint", func(t *testing.T) {
from := "en"
to := "de"
expected := "endpoint2.com"

for i := 0; i<100; i++ {
got := conf.GetEndpoint(from, to)
assert.Equal(t, expected, got)
}
})
}
4 changes: 2 additions & 2 deletions language/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ type GoogleLanguageList struct {

// ToGoogleLanguageList unmarshal a Lnx language list and marshal a corresponding
// google language list and return it.
func ToGoogleLanguageList(body []byte) ([]byte, error) {
func ToGoogleLanguageList(body []byte) (*GoogleLanguageList, error) {
var lnxLangList []Language
err := json.Unmarshal(body, &lnxLangList)
if err != nil {
Expand All @@ -212,7 +212,7 @@ func ToGoogleLanguageList(body []byte) ([]byte, error) {
googleLangList.Sl[val] = lang.Name
googleLangList.Tl[val] = lang.Name
}
return json.Marshal(googleLangList)
return &googleLangList, nil
}

func init() {
Expand Down
Loading

0 comments on commit 78a637e

Please sign in to comment.