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 + +[![Go Report Card](https://goreportcard.com/badge/github.com/lingodotdev/sdk-go)](https://goreportcard.com/report/github.com/lingodotdev/sdk-go) +[![Go Reference](https://pkg.go.dev/badge/github.com/lingodotdev/sdk-go.svg)](https://pkg.go.dev/github.com/lingodotdev/sdk-go) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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) + } +}