diff --git a/.chglog/CHANGELOG.tpl.md b/.chglog/CHANGELOG.tpl.md
new file mode 100644
index 0000000..c19679e
--- /dev/null
+++ b/.chglog/CHANGELOG.tpl.md
@@ -0,0 +1,30 @@
+{{ range .Versions }}
+
+## {{ if .Tag.Previous }}[{{ .Tag.Name }}]({{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}){{ else }}{{ .Tag.Name }}{{ end }} ({{ datetime "2006-01-02" .Tag.Date }})
+
+{{ range .CommitGroups -}}
+### {{ .Title }}
+
+{{ range .Commits -}}
+* {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
+{{ end }}
+{{ end -}}
+
+{{- if .RevertCommits -}}
+### Reverts
+
+{{ range .RevertCommits -}}
+* {{ .Revert.Header }}
+{{ end }}
+{{ end -}}
+
+{{- if .NoteGroups -}}
+{{ range .NoteGroups -}}
+### {{ .Title }}
+
+{{ range .Notes }}
+{{ .Body }}
+{{ end }}
+{{ end -}}
+{{ end -}}
+{{ end -}}
diff --git a/.chglog/config.yml b/.chglog/config.yml
new file mode 100644
index 0000000..b7cfb7a
--- /dev/null
+++ b/.chglog/config.yml
@@ -0,0 +1,35 @@
+style: github
+template: CHANGELOG.tpl.md
+info:
+ title: CHANGELOG
+ repository_url: https://github.com/lingodotdev/sdk-go
+
+options:
+ commits:
+ filters:
+ Type:
+ - feat
+ - fix
+ - perf
+ - refactor
+ - docs
+ - chore
+ - test
+ commit_groups:
+ title_maps:
+ feat: Features
+ fix: Bug Fixes
+ perf: Performance Improvements
+ refactor: Code Refactoring
+ docs: Documentation
+ chore: Maintenance
+ test: Tests
+ header:
+ pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\!?\\:\\s(.*)$"
+ pattern_maps:
+ - Type
+ - Scope
+ - Subject
+ notes:
+ keywords:
+ - BREAKING CHANGE
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..666e8ef
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,33 @@
+## Description
+Brief description of the changes in this PR.
+
+## Type of Change
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] Documentation update
+- [ ] Performance improvement
+- [ ] Code refactoring
+
+## Testing
+- [ ] Tests pass locally
+- [ ] New tests added for new functionality
+- [ ] Integration tests pass
+
+## Checklist
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings
+- [ ] New and existing unit tests pass locally with my changes
+
+## Commit Message Format
+Please ensure your commit messages follow the conventional commits format:
+- `feat: add new feature`
+- `fix: resolve bug`
+- `docs: update documentation`
+- `style: format code`
+- `refactor: refactor code`
+- `test: add tests`
+- `chore: update dependencies`
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
new file mode 100644
index 0000000..60f0b06
--- /dev/null
+++ b/.github/workflows/pr.yml
@@ -0,0 +1,60 @@
+name: Pull Request
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ name: Test Go ${{ matrix.go-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ go-version: ['1.21', '1.22']
+ env:
+ LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go ${{ matrix.go-version }}
+ uses: actions/setup-go@v5
+ with:
+ go-version: ${{ matrix.go-version }}
+ cache: true
+
+ - name: Download dependencies
+ run: go mod tidy
+
+ - name: Verify dependencies
+ run: go mod verify
+
+ - name: Build
+ run: make build
+
+ - name: Vet
+ run: make vet
+
+ - name: Format check
+ run: |
+ if [ -n "$(gofmt -l .)" ]; then
+ echo "The following files are not formatted:"
+ gofmt -l .
+ exit 1
+ fi
+
+ - name: Unit tests with coverage
+ run: go test ./tests/... -skip "TestRealAPI" -v -count=1 -timeout 60s -coverpkg=github.com/lingodotdev/sdk-go -coverprofile=coverage.out
+
+ - name: Upload coverage
+ if: matrix.go-version == '1.22'
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage.out
+ flags: unittests
+ fail_ci_if_error: false
+
+ - name: Integration tests
+ if: env.LINGODOTDEV_API_KEY != ''
+ run: make test-integration
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..66bb2ad
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,167 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ name: Test Go ${{ matrix.go-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ go-version: ['1.21', '1.22']
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go ${{ matrix.go-version }}
+ uses: actions/setup-go@v5
+ with:
+ go-version: ${{ matrix.go-version }}
+ cache: true
+
+ - name: Download dependencies
+ run: go mod tidy
+
+ - name: Verify dependencies
+ run: go mod verify
+
+ - name: Build
+ run: make build
+
+ - name: Vet
+ run: make vet
+
+ - name: Unit tests with coverage
+ run: go test ./tests/... -skip "TestRealAPI" -v -count=1 -timeout 60s -coverpkg=github.com/lingodotdev/sdk-go -coverprofile=coverage.out
+
+ - name: Upload coverage
+ if: matrix.go-version == '1.22'
+ uses: codecov/codecov-action@v4
+ with:
+ files: ./coverage.out
+ flags: unittests
+ fail_ci_if_error: false
+
+ integration:
+ name: Integration Tests
+ runs-on: ubuntu-latest
+ needs: test
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+ cache: true
+
+ - name: Download dependencies
+ run: go mod tidy
+
+ - name: Verify dependencies
+ run: go mod verify
+
+ - name: Integration tests
+ env:
+ LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }}
+ run: make test-integration
+
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+ needs: [test, integration]
+ if: always() && needs.test.result == 'success' && (needs.integration.result == 'success' || needs.integration.result == 'skipped')
+ concurrency: release
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+ cache: true
+
+ - name: Install git-chglog
+ run: go install github.com/git-chglog/git-chglog/cmd/git-chglog@latest
+
+ - name: Check for version tag
+ id: check_tag
+ run: |
+ LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
+ COMMITS_SINCE=$(git rev-list ${LATEST_TAG}..HEAD --count 2>/dev/null || echo "0")
+ if [ "$COMMITS_SINCE" -gt "0" ]; then
+ echo "release_needed=true" >> $GITHUB_OUTPUT
+ echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
+ echo "Release needed: $COMMITS_SINCE commits since $LATEST_TAG"
+ else
+ echo "release_needed=false" >> $GITHUB_OUTPUT
+ echo "No new commits since $LATEST_TAG"
+ fi
+
+ - name: Determine next version
+ if: steps.check_tag.outputs.release_needed == 'true'
+ id: next_version
+ run: |
+ LATEST_TAG=${{ steps.check_tag.outputs.latest_tag }}
+ VERSION=${LATEST_TAG#v}
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
+
+ COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
+
+ if echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then
+ MAJOR=$((MAJOR + 1))
+ MINOR=0
+ PATCH=0
+ elif echo "$COMMITS" | grep -qiE "^feat(\(.+\))?:"; then
+ MINOR=$((MINOR + 1))
+ PATCH=0
+ else
+ PATCH=$((PATCH + 1))
+ fi
+
+ NEXT_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
+ echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
+ echo "Next version: $NEXT_VERSION"
+
+ - name: Generate changelog
+ if: steps.check_tag.outputs.release_needed == 'true'
+ run: |
+ git-chglog --next-tag ${{ steps.next_version.outputs.version }} -o CHANGELOG.md
+
+ - name: Commit changelog
+ if: steps.check_tag.outputs.release_needed == 'true'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add CHANGELOG.md
+ git commit -m "chore: update CHANGELOG for ${{ steps.next_version.outputs.version }}" || echo "No changes to commit"
+ git push
+
+ - name: Create GitHub release
+ if: steps.check_tag.outputs.release_needed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION=${{ steps.next_version.outputs.version }}
+ git tag "$VERSION"
+ git push origin "$VERSION"
+
+ NOTES=$(git-chglog --next-tag "$VERSION" "$VERSION")
+
+ gh release create "$VERSION" \
+ --title "$VERSION" \
+ --notes "$NOTES" \
+ --latest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07fd9a6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# Coverage
+coverage.out
+
+# Binaries
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary
+*.test
+
+# Go workspace
+go.work
+go.work.sum
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6361e43
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..5fcf115
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,46 @@
+.PHONY: test test-integration test-coverage test-coverage-html build vet fmt lint clean help
+
+# Default target
+help:
+ @echo "Available commands:"
+ @echo " make test Run all unit tests"
+ @echo " make test-integration Run integration tests (requires LINGODOTDEV_API_KEY)"
+ @echo " make test-coverage Run unit tests with coverage summary"
+ @echo " make test-coverage-html Run unit tests and open HTML coverage report"
+ @echo " make build Build the SDK"
+ @echo " make vet Run go vet"
+ @echo " make fmt Format code with gofmt"
+ @echo " make lint Run fmt + vet"
+ @echo " make clean Remove coverage files"
+
+test:
+ go test ./tests/... -skip "TestRealAPI" -v -timeout 60s
+
+test-integration:
+ @if [ -z "$$LINGODOTDEV_API_KEY" ]; then \
+ echo "Error: LINGODOTDEV_API_KEY is not set"; \
+ exit 1; \
+ fi
+ go test ./tests/... -run "TestRealAPI" -v -timeout 120s
+
+test-coverage:
+ go test ./tests/... -skip "TestRealAPI" -timeout 60s -coverpkg=github.com/lingodotdev/sdk-go -coverprofile=coverage.out
+ go tool cover -func=coverage.out
+
+test-coverage-html:
+ go test ./tests/... -skip "TestRealAPI" -timeout 60s -coverpkg=github.com/lingodotdev/sdk-go -coverprofile=coverage.out
+ go tool cover -html=coverage.out
+
+build:
+ go build ./...
+
+vet:
+ go vet ./...
+
+fmt:
+ gofmt -w .
+
+lint: fmt vet
+
+clean:
+ rm -f coverage.out
diff --git a/README.md b/README.md
index dece5cd..1b50621 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,548 @@
-# sdk-go
-Official Lingo.dev SDK for GO
+# Lingo.dev Go SDK
+
+[](https://goreportcard.com/report/github.com/lingodotdev/sdk-go)
+[](https://pkg.go.dev/github.com/lingodotdev/sdk-go)
+[](LICENSE)
+
+A production-grade Go SDK for the Lingo.dev localization engine. Supports text, objects, chat messages, batch operations, and language detection with built-in concurrency, automatic retries, and full context propagation.
+
+## β¨ Key Features
+
+- π§΅ **Context-aware** β every method accepts `context.Context` for cancellation and timeouts
+- π **Concurrent processing** with `errgroup` for dramatically faster bulk translations
+- π‘οΈ **Typed errors** β `ValueError` and `RuntimeError` catchable with `errors.As`
+- π **Automatic retries** with exponential backoff (1s, 2s, 4s) on 5xx errors and network failures
+- π― **Multiple content types** β text, key-value objects, chat messages
+- π **Auto-detection** of source languages
+- β‘ **Fast mode** for quick translations when precision is less critical
+- βοΈ **Functional options** pattern for clean, extensible configuration
+
+## π Performance Benefits
+
+The concurrent implementation provides significant performance improvements:
+
+- **Parallel chunk processing** β large payloads are split and translated simultaneously
+- **Batch operations** β translate to multiple languages or multiple objects in one call
+- **Concurrent API requests** via `errgroup` instead of sequential loops
+- **Context cancellation** propagated to the HTTP layer β cancelled requests stop immediately
+
+## π¦ Installation
+
+```bash
+go get github.com/lingodotdev/sdk-go
+```
+
+## π― Quick Start
+
+### Simple Translation
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ result, err := client.LocalizeText(
+ context.Background(),
+ "Hello, world!",
+ lingo.LocalizationParams{TargetLocale: "es"},
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println(result) // "Β‘Hola, mundo!"
+}
+```
+
+### With Source Locale and Fast Mode
+
+`SourceLocale` and `Fast` are pointer types β use `&` to pass optional values:
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func strPtr(s string) *string { return &s }
+func boolPtr(b bool) *bool { return &b }
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ result, err := client.LocalizeText(
+ context.Background(),
+ "Hello, world!",
+ lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ Fast: boolPtr(true),
+ },
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println(result)
+}
+```
+
+## π₯ Advanced Usage
+
+### Object Translation
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ obj := map[string]any{
+ "greeting": "Hello",
+ "farewell": "Goodbye",
+ "question": "How are you?",
+ }
+
+ result, err := client.LocalizeObject(
+ context.Background(),
+ obj,
+ lingo.LocalizationParams{TargetLocale: "fr"},
+ true, // concurrent β process chunks in parallel for speed
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for k, v := range result {
+ fmt.Printf("%s: %v\n", k, v)
+ }
+}
+```
+
+### Batch Translation (Multiple Target Languages)
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ results, err := client.BatchLocalizeText(
+ context.Background(),
+ "Welcome to our application",
+ nil, // sourceLocale (auto-detect)
+ nil, // fast (default)
+ []string{"es", "fr", "de", "it"},
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for i, result := range results {
+ fmt.Printf("[%d] %s\n", i, result)
+ }
+}
+```
+
+### Chat Translation
+
+Speaker names are preserved while text is translated:
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func strPtr(s string) *string { return &s }
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ chat := []map[string]string{
+ {"name": "Alice", "text": "Hello everyone!"},
+ {"name": "Bob", "text": "How is everyone doing?"},
+ {"name": "Charlie", "text": "Great to see you all!"},
+ }
+
+ translated, err := client.LocalizeChat(
+ context.Background(),
+ chat,
+ lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ },
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, msg := range translated {
+ fmt.Printf("%s: %s\n", msg["name"], msg["text"])
+ }
+}
+```
+
+### Batch Object Translation
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ objects := []map[string]any{
+ {"title": "Welcome", "description": "Please sign in"},
+ {"error": "Invalid input", "help": "Check your email"},
+ {"success": "Account created", "next": "Continue to dashboard"},
+ }
+
+ results, err := client.BatchLocalizeObjects(
+ context.Background(),
+ objects,
+ lingo.LocalizationParams{TargetLocale: "fr"},
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for i, result := range results {
+ fmt.Printf("Object %d: %v\n", i, result)
+ }
+}
+```
+
+### Language Detection
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ locale, err := client.RecognizeLocale(context.Background(), "Bonjour le monde")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println(locale) // "fr"
+}
+```
+
+### Account Info
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ info, err := client.WhoAmI(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if info == nil {
+ fmt.Println("Not authenticated")
+ return
+ }
+
+ for k, v := range info {
+ fmt.Printf("%s: %s\n", k, v)
+ }
+}
+```
+
+### Context and Cancellation
+
+Go's `context.Context` propagates all the way to the HTTP layer. Use timeouts and cancellation to control long-running operations:
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ result, err := client.LocalizeText(
+ ctx,
+ "Hello, world!",
+ lingo.LocalizationParams{TargetLocale: "es"},
+ )
+ if err != nil {
+ log.Fatalf("translation failed or timed out: %s", err)
+ }
+
+ fmt.Println(result)
+}
+```
+
+If the context is cancelled or times out, in-flight HTTP requests are aborted immediately β no wasted network calls.
+
+## βοΈ Configuration Options
+
+```go
+client, err := lingo.NewClient(
+ "your-api-key",
+ lingo.SetURL("https://engine.lingo.dev"), // Optional: API endpoint (default shown)
+ lingo.SetBatchSize(25), // Optional: Items per chunk (1β250, default 25)
+ lingo.SetIdealBatchItemSize(250), // Optional: Target words per chunk (1β2500, default 250)
+)
+```
+
+| Option | Default | Range | Description |
+|--------|---------|-------|-------------|
+| `SetURL` | `https://engine.lingo.dev` | Valid HTTP/HTTPS URL | API endpoint |
+| `SetBatchSize` | `25` | 1β250 | Maximum items per chunk before splitting |
+| `SetIdealBatchItemSize` | `250` | 1β2500 | Target word count per chunk before splitting |
+
+## ποΈ LocalizationParams Reference
+
+```go
+type LocalizationParams struct {
+ SourceLocale *string // Source language code (nil = auto-detect)
+ TargetLocale string // Target language code (required)
+ Fast *bool // Enable fast mode for quicker translations (nil = default)
+ Reference map[string]map[string]any // Reference translations for additional context
+}
+```
+
+Optional fields use pointer types (`*string`, `*bool`) so that `nil` means "use the default" rather than requiring a zero value. Use small helpers to pass values inline:
+
+```go
+func strPtr(s string) *string { return &s }
+func boolPtr(b bool) *bool { return &b }
+```
+
+## π API Reference
+
+### Core Methods
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| **LocalizeText** | `(ctx context.Context, text string, params LocalizationParams) (string, error)` | Translate a single text string |
+| **LocalizeObject** | `(ctx context.Context, obj map[string]any, params LocalizationParams, concurrent bool) (map[string]any, error)` | Translate all values in a map |
+| **LocalizeChat** | `(ctx context.Context, chat []map[string]string, params LocalizationParams) ([]map[string]string, error)` | Translate chat messages, preserving speaker names |
+| **RecognizeLocale** | `(ctx context.Context, text string) (string, error)` | Detect the language of a text string |
+| **WhoAmI** | `(ctx context.Context) (map[string]string, error)` | Get authenticated user info; returns `nil, nil` if unauthenticated |
+
+### Batch Methods
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| **BatchLocalizeText** | `(ctx context.Context, text string, sourceLocale *string, fast *bool, targetLocales []string) ([]string, error)` | Translate text to multiple languages concurrently |
+| **BatchLocalizeObjects** | `(ctx context.Context, objects []map[string]any, params LocalizationParams) ([]map[string]any, error)` | Translate multiple objects concurrently |
+
+## π§ Error Handling
+
+The SDK uses two typed errors that you can catch with `errors.As`:
+
+```go
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ client, err := lingo.NewClient("your-api-key")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ _, err = client.LocalizeText(
+ context.Background(),
+ "Hello",
+ lingo.LocalizationParams{TargetLocale: "es"},
+ )
+ if err != nil {
+ var ve *lingo.ValueError
+ if errors.As(err, &ve) {
+ // Validation error: empty text, invalid config, bad request (400)
+ fmt.Printf("ValueError: %s\n", ve.Message)
+ return
+ }
+
+ var re *lingo.RuntimeError
+ if errors.As(err, &re) {
+ // Runtime error: server error (5xx), network failure, unexpected response
+ fmt.Printf("RuntimeError: %s (status %d)\n", re.Message, re.StatusCode)
+ return
+ }
+
+ log.Fatal(err)
+ }
+}
+```
+
+| Error Type | Returned When |
+|------------|---------------|
+| `*ValueError` | Empty text, invalid config options, API returns 400 Bad Request |
+| `*RuntimeError` | Server errors (5xx), network failures, unexpected response format, JSON parse errors |
+
+`RuntimeError.StatusCode` contains the HTTP status code when the error originated from an HTTP response (0 otherwise).
+
+## π Performance Tips
+
+1. **Use `concurrent: true`** in `LocalizeObject` for large payloads β chunks are processed in parallel via `errgroup`
+2. **Use `BatchLocalizeText`** to translate one text into multiple languages concurrently instead of sequential `LocalizeText` calls
+3. **Use `BatchLocalizeObjects`** to translate multiple objects at once β each object is processed in its own goroutine
+4. **Tune `SetBatchSize`** based on your content β smaller batches create more chunks which enables more parallelism
+5. **Use `context.WithTimeout`** to set hard deadlines β the context propagates to the HTTP layer and cancels in-flight requests
+6. **Let the SDK retry** β 5xx errors and network failures are retried automatically up to 3 times with exponential backoff; don't add your own retry wrapper
+
+## π§ͺ Running Tests
+
+This project uses a Makefile for common tasks.
+
+### Unit Tests
+
+```bash
+make test
+```
+
+### Integration Tests (requires API key)
+
+```bash
+export LINGODOTDEV_API_KEY="your-api-key"
+make test-integration
+```
+
+### With Coverage Report
+
+```bash
+make test-coverage
+```
+
+Opens an HTML coverage report in your browser automatically.
+
+### All Available Commands
+
+```bash
+make help
+```
+
+## π Go Advantages
+
+The Go SDK offers several advantages over other language implementations:
+
+- **Native context cancellation** β `context.Context` propagates through every layer down to `http.NewRequestWithContext`, so cancelled requests are aborted at the HTTP transport level with zero wasted network calls
+- **Zero external dependencies for core functionality** β the only dependencies are `golang.org/x/sync` for `errgroup` and `go-nanoid` for workflow IDs; the core HTTP, JSON, and retry logic uses only the standard library
+- **Typed errors with `errors.As`** β no string matching or catch-all exception handlers; `ValueError` and `RuntimeError` are distinct types you can match precisely
+- **No async/await complexity** β standard synchronous function calls with goroutines and `errgroup` for concurrency; no colored functions, no event loop, no runtime overhead
+
+## π Versioning
+
+This SDK follows [Semantic Versioning](https://semver.org/). Check the [releases page](https://github.com/lingodotdev/sdk-go/releases) for the latest version.
+
+## π License
+
+Apache-2.0 License
+
+## π€ Support
+
+- π [Documentation](https://lingo.dev/docs)
+- π [Issues](https://github.com/lingodotdev/sdk-go/issues)
+- π¬ [Community](https://discord.com/invite/rJ8zGYJQj5)
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..31eea9e
--- /dev/null
+++ b/client.go
@@ -0,0 +1,237 @@
+package lingo
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ MaxResponseLength = 200
+)
+
+type Client struct {
+ config Config
+ httpClient *http.Client
+}
+
+// NewClient creates a new Lingo.dev SDK client with the given API key.
+// Optional ConfigOption functions can be passed to override defaults.
+func NewClient(apiKey string, opts ...ConfigOption) (*Client, error) {
+ if apiKey == "" {
+ return nil, &ValueError{Message: "lingo: api key is required"}
+ }
+ config, err := newEngineConfig(apiKey, opts...)
+ if err != nil {
+ return nil, err
+ }
+ c := &Client{
+ config: *config,
+ httpClient: &http.Client{
+ Timeout: 60 * time.Second,
+ },
+ }
+ return c, nil
+}
+
+// TruncateResponse truncates text to MaxResponseLength, appending "..." if truncated.
+func TruncateResponse(text string) string {
+ if len(text) > MaxResponseLength {
+ return text[:MaxResponseLength] + "..."
+ }
+ return text
+}
+
+// do executes an authenticated POST request to the given endpoint,
+// retrying up to 3 times with exponential backoff on 5xx errors.
+// Returns the full parsed JSON response map.
+func (c *Client) do(ctx context.Context, endpoint string, requestData any) (map[string]any, error) {
+ // Marshall data
+ dataByte, err := json.Marshal(requestData)
+ if err != nil {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: failed to marshall request data: %s", err)}
+ }
+
+ const maxRetries = 3
+ var lastErr error
+
+ for attempt := 0; attempt < maxRetries; attempt++ {
+ if attempt > 0 {
+ delay := time.Duration(1<= 2 {
+ reasonPhrase = parts[1]
+ }
+
+ if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode < 600 {
+ lastErr = &RuntimeError{Message: fmt.Sprintf("lingo: server error %d : %s. this may be due to temporary service issues. response: %s", resp.StatusCode, reasonPhrase, responsePreview), StatusCode: resp.StatusCode}
+ continue
+ } else if resp.StatusCode == http.StatusBadRequest {
+ return nil, &ValueError{Message: fmt.Sprintf("lingo: invalid request (%d): %s. response: %s", resp.StatusCode, reasonPhrase, responsePreview)}
+ } else {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: request failed (%d): %s. response: %s", resp.StatusCode, reasonPhrase, responsePreview), StatusCode: resp.StatusCode}
+ }
+ }
+
+ // Parse JSON from same byteData
+
+ var jsonResponse map[string]any
+
+ err = json.Unmarshal(byteData, &jsonResponse)
+ if err != nil {
+ preview := TruncateResponse(string(byteData))
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: failed to parse api response as json (status %d). this may indicate a gateway or proxy error. response: %s", resp.StatusCode, preview), StatusCode: resp.StatusCode}
+ }
+
+ // Check API level error
+ if jsonResponse["error"] != nil {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: %s", jsonResponse["error"])}
+ }
+
+ return jsonResponse, nil
+ }
+
+ return nil, lastErr
+}
+
+// CountWords counts the total number of words in the given payload recursively.
+func CountWords(payload any) int {
+ switch v := payload.(type) {
+ case []any:
+ total := 0
+ for _, item := range v {
+ total += CountWords(item)
+ }
+ return total
+ case []string:
+ total := 0
+ for _, s := range v {
+ total += len(strings.Fields(s))
+ }
+ return total
+ case map[string]any:
+ total := 0
+ for _, value := range v {
+ total += CountWords(value)
+ }
+ return total
+ case map[string]string:
+ total := 0
+ for _, s := range v {
+ total += len(strings.Fields(s))
+ }
+ return total
+ case string:
+ return len(strings.Fields(v))
+ default:
+ return 0
+ }
+}
+
+// ExtractChunks splits a payload map into smaller chunks based on configured batch size and ideal item size.
+func (c *Client) ExtractChunks(payload map[string]any) []map[string]any {
+ total := len(payload)
+ processed := 0
+ var result []map[string]any
+ currentChunk := make(map[string]any)
+ var currentItemCount int
+
+ for key, value := range payload {
+ currentChunk[key] = value
+ currentItemCount++
+ currentChunkSize := CountWords(currentChunk)
+ processed++
+
+ if currentChunkSize > c.config.IdealBatchItemSize || currentItemCount >= c.config.BatchSize || processed == total {
+ result = append(result, currentChunk)
+ currentChunk = make(map[string]any)
+ currentItemCount = 0
+ }
+ }
+
+ return result
+}
+
+func (c *Client) localizeChunk(ctx context.Context, sourceLocale *string, workflowID, targetLocale string, payload map[string]any, fast bool) (any, error) {
+ endpoint, err := url.JoinPath(c.config.APIURL, "/i18n")
+ if err != nil {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: unable to join path: %s", err)}
+ }
+
+ requestData := &requestData{
+ Param: parameter{
+ WorkflowID: workflowID,
+ Fast: fast,
+ },
+ Locale: locale{
+ Source: sourceLocale,
+ Target: targetLocale,
+ },
+ Data: payload["data"],
+ }
+
+ if raw, ok := payload["reference"]; ok {
+ ref, ok := raw.(map[string]map[string]any)
+ if !ok {
+ return nil, &ValueError{Message: "lingo: reference has invalid type"}
+ }
+ requestData.Reference = ref
+ }
+
+ result, err := c.do(ctx, endpoint, requestData)
+ if err != nil {
+ return nil, err
+ }
+
+ return result["data"], nil
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..a446117
--- /dev/null
+++ b/config.go
@@ -0,0 +1,73 @@
+package lingo
+
+import (
+ "strings"
+)
+
+// Config holds the SDK configuration options.
+type Config struct {
+ APIKey string
+ APIURL string
+ BatchSize int
+ IdealBatchItemSize int
+}
+
+// ConfigOption is a function that configures the SDK client.
+type ConfigOption func(c *Config) error
+
+// SetURL configures the API endpoint URL.
+// The URL must start with http:// or https://.
+func SetURL(url string) ConfigOption {
+ return func(c *Config) error {
+ if !(strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")) {
+ return &ValueError{Message: "lingo: api url must be a valid http/https url"}
+ }
+ c.APIURL = url
+ return nil
+ }
+}
+
+// SetBatchSize configures the maximum number of items per chunk (1-250).
+func SetBatchSize(batch int) ConfigOption {
+ return func(c *Config) error {
+ if batch < 1 || batch > 250 {
+ return &ValueError{Message: "lingo: batch size should be between 1-250"}
+ }
+ c.BatchSize = batch
+ return nil
+ }
+}
+
+// SetIdealBatchItemSize configures the target word count per chunk (1-2500).
+func SetIdealBatchItemSize(size int) ConfigOption {
+ return func(c *Config) error {
+ if size < 1 || size > 2500 {
+ return &ValueError{Message: "lingo: ideal batch item size should be between 1-2500"}
+ }
+ c.IdealBatchItemSize = size
+ return nil
+ }
+}
+
+func newEngineConfig(apiKey string, opts ...ConfigOption) (*Config, error) {
+ const (
+ defaultAPIURL = "https://engine.lingo.dev"
+ defaultBatchSize = 25
+ defaultIdealBatchItemSize = 250
+ )
+
+ c := &Config{
+ APIKey: apiKey,
+ APIURL: defaultAPIURL,
+ BatchSize: defaultBatchSize,
+ IdealBatchItemSize: defaultIdealBatchItemSize,
+ }
+
+ for _, opt := range opts {
+ if err := opt(c); err != nil {
+ return nil, err
+ }
+ }
+
+ return c, nil
+}
diff --git a/engine.go b/engine.go
new file mode 100644
index 0000000..5afc3eb
--- /dev/null
+++ b/engine.go
@@ -0,0 +1,313 @@
+package lingo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sync"
+
+ gonanoid "github.com/matoous/go-nanoid/v2"
+ "golang.org/x/sync/errgroup"
+)
+
+// localizeRaw splits payload into chunks and localizes each chunk,
+// either sequentially or concurrently based on the concurrent flag.
+func (c *Client) localizeRaw(ctx context.Context, payload map[string]any, params LocalizationParams, concurrent bool) (map[string]any, error) {
+ chunks := c.ExtractChunks(payload)
+ if len(chunks) == 0 {
+ return map[string]any{}, nil
+ }
+
+ workflowID, err := gonanoid.New()
+ if err != nil {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: failed to generate workflow id: %s", err)}
+ }
+
+ fast := false
+ if params.Fast != nil {
+ fast = *params.Fast
+ }
+
+ merged := make(map[string]any)
+
+ if concurrent {
+ var mu sync.Mutex
+ g, gCtx := errgroup.WithContext(ctx)
+
+ for _, chunk := range chunks {
+ chunk := chunk
+ chunkPayload := map[string]any{"data": chunk}
+ if params.Reference != nil {
+ chunkPayload["reference"] = params.Reference
+ }
+
+ g.Go(func() error {
+ result, err := c.localizeChunk(gCtx, params.SourceLocale, workflowID, params.TargetLocale, chunkPayload, fast)
+ if err != nil {
+ return err
+ }
+
+ resultMap, ok := result.(map[string]any)
+ if !ok {
+ return &RuntimeError{Message: "lingo: unexpected response type from server"}
+ }
+
+ mu.Lock()
+ for k, v := range resultMap {
+ merged[k] = v
+ }
+ mu.Unlock()
+
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+ } else {
+ for _, chunk := range chunks {
+ chunkPayload := map[string]any{"data": chunk}
+ if params.Reference != nil {
+ chunkPayload["reference"] = params.Reference
+ }
+
+ result, err := c.localizeChunk(ctx, params.SourceLocale, workflowID, params.TargetLocale, chunkPayload, fast)
+ if err != nil {
+ return nil, err
+ }
+
+ resultMap, ok := result.(map[string]any)
+ if !ok {
+ return nil, &RuntimeError{Message: "lingo: unexpected response type from server"}
+ }
+
+ for k, v := range resultMap {
+ merged[k] = v
+ }
+ }
+ }
+
+ return merged, nil
+}
+
+// LocalizeText translates a single text string to the target locale specified in params.
+func (c *Client) LocalizeText(ctx context.Context, text string, params LocalizationParams) (string, error) {
+ if text == "" {
+ return "", &ValueError{Message: "lingo: text must not be empty"}
+ }
+
+ payload := map[string]any{"text": text}
+
+ result, err := c.localizeRaw(ctx, payload, params, false)
+ if err != nil {
+ return "", err
+ }
+
+ localized, ok := result["text"].(string)
+ if !ok {
+ return "", &RuntimeError{Message: "lingo: unexpected response type for localized text"}
+ }
+
+ return localized, nil
+}
+
+// LocalizeObject translates all string values in the given map to the target locale specified in params.
+func (c *Client) LocalizeObject(ctx context.Context, obj map[string]any, params LocalizationParams, concurrent bool) (map[string]any, error) {
+ return c.localizeRaw(ctx, obj, params, concurrent)
+}
+
+// LocalizeChat translates the text field of each chat message to the target locale specified in params.
+func (c *Client) LocalizeChat(ctx context.Context, chat []map[string]string, params LocalizationParams) ([]map[string]string, error) {
+ if len(chat) == 0 {
+ return []map[string]string{}, nil
+ }
+
+ for i, msg := range chat {
+ if _, ok := msg["name"]; !ok {
+ return nil, &ValueError{Message: fmt.Sprintf("lingo: chat message at index %d is missing 'name' field", i)}
+ }
+ if _, ok := msg["text"]; !ok {
+ return nil, &ValueError{Message: fmt.Sprintf("lingo: chat message at index %d is missing 'text' field", i)}
+ }
+ }
+
+ chatPayload := make([]any, len(chat))
+ for i, msg := range chat {
+ chatPayload[i] = map[string]any{
+ "name": msg["name"],
+ "text": msg["text"],
+ }
+ }
+ payload := map[string]any{"chat": chatPayload}
+
+ result, err := c.localizeRaw(ctx, payload, params, false)
+ if err != nil {
+ return nil, err
+ }
+
+ rawChat, ok := result["chat"].([]any)
+ if !ok {
+ return nil, &RuntimeError{Message: "lingo: unexpected response type for localized chat"}
+ }
+
+ if len(rawChat) != len(chat) {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: expected %d chat messages but got %d", len(chat), len(rawChat))}
+ }
+
+ localized := make([]map[string]string, len(rawChat))
+ for i, item := range rawChat {
+ msgMap, ok := item.(map[string]any)
+ if !ok {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: unexpected response type for chat message at index %d", i)}
+ }
+ name, ok := msgMap["name"].(string)
+ if !ok {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: unexpected response type for chat message name at index %d", i)}
+ }
+ text, ok := msgMap["text"].(string)
+ if !ok {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: unexpected response type for chat message text at index %d", i)}
+ }
+ localized[i] = map[string]string{
+ "name": name,
+ "text": text,
+ }
+ }
+
+ return localized, nil
+}
+
+// RecognizeLocale detects the locale of the given text.
+func (c *Client) RecognizeLocale(ctx context.Context, text string) (string, error) {
+ if text == "" {
+ return "", &ValueError{Message: "lingo: text must not be empty"}
+ }
+
+ endpoint, err := url.JoinPath(c.config.APIURL, "/recognize")
+ if err != nil {
+ return "", &RuntimeError{Message: fmt.Sprintf("lingo: unable to join path: %s", err)}
+ }
+
+ requestData := map[string]any{"text": text}
+
+ result, err := c.do(ctx, endpoint, requestData)
+ if err != nil {
+ return "", err
+ }
+
+ locale, ok := result["locale"].(string)
+ if !ok {
+ return "", &RuntimeError{Message: "lingo: missing locale field in response"}
+ }
+
+ return locale, nil
+}
+
+// WhoAmI returns the authenticated user's information, or nil if not authenticated.
+func (c *Client) WhoAmI(ctx context.Context) (map[string]string, error) {
+ endpoint, err := url.JoinPath(c.config.APIURL, "/whoami")
+ if err != nil {
+ return nil, &RuntimeError{Message: fmt.Sprintf("lingo: unable to join path: %s", err)}
+ }
+
+ result, err := c.do(ctx, endpoint, map[string]any{})
+ if err != nil {
+ var re *RuntimeError
+ if errors.As(err, &re) && re.StatusCode == http.StatusUnauthorized {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ data := result["data"]
+ if data == nil {
+ return nil, nil
+ }
+
+ dataMap, ok := data.(map[string]any)
+ if !ok {
+ return nil, &RuntimeError{Message: "lingo: unexpected response type for whoami"}
+ }
+
+ info := make(map[string]string, len(dataMap))
+ for k, v := range dataMap {
+ str, ok := v.(string)
+ if !ok {
+ continue
+ }
+ info[k] = str
+ }
+
+ if len(info) == 0 {
+ return nil, nil
+ }
+
+ return info, nil
+}
+
+// BatchLocalizeText translates a single text string into multiple target locales concurrently.
+func (c *Client) BatchLocalizeText(ctx context.Context, text string, sourceLocale *string, fast *bool, targetLocales []string) ([]string, error) {
+ if text == "" {
+ return nil, &ValueError{Message: "lingo: text must not be empty"}
+ }
+ if len(targetLocales) == 0 {
+ return []string{}, nil
+ }
+
+ results := make([]string, len(targetLocales))
+ g, gCtx := errgroup.WithContext(ctx)
+
+ for i, targetLocale := range targetLocales {
+ i, targetLocale := i, targetLocale
+ params := LocalizationParams{
+ SourceLocale: sourceLocale,
+ TargetLocale: targetLocale,
+ Fast: fast,
+ }
+ g.Go(func() error {
+ localized, err := c.LocalizeText(gCtx, text, params)
+ if err != nil {
+ return err
+ }
+ results[i] = localized
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
+
+// BatchLocalizeObjects translates multiple objects concurrently using the same localization params.
+func (c *Client) BatchLocalizeObjects(ctx context.Context, objects []map[string]any, params LocalizationParams) ([]map[string]any, error) {
+ if len(objects) == 0 {
+ return []map[string]any{}, nil
+ }
+
+ results := make([]map[string]any, len(objects))
+ g, gCtx := errgroup.WithContext(ctx)
+
+ for i, obj := range objects {
+ i, obj := i, obj
+ g.Go(func() error {
+ localized, err := c.LocalizeObject(gCtx, obj, params, false)
+ if err != nil {
+ return err
+ }
+ results[i] = localized
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
diff --git a/error.go b/error.go
new file mode 100644
index 0000000..c60b701
--- /dev/null
+++ b/error.go
@@ -0,0 +1,21 @@
+package lingo
+
+// ValueError represents a validation or bad request error.
+type ValueError struct {
+ Message string
+}
+
+func (v *ValueError) Error() string {
+ return v.Message
+}
+
+// RuntimeError represents a server-side or network error.
+// StatusCode contains the HTTP status code when available, or 0 otherwise.
+type RuntimeError struct {
+ Message string
+ StatusCode int
+}
+
+func (r *RuntimeError) Error() string {
+ return r.Message
+}
diff --git a/examples/main.go b/examples/main.go
new file mode 100644
index 0000000..5b4d72a
--- /dev/null
+++ b/examples/main.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func main() {
+ apiKey := os.Getenv("LINGODOTDEV_API_KEY")
+ if apiKey == "" {
+ fmt.Println("LINGODOTDEV_API_KEY environment variable is not set.")
+ fmt.Println("Set it with: export LINGODOTDEV_API_KEY=\"your-api-key\"")
+ os.Exit(1)
+ }
+
+ client, err := lingo.NewClient(apiKey)
+ if err != nil {
+ log.Fatalf("failed to create client: %s", err)
+ }
+
+ ctx := context.Background()
+
+ // --- LocalizeText ---
+ fmt.Println("=== LocalizeText ===")
+ translated, err := client.LocalizeText(ctx, "Hello, world!", lingo.LocalizationParams{
+ TargetLocale: "es",
+ })
+ if err != nil {
+ log.Fatalf("LocalizeText error: %s", err)
+ }
+ fmt.Printf("Translated: %s\n\n", translated)
+
+ // --- LocalizeObject ---
+ fmt.Println("=== LocalizeObject ===")
+ obj := map[string]any{
+ "greeting": "Hello",
+ "farewell": "Goodbye",
+ "question": "How are you?",
+ }
+ objResult, err := client.LocalizeObject(ctx, obj, lingo.LocalizationParams{
+ TargetLocale: "fr",
+ }, true)
+ if err != nil {
+ log.Fatalf("LocalizeObject error: %s", err)
+ }
+ for k, v := range objResult {
+ fmt.Printf(" %s: %v\n", k, v)
+ }
+ fmt.Println()
+
+ // --- BatchLocalizeText ---
+ fmt.Println("=== BatchLocalizeText ===")
+ locales := []string{"es", "fr", "de"}
+ batchResults, err := client.BatchLocalizeText(ctx, "Welcome to our application", nil, nil, locales)
+ if err != nil {
+ log.Fatalf("BatchLocalizeText error: %s", err)
+ }
+ for i, result := range batchResults {
+ fmt.Printf(" %s: %s\n", locales[i], result)
+ }
+ fmt.Println()
+
+ // --- LocalizeChat ---
+ fmt.Println("=== LocalizeChat ===")
+ chat := []map[string]string{
+ {"name": "Alice", "text": "Hello everyone!"},
+ {"name": "Bob", "text": "How are you?"},
+ }
+ chatResult, err := client.LocalizeChat(ctx, chat, lingo.LocalizationParams{
+ TargetLocale: "es",
+ })
+ if err != nil {
+ log.Fatalf("LocalizeChat error: %s", err)
+ }
+ for _, msg := range chatResult {
+ fmt.Printf(" %s: %s\n", msg["name"], msg["text"])
+ }
+ fmt.Println()
+
+ // --- RecognizeLocale ---
+ fmt.Println("=== RecognizeLocale ===")
+ locale, err := client.RecognizeLocale(ctx, "Bonjour le monde")
+ if err != nil {
+ log.Fatalf("RecognizeLocale error: %s", err)
+ }
+ fmt.Printf("Detected locale: %s\n\n", locale)
+
+ // --- WhoAmI ---
+ fmt.Println("=== WhoAmI ===")
+ info, err := client.WhoAmI(ctx)
+ if err != nil {
+ log.Fatalf("WhoAmI error: %s", err)
+ }
+ if info == nil {
+ fmt.Println("Not authenticated or no user info available.")
+ } else {
+ for k, v := range info {
+ fmt.Printf(" %s: %s\n", k, v)
+ }
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2a1887a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,8 @@
+module github.com/lingodotdev/sdk-go
+
+go 1.21.0
+
+require (
+ github.com/matoous/go-nanoid/v2 v2.1.0
+ golang.org/x/sync v0.6.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7eebd80
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
+github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/model.go b/model.go
new file mode 100644
index 0000000..7e81618
--- /dev/null
+++ b/model.go
@@ -0,0 +1,27 @@
+package lingo
+
+// LocalizationParams contains parameters for a localization request.
+// SourceLocale and Fast are optional pointer types β pass nil to use API defaults.
+type LocalizationParams struct {
+ SourceLocale *string `json:"source_locale,omitempty"`
+ TargetLocale string `json:"target_locale"`
+ Fast *bool `json:"fast,omitempty"`
+ Reference map[string]map[string]any `json:"reference,omitempty"`
+}
+
+type requestData struct {
+ Param parameter `json:"params"`
+ Locale locale `json:"locale"`
+ Data any `json:"data"`
+ Reference map[string]map[string]any `json:"reference,omitempty"`
+}
+
+type parameter struct {
+ WorkflowID string `json:"workflowId"`
+ Fast bool `json:"fast"`
+}
+
+type locale struct {
+ Source *string `json:"source,omitempty"`
+ Target string `json:"target"`
+}
diff --git a/tests/client_test.go b/tests/client_test.go
new file mode 100644
index 0000000..1364fe0
--- /dev/null
+++ b/tests/client_test.go
@@ -0,0 +1,769 @@
+package tests
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func TestNewClient_MissingAPIKey(t *testing.T) {
+ _, err := lingo.NewClient("")
+ if err == nil {
+ t.Fatal("expected error for missing api key")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "api key") {
+ t.Errorf("expected error about api key, got: %s", ve.Message)
+ }
+}
+
+func TestNewClient_InvalidURL(t *testing.T) {
+ _, err := lingo.NewClient("test-key", lingo.SetURL("not-a-url"))
+ if err == nil {
+ t.Fatal("expected error for invalid url")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "url") {
+ t.Errorf("expected error about url, got: %s", ve.Message)
+ }
+}
+
+func TestNewClient_InvalidBatchSize(t *testing.T) {
+ _, err := lingo.NewClient("test-key", lingo.SetBatchSize(0))
+ if err == nil {
+ t.Fatal("expected error for batch size 0")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "batch size") {
+ t.Errorf("expected error about batch size, got: %s", ve.Message)
+ }
+
+ _, err = lingo.NewClient("test-key", lingo.SetBatchSize(300))
+ if err == nil {
+ t.Fatal("expected error for batch size > 250")
+ }
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+}
+
+func TestNewClient_InvalidIdealBatchItemSize(t *testing.T) {
+ _, err := lingo.NewClient("test-key", lingo.SetIdealBatchItemSize(0))
+ if err == nil {
+ t.Fatal("expected error for ideal batch item size 0")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "ideal batch item size") {
+ t.Errorf("expected error about ideal batch item size, got: %s", ve.Message)
+ }
+
+ _, err = lingo.NewClient("test-key", lingo.SetIdealBatchItemSize(3000))
+ if err == nil {
+ t.Fatal("expected error for ideal batch item size > 2500")
+ }
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+}
+
+func TestCountWords(t *testing.T) {
+ tests := []struct {
+ name string
+ payload any
+ expected int
+ }{
+ {name: "empty string", payload: "", expected: 0},
+ {name: "single word", payload: "hello", expected: 1},
+ {name: "multiple words", payload: "hello world foo", expected: 3},
+ {name: "nested map", payload: map[string]any{"a": "one two", "b": "three"}, expected: 3},
+ {name: "nested slice", payload: []any{"one two", "three four five"}, expected: 5},
+ {name: "integer", payload: 42, expected: 0},
+ {name: "nil", payload: nil, expected: 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := lingo.CountWords(tt.payload)
+ if result != tt.expected {
+ t.Errorf("CountWords(%v) = %d, want %d", tt.payload, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestExtractChunks_RespectsItemLimit(t *testing.T) {
+ client, err := lingo.NewClient("test-key", lingo.SetBatchSize(2))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := map[string]any{
+ "a": "hello",
+ "b": "world",
+ "c": "foo",
+ "d": "bar",
+ }
+
+ chunks := client.ExtractChunks(payload)
+ if len(chunks) < 2 {
+ t.Fatalf("expected at least 2 chunks with batch size 2 and 4 items, got %d", len(chunks))
+ }
+
+ for i, chunk := range chunks {
+ if len(chunk) > 2 {
+ t.Errorf("chunk %d has %d items, expected at most 2", i, len(chunk))
+ }
+ }
+}
+
+func TestExtractChunks_PreservesAllKeys(t *testing.T) {
+ client, err := lingo.NewClient("test-key", lingo.SetBatchSize(2))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := map[string]any{
+ "a": "hello",
+ "b": "world",
+ "c": "foo",
+ "d": "bar",
+ }
+
+ chunks := client.ExtractChunks(payload)
+ allKeys := make(map[string]bool)
+ for _, chunk := range chunks {
+ for k := range chunk {
+ allKeys[k] = true
+ }
+ }
+ if len(allKeys) != len(payload) {
+ t.Errorf("expected %d total keys across all chunks, got %d", len(payload), len(allKeys))
+ }
+ for k := range payload {
+ if !allKeys[k] {
+ t.Errorf("key %q missing from chunks", k)
+ }
+ }
+}
+
+func TestExtractChunks_EmptyPayload(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ chunks := client.ExtractChunks(map[string]any{})
+ if len(chunks) != 0 {
+ t.Errorf("expected 0 chunks for empty payload, got %d", len(chunks))
+ }
+}
+
+func TestLocalizeText_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+ if r.Header.Get("Authorization") != "Bearer test-key" {
+ t.Errorf("expected Authorization 'Bearer test-key', got %s", r.Header.Get("Authorization"))
+ }
+ if r.Header.Get("Content-Type") != "application/json; charset=utf-8" {
+ t.Errorf("unexpected content type: %s", r.Header.Get("Content-Type"))
+ }
+ if !strings.HasSuffix(r.URL.Path, "/i18n") {
+ t.Errorf("expected request path ending with /i18n, got %s", r.URL.Path)
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("failed to read request body: %s", err)
+ }
+ var reqBody map[string]any
+ if err := json.Unmarshal(body, &reqBody); err != nil {
+ t.Fatalf("failed to decode request body: %s", err)
+ }
+
+ localeMap, ok := reqBody["locale"].(map[string]any)
+ if !ok {
+ t.Fatal("expected locale field in request body")
+ }
+ if localeMap["target"] != "es" {
+ t.Errorf("expected locale.target 'es', got %v", localeMap["target"])
+ }
+
+ dataMap, ok := reqBody["data"].(map[string]any)
+ if !ok {
+ t.Fatal("expected data field in request body")
+ }
+ if dataMap["text"] != "hello" {
+ t.Errorf("expected data.text 'hello', got %v", dataMap["text"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"text": "hola"},
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ result, err := client.LocalizeText(context.Background(), "hello", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if result != "hola" {
+ t.Fatalf("expected 'hola', got '%s'", result)
+ }
+}
+
+func TestLocalizeText_EmptyText(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(context.Background(), "", params)
+ if err == nil {
+ t.Fatal("expected error for empty text")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "text must not be empty") {
+ t.Errorf("expected error about empty text, got: %s", ve.Message)
+ }
+}
+
+func TestLocalizeText_ServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"error": "internal server error"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(context.Background(), "hello", params)
+ if err == nil {
+ t.Fatal("expected error for server error")
+ }
+ var re *lingo.RuntimeError
+ if !errors.As(err, &re) {
+ t.Fatalf("expected *RuntimeError, got %T", err)
+ }
+ if !strings.Contains(re.Message, "server error") {
+ t.Errorf("expected error message to contain 'server error', got: %s", re.Message)
+ }
+}
+
+func TestLocalizeText_BadRequest(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"error": "bad request"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(context.Background(), "hello", params)
+ if err == nil {
+ t.Fatal("expected error for bad request")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T: %s", err, err)
+ }
+ if !strings.Contains(ve.Message, "invalid request") {
+ t.Errorf("expected error about invalid request, got: %s", ve.Message)
+ }
+}
+
+func TestLocalizeText_StreamingError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "error": "streaming error occurred",
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(context.Background(), "hello", params)
+ if err == nil {
+ t.Fatal("expected error for streaming error response")
+ }
+ var re *lingo.RuntimeError
+ if !errors.As(err, &re) {
+ t.Fatalf("expected *RuntimeError, got %T", err)
+ }
+ if !strings.Contains(re.Message, "streaming error occurred") {
+ t.Errorf("expected error about streaming error, got: %s", re.Message)
+ }
+}
+
+func TestLocalizeChat_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{
+ "chat": []any{
+ map[string]any{"name": "user", "text": "hola"},
+ map[string]any{"name": "bot", "text": "adiΓ³s"},
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ chat := []map[string]string{
+ {"name": "user", "text": "hello"},
+ {"name": "bot", "text": "goodbye"},
+ }
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ result, err := client.LocalizeChat(context.Background(), chat, params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(result) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(result))
+ }
+ if result[0]["name"] != "user" || result[0]["text"] != "hola" {
+ t.Errorf("unexpected first message: %v", result[0])
+ }
+ if result[1]["name"] != "bot" || result[1]["text"] != "adiΓ³s" {
+ t.Errorf("unexpected second message: %v", result[1])
+ }
+}
+
+func TestLocalizeChat_MissingNameField(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ chat := []map[string]string{
+ {"text": "hello"},
+ }
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeChat(context.Background(), chat, params)
+ if err == nil {
+ t.Fatal("expected error for missing name field")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "index 0") {
+ t.Errorf("expected error mentioning index 0, got: %s", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "name") {
+ t.Errorf("expected error mentioning 'name', got: %s", ve.Message)
+ }
+}
+
+func TestLocalizeChat_MissingTextField(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ chat := []map[string]string{
+ {"name": "user"},
+ }
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeChat(context.Background(), chat, params)
+ if err == nil {
+ t.Fatal("expected error for missing text field")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "index 0") {
+ t.Errorf("expected error mentioning index 0, got: %s", ve.Message)
+ }
+ if !strings.Contains(ve.Message, "text") {
+ t.Errorf("expected error mentioning 'text', got: %s", ve.Message)
+ }
+}
+
+func TestLocalizeChat_EmptyChat(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ result, err := client.LocalizeChat(context.Background(), []map[string]string{}, params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(result) != 0 {
+ t.Fatalf("expected empty result, got %d items", len(result))
+ }
+}
+
+func TestLocalizeChat_ResponseLengthMismatch(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{
+ "chat": []any{
+ map[string]any{"name": "user", "text": "hola"},
+ },
+ },
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ chat := []map[string]string{
+ {"name": "user", "text": "hello"},
+ {"name": "bot", "text": "goodbye"},
+ }
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeChat(context.Background(), chat, params)
+ if err == nil {
+ t.Fatal("expected error for response length mismatch")
+ }
+ var re *lingo.RuntimeError
+ if !errors.As(err, &re) {
+ t.Fatalf("expected *RuntimeError, got %T", err)
+ }
+ if !strings.Contains(re.Message, "expected 2") {
+ t.Errorf("expected error about message count mismatch, got: %s", re.Message)
+ }
+}
+
+func TestBatchLocalizeText_EmptyLocales(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ result, err := client.BatchLocalizeText(context.Background(), "hello", nil, nil, []string{})
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(result) != 0 {
+ t.Fatalf("expected empty result, got %d items", len(result))
+ }
+}
+
+func TestBatchLocalizeText_Success(t *testing.T) {
+ translations := map[string]string{
+ "es": "hola",
+ "fr": "bonjour",
+ "de": "hallo",
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("failed to read request body: %s", err)
+ return
+ }
+ var reqBody map[string]any
+ if err := json.Unmarshal(body, &reqBody); err != nil {
+ t.Errorf("failed to decode request body: %s", err)
+ return
+ }
+
+ localeMap, ok := reqBody["locale"].(map[string]any)
+ if !ok {
+ t.Error("expected locale field in request body")
+ return
+ }
+ target, ok := localeMap["target"].(string)
+ if !ok {
+ t.Error("expected locale.target string in request body")
+ return
+ }
+
+ translated, exists := translations[target]
+ if !exists {
+ translated = "unknown"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"text": translated},
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ locales := []string{"es", "fr", "de"}
+ results, err := client.BatchLocalizeText(context.Background(), "hello", nil, nil, locales)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(results) != 3 {
+ t.Fatalf("expected 3 results, got %d", len(results))
+ }
+ for i, locale := range locales {
+ expected := translations[locale]
+ if results[i] != expected {
+ t.Errorf("locale %s: expected '%s', got '%s'", locale, expected, results[i])
+ }
+ }
+}
+
+func TestBatchLocalizeText_EmptyText(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ _, err = client.BatchLocalizeText(context.Background(), "", nil, nil, []string{"es"})
+ if err == nil {
+ t.Fatal("expected error for empty text")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "text must not be empty") {
+ t.Errorf("expected error about empty text, got: %s", ve.Message)
+ }
+}
+
+// --- WHOAMI TESTS ---
+
+func TestWhoAmI_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{
+ "email": "test@example.com",
+ "id": "user-123",
+ },
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ result, err := client.WhoAmI(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result["email"] != "test@example.com" {
+ t.Errorf("expected email 'test@example.com', got '%s'", result["email"])
+ }
+ if result["id"] != "user-123" {
+ t.Errorf("expected id 'user-123', got '%s'", result["id"])
+ }
+}
+
+func TestWhoAmI_Unauthenticated(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(`{"error": "unauthorized"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ result, err := client.WhoAmI(context.Background())
+ if err != nil {
+ t.Fatalf("expected nil error for unauthenticated, got: %s", err)
+ }
+ if result != nil {
+ t.Fatalf("expected nil result for unauthenticated, got: %v", result)
+ }
+}
+
+func TestWhoAmI_ServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"error": "internal server error"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ _, err = client.WhoAmI(context.Background())
+ if err == nil {
+ t.Fatal("expected error for server error")
+ }
+ var re *lingo.RuntimeError
+ if !errors.As(err, &re) {
+ t.Fatalf("expected *RuntimeError, got %T", err)
+ }
+ if !strings.Contains(re.Message, "server error") {
+ t.Errorf("expected error about server error, got: %s", re.Message)
+ }
+}
+
+func TestRecognizeLocale_EmptyText(t *testing.T) {
+ client, err := lingo.NewClient("test-key")
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ _, err = client.RecognizeLocale(context.Background(), "")
+ if err == nil {
+ t.Fatal("expected error for empty text")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T", err)
+ }
+ if !strings.Contains(ve.Message, "text must not be empty") {
+ t.Errorf("expected error about empty text, got: %s", ve.Message)
+ }
+}
+
+func TestRecognizeLocale_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasSuffix(r.URL.Path, "/recognize") {
+ t.Errorf("expected path ending with /recognize, got %s", r.URL.Path)
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("failed to read request body: %s", err)
+ }
+ var reqBody map[string]any
+ if err := json.Unmarshal(body, &reqBody); err != nil {
+ t.Fatalf("failed to decode request body: %s", err)
+ }
+ if reqBody["text"] != "hello world" {
+ t.Errorf("expected text 'hello world', got %v", reqBody["text"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "locale": "en",
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ locale, err := client.RecognizeLocale(context.Background(), "hello world")
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if locale != "en" {
+ t.Fatalf("expected 'en', got '%s'", locale)
+ }
+}
+
+func TestRecognizeLocale_ServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"error": "internal server error"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ _, err = client.RecognizeLocale(context.Background(), "hello")
+ if err == nil {
+ t.Fatal("expected error for server error")
+ }
+ var re *lingo.RuntimeError
+ if !errors.As(err, &re) {
+ t.Fatalf("expected *RuntimeError, got %T", err)
+ }
+ if !strings.Contains(re.Message, "server error") {
+ t.Errorf("expected error about server error, got: %s", re.Message)
+ }
+}
+
+func TestTruncateResponse_Short(t *testing.T) {
+ short := "hello"
+ result := lingo.TruncateResponse(short)
+ if result != short {
+ t.Errorf("expected '%s', got '%s'", short, result)
+ }
+}
+
+func TestTruncateResponse_Long(t *testing.T) {
+ long := strings.Repeat("x", 300)
+ result := lingo.TruncateResponse(long)
+ if len(result) != lingo.MaxResponseLength+3 {
+ t.Errorf("expected length %d, got %d", lingo.MaxResponseLength+3, len(result))
+ }
+ if !strings.HasSuffix(result, "...") {
+ t.Errorf("expected truncated string to end with '...', got '%s'", result[len(result)-3:])
+ }
+}
+
+func TestTruncateResponse_ExactLength(t *testing.T) {
+ exact := strings.Repeat("x", lingo.MaxResponseLength)
+ result := lingo.TruncateResponse(exact)
+ if result != exact {
+ t.Errorf("expected string of length %d to not be truncated", lingo.MaxResponseLength)
+ }
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
new file mode 100644
index 0000000..e50b860
--- /dev/null
+++ b/tests/integration_test.go
@@ -0,0 +1,636 @@
+package tests
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ lingo "github.com/lingodotdev/sdk-go"
+)
+
+func skipIfNoAPIKey(t *testing.T) (string, string) {
+ t.Helper()
+ apiKey := os.Getenv("LINGODOTDEV_API_KEY")
+ if apiKey == "" {
+ t.Skip("LINGODOTDEV_API_KEY not set, skipping real API test")
+ }
+ apiURL := os.Getenv("LINGODOTDEV_API_URL")
+ if apiURL == "" {
+ apiURL = "https://engine.lingo.dev"
+ }
+ return apiKey, apiURL
+}
+
+func newRealClient(t *testing.T) *lingo.Client {
+ t.Helper()
+ apiKey, apiURL := skipIfNoAPIKey(t)
+ client, err := lingo.NewClient(apiKey, lingo.SetURL(apiURL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+ return client
+}
+
+func strPtr(s string) *string { return &s }
+func boolPtr(b bool) *bool { return &b }
+
+func TestRealAPI_LocalizeText(t *testing.T) {
+ client := newRealClient(t)
+
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ }
+ result, err := client.LocalizeText(context.Background(), "Hello, world!", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if result == "" {
+ t.Fatal("expected non-empty result")
+ }
+ if result == "Hello, world!" {
+ t.Fatal("expected translated text, got original")
+ }
+ t.Logf("LocalizeText result: %s", result)
+}
+
+func TestRealAPI_LocalizeObject(t *testing.T) {
+ client := newRealClient(t)
+
+ obj := map[string]any{
+ "greeting": "Hello",
+ "farewell": "Goodbye",
+ "question": "How are you?",
+ }
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "fr",
+ }
+ result, err := client.LocalizeObject(context.Background(), obj, params, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(result) != 3 {
+ t.Fatalf("expected 3 keys, got %d", len(result))
+ }
+ for _, key := range []string{"greeting", "farewell", "question"} {
+ val, ok := result[key]
+ if !ok {
+ t.Errorf("missing key %q in result", key)
+ continue
+ }
+ valStr, ok := val.(string)
+ if !ok {
+ t.Errorf("expected string for key %q, got %T", key, val)
+ continue
+ }
+ if valStr == obj[key] {
+ t.Errorf("expected translated value for key %q, got original: %s", key, valStr)
+ }
+ }
+ t.Logf("LocalizeObject result: %v", result)
+}
+
+func TestRealAPI_BatchLocalizeText(t *testing.T) {
+ client := newRealClient(t)
+
+ original := "Welcome to our application"
+ locales := []string{"es", "fr", "de"}
+ results, err := client.BatchLocalizeText(context.Background(), original, strPtr("en"), nil, locales)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(results) != 3 {
+ t.Fatalf("expected 3 results, got %d", len(results))
+ }
+ for i, result := range results {
+ if result == "" {
+ t.Errorf("locale %s: expected non-empty result", locales[i])
+ }
+ if result == original {
+ t.Errorf("locale %s: expected translated text, got original", locales[i])
+ }
+ }
+ t.Logf("BatchLocalizeText results: %v", results)
+}
+
+func TestRealAPI_LocalizeChat(t *testing.T) {
+ client := newRealClient(t)
+
+ chat := []map[string]string{
+ {"name": "Alice", "text": "Hello everyone!"},
+ {"name": "Bob", "text": "How are you doing?"},
+ {"name": "Charlie", "text": "I'm doing great, thanks!"},
+ }
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ }
+ result, err := client.LocalizeChat(context.Background(), chat, params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(result) != 3 {
+ t.Fatalf("expected 3 messages, got %d", len(result))
+ }
+ expectedNames := []string{"Alice", "Bob", "Charlie"}
+ for i, msg := range result {
+ name, ok := msg["name"]
+ if !ok {
+ t.Errorf("message %d: missing 'name' key", i)
+ continue
+ }
+ text, ok := msg["text"]
+ if !ok {
+ t.Errorf("message %d: missing 'text' key", i)
+ continue
+ }
+ if name != expectedNames[i] {
+ t.Errorf("message %d: expected name %q, got %q", i, expectedNames[i], name)
+ }
+ if text == chat[i]["text"] {
+ t.Errorf("message %d: expected translated text, got original: %s", i, text)
+ }
+ }
+ t.Logf("LocalizeChat results: %v", result)
+}
+
+func TestRealAPI_RecognizeLocale(t *testing.T) {
+ client := newRealClient(t)
+
+ tests := []struct {
+ name string
+ text string
+ }{
+ {name: "english", text: "Hello, how are you?"},
+ {name: "spanish", text: "Hola, ΒΏcΓ³mo estΓ‘s?"},
+ {name: "french", text: "Bonjour, comment allez-vous?"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ locale, err := client.RecognizeLocale(context.Background(), tt.text)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if locale == "" {
+ t.Fatal("expected non-empty locale")
+ }
+ t.Logf("RecognizeLocale(%q) = %s", tt.text, locale)
+ })
+ }
+}
+
+func TestRealAPI_WhoAmI(t *testing.T) {
+ client := newRealClient(t)
+
+ result, err := client.WhoAmI(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if result == nil {
+ t.Log("WhoAmI returned nil (unauthenticated or no user info)")
+ return
+ }
+ email, ok := result["email"]
+ if !ok {
+ t.Error("expected 'email' field in result")
+ } else if !strings.Contains(email, "@") {
+ t.Errorf("expected email to contain '@', got: %s", email)
+ }
+ if _, ok := result["id"]; !ok {
+ t.Error("expected 'id' field in result")
+ }
+ t.Logf("WhoAmI result: %v", result)
+}
+
+func TestRealAPI_FastMode(t *testing.T) {
+ client := newRealClient(t)
+
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ Fast: boolPtr(true),
+ }
+ fastResult, err := client.LocalizeText(context.Background(), "Hello, world!", params)
+ if err != nil {
+ t.Fatalf("fast mode error: %s", err)
+ }
+
+ params.Fast = boolPtr(false)
+ normalResult, err := client.LocalizeText(context.Background(), "Hello, world!", params)
+ if err != nil {
+ t.Fatalf("normal mode error: %s", err)
+ }
+
+ if fastResult == "" {
+ t.Error("fast mode returned empty result")
+ }
+ if normalResult == "" {
+ t.Error("normal mode returned empty result")
+ }
+ if fastResult == "Hello, world!" {
+ t.Error("fast mode returned original text")
+ }
+ if normalResult == "Hello, world!" {
+ t.Error("normal mode returned original text")
+ }
+ t.Logf("Fast result: %s", fastResult)
+ t.Logf("Normal result: %s", normalResult)
+}
+
+func TestRealAPI_ConcurrentVsSequential(t *testing.T) {
+ apiKey, apiURL := skipIfNoAPIKey(t)
+ client, err := lingo.NewClient(apiKey, lingo.SetURL(apiURL), lingo.SetBatchSize(2))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := make(map[string]any)
+ for i := 0; i < 10; i++ {
+ payload[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("Test content number %d for performance testing", i)
+ }
+
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ }
+
+ start := time.Now()
+ _, err = client.LocalizeObject(context.Background(), payload, params, false)
+ if err != nil {
+ t.Fatalf("sequential error: %s", err)
+ }
+ seqDuration := time.Since(start)
+
+ start = time.Now()
+ _, err = client.LocalizeObject(context.Background(), payload, params, true)
+ if err != nil {
+ t.Fatalf("concurrent error: %s", err)
+ }
+ concDuration := time.Since(start)
+
+ t.Logf("Sequential duration: %s", seqDuration)
+ t.Logf("Concurrent duration: %s", concDuration)
+}
+
+func TestRealAPI_BatchLocalizeObjects(t *testing.T) {
+ client := newRealClient(t)
+
+ objects := []map[string]any{
+ {"greeting": "Hello", "question": "How are you?"},
+ {"farewell": "Goodbye", "thanks": "Thank you"},
+ {"welcome": "Welcome", "help": "Can I help you?"},
+ }
+ params := lingo.LocalizationParams{
+ SourceLocale: strPtr("en"),
+ TargetLocale: "es",
+ }
+ results, err := client.BatchLocalizeObjects(context.Background(), objects, params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if len(results) != 3 {
+ t.Fatalf("expected 3 results, got %d", len(results))
+ }
+ for i, result := range results {
+ for key, origVal := range objects[i] {
+ val, ok := result[key]
+ if !ok {
+ t.Errorf("object %d: missing key %q", i, key)
+ continue
+ }
+ valStr, ok := val.(string)
+ if !ok {
+ t.Errorf("object %d: expected string for key %q, got %T", i, key, val)
+ continue
+ }
+ if valStr == origVal {
+ t.Errorf("object %d: expected translated value for key %q, got original: %s", i, key, valStr)
+ }
+ }
+ }
+ t.Logf("BatchLocalizeObjects results: %v", results)
+}
+
+func TestMocked_LargePayloadChunking(t *testing.T) {
+ var callCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount.Add(1)
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+
+ data, ok := body["data"].(map[string]any)
+ if !ok {
+ data = map[string]any{"key": "value"}
+ }
+ response := make(map[string]any)
+ for k := range data {
+ response[k] = "translated"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"data": response})
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := make(map[string]any)
+ for i := 0; i < 100; i++ {
+ payload[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("value %d", i)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeObject(context.Background(), payload, params, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ count := callCount.Load()
+ if count <= 1 {
+ t.Fatalf("expected more than 1 chunk, got %d server calls", count)
+ }
+ t.Logf("Number of chunks: %d", count)
+}
+
+func TestMocked_ReferenceParameterIncluded(t *testing.T) {
+ var capturedBody map[string]any
+ var mu sync.Mutex
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+ mu.Lock()
+ capturedBody = body
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"key": "translated"},
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ ref := map[string]map[string]any{
+ "es": {"key": "valor de referencia"},
+ }
+ obj := map[string]any{"key": "value"}
+ params := lingo.LocalizationParams{
+ TargetLocale: "es",
+ Reference: ref,
+ }
+ _, err = client.LocalizeObject(context.Background(), obj, params, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if capturedBody == nil {
+ t.Fatal("no request body captured")
+ }
+ refField, ok := capturedBody["reference"]
+ if !ok {
+ t.Fatal("expected 'reference' field in request body")
+ }
+ refMap, ok := refField.(map[string]any)
+ if !ok {
+ t.Fatalf("expected reference to be map, got %T", refField)
+ }
+ esRef, ok := refMap["es"]
+ if !ok {
+ t.Fatal("expected 'es' key in reference")
+ }
+ esRefMap, ok := esRef.(map[string]any)
+ if !ok {
+ t.Fatalf("expected es reference to be map, got %T", esRef)
+ }
+ if esRefMap["key"] != "valor de referencia" {
+ t.Errorf("expected reference value 'valor de referencia', got %v", esRefMap["key"])
+ }
+}
+
+func TestMocked_WorkflowIDConsistency(t *testing.T) {
+ var workflowIDs []string
+ var mu sync.Mutex
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+
+ params, ok := body["params"].(map[string]any)
+ if ok {
+ wfID, ok := params["workflowId"].(string)
+ if ok {
+ mu.Lock()
+ workflowIDs = append(workflowIDs, wfID)
+ mu.Unlock()
+ }
+ }
+
+ data, ok := body["data"].(map[string]any)
+ if !ok {
+ data = map[string]any{}
+ }
+ response := make(map[string]any)
+ for k := range data {
+ response[k] = "translated"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"data": response})
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL), lingo.SetBatchSize(2))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := make(map[string]any)
+ for i := 0; i < 50; i++ {
+ payload[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("value %d", i)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeObject(context.Background(), payload, params, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(workflowIDs) < 2 {
+ t.Fatalf("expected at least 2 workflow IDs (chunks), got %d", len(workflowIDs))
+ }
+ firstID := workflowIDs[0]
+ if firstID == "" {
+ t.Fatal("expected non-empty workflow ID")
+ }
+ for i, id := range workflowIDs {
+ if id != firstID {
+ t.Errorf("workflow ID mismatch: chunk 0 has %q, chunk %d has %q", firstID, i, id)
+ }
+ }
+ t.Logf("All %d chunks used workflow ID: %s", len(workflowIDs), firstID)
+}
+
+func TestMocked_ConcurrentChunkProcessing(t *testing.T) {
+ var callCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount.Add(1)
+ time.Sleep(50 * time.Millisecond)
+
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+
+ data, ok := body["data"].(map[string]any)
+ if !ok {
+ data = map[string]any{}
+ }
+ response := make(map[string]any)
+ for k := range data {
+ response[k] = "translated"
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"data": response})
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL), lingo.SetBatchSize(2))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ payload := make(map[string]any)
+ for i := 0; i < 10; i++ {
+ payload[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("value %d", i)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+
+ start := time.Now()
+ _, err = client.LocalizeObject(context.Background(), payload, params, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ duration := time.Since(start)
+
+ count := int(callCount.Load())
+ serialDuration := time.Duration(count) * 50 * time.Millisecond
+ if duration >= serialDuration {
+ t.Errorf("concurrent processing (%s) was not faster than serial estimate (%s with %d calls)", duration, serialDuration, count)
+ }
+ t.Logf("Concurrent duration: %s, call count: %d, serial estimate: %s", duration, count, serialDuration)
+}
+
+func TestMocked_RetryOn500(t *testing.T) {
+ var callCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ count := callCount.Add(1)
+ if count <= 2 {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"error": "server error"}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"text": "translated"},
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ result, err := client.LocalizeText(context.Background(), "hello", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if result != "translated" {
+ t.Errorf("expected 'translated', got '%s'", result)
+ }
+
+ finalCount := callCount.Load()
+ if finalCount != 3 {
+ t.Errorf("expected 3 server calls (2 retries + 1 success), got %d", finalCount)
+ }
+}
+
+func TestMocked_NoRetryOn400(t *testing.T) {
+ var callCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount.Add(1)
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"error": "bad request"}`))
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(context.Background(), "hello", params)
+ if err == nil {
+ t.Fatal("expected error for bad request")
+ }
+ var ve *lingo.ValueError
+ if !errors.As(err, &ve) {
+ t.Fatalf("expected *ValueError, got %T: %s", err, err)
+ }
+
+ finalCount := callCount.Load()
+ if finalCount != 1 {
+ t.Errorf("expected exactly 1 server call (no retries), got %d", finalCount)
+ }
+}
+
+func TestMocked_ContextCancellation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(200 * time.Millisecond)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"text": "translated"},
+ })
+ }))
+ defer server.Close()
+
+ client, err := lingo.NewClient("test-key", lingo.SetURL(server.URL))
+ if err != nil {
+ t.Fatalf("failed to create client: %s", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ params := lingo.LocalizationParams{TargetLocale: "es"}
+ _, err = client.LocalizeText(ctx, "hello", params)
+ if err == nil {
+ t.Fatal("expected error for context cancellation")
+ }
+ if !strings.Contains(err.Error(), "context deadline exceeded") && !strings.Contains(err.Error(), "context canceled") {
+ t.Errorf("expected context error, got: %s", err)
+ }
+}