Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Key repository information:

Current API surface highlights:

- Supported client endpoints include `/test`, `/languages`, `/search`, `/search-entries`, `/search-rdf`, `/search-definitions`, `/fluky-search`, `/entries`, `/senses`, and advanced search variants.
- Supported client endpoints include `/test`, `/languages`, `/search`, `/search-entries`, `/search-rdf`, `/search-by-definitions`, `/fluky-search`, `/entries`, `/senses`, and advanced search variants.
- The `/me` endpoint has been removed and should not be described as supported.

When answering questions about this repository, reference this file for the canonical project layout and repository-level details.
12 changes: 11 additions & 1 deletion .github/workflows/beta-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,15 @@ jobs:
run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal
- name: Pack
run: dotnet pack ./source/Lexicala.NET/Lexicala.NET.csproj --configuration Release --no-build --version-suffix "beta.${{ github.run_number }}" --output ./artifacts
- name: Resolve package file
id: package
shell: pwsh
run: |
$versionPrefix = dotnet msbuild ./source/Lexicala.NET/Lexicala.NET.csproj -nologo -getProperty:VersionPrefix
$versionPrefix = $versionPrefix.Trim()
$version = "$versionPrefix-beta.${{ github.run_number }}"
"package_file=./artifacts/Lexicala.NET.$version.nupkg" >> $env:GITHUB_OUTPUT
- name: Publish to NuGet
run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: dotnet nuget push "${{ steps.package.outputs.package_file }}" --api-key "$env:NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
4 changes: 4 additions & 0 deletions .github/workflows/build-on-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
# don't run on master because we already have another action for that
branches-ignore: [master]

permissions:
contents: read
packages: read

jobs:
build:

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/build-test_pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
pull_request:
branches: [ master ]
paths-ignore: ['**.md', '.github/**']

permissions:
contents: read
packages: read

jobs:
build:

Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/package-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [ master ]
paths-ignore: ['**.md', '.github/**']

permissions:
contents: read
packages: read

jobs:
build:

Expand All @@ -24,5 +28,14 @@ jobs:
run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal
- name: Pack
run: dotnet pack ./source/Lexicala.NET/Lexicala.NET.csproj --configuration Release --no-build --output ./artifacts
- name: Resolve package file
id: package
shell: pwsh
run: |
$version = dotnet msbuild ./source/Lexicala.NET/Lexicala.NET.csproj -nologo -getProperty:VersionPrefix
$version = $version.Trim()
"package_file=./artifacts/Lexicala.NET.$version.nupkg" >> $env:GITHUB_OUTPUT
- name: Publish to NuGet
run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: dotnet nuget push "${{ steps.package.outputs.package_file }}" --api-key "$env:NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
79 changes: 70 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,16 @@ Set `UseLiteEndpoints` to `true` if your subscription only allows Lite entry/sen
- `/entries-lite/{entryId}` instead of `/entries/{entryId}`
- `/senses-lite/{senseId}` instead of `/senses/{senseId}`

To restore missing translation details when using lite endpoints, use the translation-enriched methods:

- `GetEntryWithTranslationsAsync(entryId, targetLanguage)`
- `GetSenseWithTranslationsAsync(senseId, targetLanguage)`

These methods call translation endpoints to enrich missing sense and example translations.

### 3. Register Services

In your `Program.cs` (for .NET 6+):
In your `Program.cs`:

```csharp
using Lexicala.NET;
Expand Down Expand Up @@ -238,7 +245,7 @@ The repository includes an ASP.NET Core minimal Web API demo host with Swagger U
dotnet run
```

3. Open Swagger UI in your browser:
4. Open Swagger UI in your browser:
- HTTP: `http://localhost:5000/swagger`
- HTTPS: `https://localhost:5001/swagger`

Expand All @@ -252,7 +259,7 @@ Available endpoints:
- `GET /search-entries` - Basic search with full entries
- `GET /search-entries-lite` - Basic search with full entries in lite mode (`UseLiteEndpoints=true`)
- `GET /search-rdf` - Basic search in RDF/JSON-LD format
- `GET /search-definitions` - Free-text search in definitions
- `GET /search-by-definitions` - Free-text search in definitions
- `GET /fluky-search` - Random word discovery
- `GET /entries/{entryId}` - Get dictionary entry by ID
- `GET /entries-lite/{entryId}` - Get dictionary entry by ID in lite mode (`UseLiteEndpoints=true`)
Expand All @@ -277,6 +284,9 @@ Missing endpoints compared to Rapid Api test console / Lexicala MCP tooling - th
- `GET /semantic-categories`
- `GET /subcategorizations`
- `GET /synonyms`

Implemented in this SDK (not exposed by the demo API host routes):

- `GET /translate-to`
- `GET /translate-example`
- `GET /translate-phrase`
Expand All @@ -286,7 +296,7 @@ For React frontend development, CORS is enabled for:
- `http://localhost:3000`
- `http://localhost:5173`

## Sense Sprint Demo Game
## Demo Game

A dedicated React + Vite frontend for a word guessing game is available at `source/Demo/sense-sprint-web`.

Expand All @@ -303,12 +313,14 @@ The web demo currently includes two game modes. `Sense Sprint` is the lower-cost
3. Install dependencies and start the dev server:

**PowerShell:**

```powershell
npm.cmd install
npm.cmd run dev
```

**Bash / Command Prompt:**

```bash
npm install
npm run dev
Expand Down Expand Up @@ -355,36 +367,85 @@ The library validates and supports these commonly used API parameter values:

The library implements the following Lexicala API endpoints:

**Utility Endpoints**
### Utility Endpoints

- `/test` - Test API connectivity
- `/languages` - Get available languages

**Search Endpoints**
### Search Endpoints

- `/search` - Basic search
- `/search-entries` - Search with full entries
- `/search-entries-lite` - Search with full entries in lite mode (`UseLiteEndpoints=true`)
- `/search-rdf` - Search in RDF/JSON-LD format
- `/search-definitions` - Free-text search in definitions
- `/search-by-definitions` - Free-text search in definitions
- `/fluky-search` - Random word discovery

**Advanced Search Endpoints**
### Advanced Search Endpoints

- `/search-advanced` - Advanced search with custom parameters
- `/search-entries-advanced` - Advanced search with full entries
- `/search-rdf-advanced` - Advanced search in RDF/JSON-LD format

**Entry and Sense Endpoints**
### Entry and Sense Endpoints

- `/entries` - Get entry details by ID
- `/entries-lite` - Get entry details by ID in lite mode (`UseLiteEndpoints=true`)
- `/senses` - Get sense details by ID
- `/senses-lite` - Get sense details by ID in lite mode (`UseLiteEndpoints=true`)
- `/rdf` - Get entry in RDF/JSON-LD format

### Translation Endpoints

- `/translate-to` - Translate a lexical unit into a target language
- `/translate-example` - Translate an example sentence into a target language
- `/translate-phrase` - Translate a phrase into a target language

### Lite Translation Enrichment

- `GetEntryWithTranslationsAsync` - Retrieves an entry (including lite mode) and enriches missing sense/example translations using translation endpoints
- `GetSenseWithTranslationsAsync` - Retrieves a sense (including lite mode) and enriches missing sense/example translations using translation endpoints

For complete API documentation, visit the [Lexicala API Documentation](https://api.lexicala.com/documentation).

## Rate Limiting

The Lexicala API enforces a daily request quota. When the quota is exhausted the API returns **HTTP 429 Too Many Requests** and includes an `X-RateLimit-requests-Reset` header indicating how many seconds remain until the quota resets.

### Retry behaviour

The client uses a Polly-based retry policy with the following rules:

| Condition | Behaviour |
|-----------|-----------|
| Transient errors (5xx, network) | Retry up to 3 times with exponential back-off (max 8 s per wait) |
| HTTP 429 — reset ≤ 60 s | Retry up to 3 times, waiting the server-indicated number of seconds between attempts |
| HTTP 429 — reset > 60 s | **Fail immediately** — no retry; a `LexicalaApiException` with `StatusCode = 429` is thrown |

The 60-second threshold prevents the application from stalling when the quota won't reset for minutes or hours. In that scenario every retry attempt would also receive a 429, so the library surfaces the failure straight away instead of blocking.

### Logging

When a request fails due to rate limiting, structured log messages are emitted:

- **Warning** (before each retry): `Rate limit exceeded (HTTP 429). Waiting {n}s before retry attempt {x}/3.`
- **Warning** (skip-retry path): `Rate limit exceeded (HTTP 429). API quota resets in {n}s which exceeds the retry threshold (60s). Not retrying.`
- **Error** (after final failure): `API rate limit exceeded (HTTP 429). Quota resets in {n}s. Request failed without retrying because the reset time exceeds the retry threshold.`

### Handling the exception

```csharp
try
{
var result = await client.BasicSearchAsync("hello", "en");
}
catch (LexicalaApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var secondsUntilReset = ex.Metadata?.RateLimits?.Reset;
Console.WriteLine($"Daily quota exceeded. Quota resets in {secondsUntilReset}s.");
}
```

## Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.
Expand Down
5 changes: 5 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Change log

## Lexicala.NET

3.2.0 - Added translation capabilities for lite endpoints, including translation enrichment. Improved response handling consistency across client models.

3.1.0 - Expanded supported Lexicala API surface with additional search and retrieval endpoints (including advanced search variants). Improved parser robustness for broader response shapes.

3.0.0 - Major update: Migrated from Newtonsoft.Json to System.Text.Json for better performance. Replaced console tester app with ASP.NET Core minimal Web API host with full Swagger/OpenAPI support. Enhanced error handling and logging. Now targets .NET 10.0 and .NET 8.0

2.0.0 - Now supports .NET 8.0 and .NET standard 2.0. Removed packages Lexicala.NET.Autofac and Lexicala.NET.MicrosoftDependencyInjection. Api calls to old dictapi url were no longer valid, this has been fixed by using the new RapidApi url now.
Expand Down
116 changes: 114 additions & 2 deletions source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Lexicala.NET.Request;
using Lexicala.NET.Response.Entries;
using Lexicala.NET.Response.Search;
using Lexicala.NET.Response.Translation;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -232,6 +233,7 @@

// Find a translation in the target language from any sense
var correctTranslation = FindTranslation(entry, targetLanguage);
correctTranslation ??= await FindTranslationViaPhraseEndpointAsync(sourceWord, targetLanguage, cancellationToken);
if (string.IsNullOrWhiteSpace(correctTranslation))
{
return null;
Expand Down Expand Up @@ -337,7 +339,7 @@

private static string GetDistractorPoolCacheKey(string targetLanguage) => $"{DistractorPoolCacheKeyPrefix}{targetLanguage}";

private static string? FindTranslation(Lexicala.NET.Response.Entries.Entry entry, string targetLanguage)
private static string? FindTranslation(Entry entry, string targetLanguage)
{
foreach (var sense in entry.Senses)
{
Expand All @@ -356,7 +358,7 @@
return null;
}

private static string? GetFirstHeadword(Lexicala.NET.Response.Search.Result? result)
private static string? GetFirstHeadword(Result? result)
{
if (result is null)
{
Expand All @@ -369,6 +371,116 @@
: hw.Headword?.Text;
}

private async Task<string?> FindTranslationViaPhraseEndpointAsync(string sourceText, string targetLanguage, CancellationToken cancellationToken)
{
try
{
var translated = await _lexicalaClient.TranslateToAsync(sourceText, targetLanguage, "en", cancellationToken: cancellationToken);
var best = TryExtractBestTranslation(translated, sourceText, targetLanguage);
if (!string.IsNullOrWhiteSpace(best))
{
return best;
}

translated = await _lexicalaClient.TranslatePhraseAsync(sourceText, targetLanguage, "en", cancellationToken: cancellationToken);
return TryExtractBestTranslation(translated, sourceText, targetLanguage);
}
catch (LexicalaApiException ex)
{
_logger.LogDebug(ex, "Translation fallback failed for source '{SourceText}' to '{TargetLanguage}'", sourceText, targetLanguage);

Check warning

Code scanning / CodeQL

Log entries created from user input Medium

This log entry depends on a
user-provided value
.
return null;
}
}

private static string? TryExtractBestTranslation(TranslationResponse response, string sourceText, string targetLanguage)
{
foreach (var result in response.Results)
{
var candidate = TryExtractText(result, sourceText, targetLanguage, "en");
if (!string.IsNullOrWhiteSpace(candidate))
{
return candidate;
}
}

return null;
}

private static string? TryExtractText(System.Text.Json.JsonElement element, string sourceText, string targetLanguage, string sourceLanguage)
{
if (element.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (element.TryGetProperty("translations", out var translationsElement) &&
translationsElement.ValueKind == System.Text.Json.JsonValueKind.Object &&
translationsElement.TryGetProperty(targetLanguage, out var targetTranslations))
{
var fromTranslations = TryExtractText(targetTranslations, sourceText, targetLanguage, sourceLanguage);
if (!string.IsNullOrWhiteSpace(fromTranslations))
{
return fromTranslations;
}
}

if (element.TryGetProperty("translation", out var translationElement))
{
var fromTranslation = TryExtractText(translationElement, sourceText, targetLanguage, sourceLanguage);
if (!string.IsNullOrWhiteSpace(fromTranslation))
{
return fromTranslation;
}
}

if (element.TryGetProperty("text", out var textElement) &&
textElement.ValueKind == System.Text.Json.JsonValueKind.String)
{
var text = textElement.GetString();
var language = element.TryGetProperty("language", out var languageElement) && languageElement.ValueKind == System.Text.Json.JsonValueKind.String
? languageElement.GetString()
: null;

var isSourceLanguageText = !string.IsNullOrWhiteSpace(language) && string.Equals(language, sourceLanguage, StringComparison.OrdinalIgnoreCase);

if (!string.IsNullOrWhiteSpace(text)
&& !string.Equals(text, sourceText, StringComparison.OrdinalIgnoreCase)
&& !isSourceLanguageText)
{
return text;
}
}

foreach (var property in element.EnumerateObject())
{
var nested = TryExtractText(property.Value, sourceText, targetLanguage, sourceLanguage);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
else if (element.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
var nested = TryExtractText(item, sourceText, targetLanguage, sourceLanguage);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}

if (element.ValueKind == System.Text.Json.JsonValueKind.String)
{
var text = element.GetString();
if (!string.IsNullOrWhiteSpace(text) && !string.Equals(text, sourceText, StringComparison.OrdinalIgnoreCase))
{
return text;
}
}

return null;
}

private TranslationQuizRoundState GetRequiredRound(Guid roundId)
{
if (!_cache.TryGetValue(roundId, out TranslationQuizRoundState? round) || round is null)
Expand Down
Loading
Loading