Skip to content
Merged
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
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,18 @@ Add the Lexicala configuration to your `appsettings.json`:
```json
{
"Lexicala": {
"ApiKey": "your-rapidapi-key-here"
"ApiKey": "your-rapidapi-key-here",
"UseLiteEndpoints": false
}
}
```

Set `UseLiteEndpoints` to `true` if your subscription only allows Lite entry/sense endpoints. When enabled, the client automatically uses:

- `/search-entries-lite` instead of `/search-entries`
- `/entries-lite/{entryId}` instead of `/entries/{entryId}`
- `/senses-lite/{senseId}` instead of `/senses/{senseId}`

### 3. Register Services

In your `Program.cs` (for .NET 6+):
Expand Down Expand Up @@ -219,7 +226,8 @@ The repository includes an ASP.NET Core minimal Web API demo host with Swagger U
```json
{
"Lexicala": {
"ApiKey": "your-rapidapi-key-here"
"ApiKey": "your-rapidapi-key-here",
"UseLiteEndpoints": false
}
}
```
Expand All @@ -242,16 +250,37 @@ Available endpoints:
- `GET /languages` - Get available languages
- `GET /search` - Basic search
- `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 /fluky-search` - Random word discovery
- `GET /entry/{entryId}` - Get dictionary entry by ID
- `GET /sense/{senseId}` - Get sense by ID
- `GET /entries/{entryId}` - Get dictionary entry by ID
- `GET /entries-lite/{entryId}` - Get dictionary entry by ID in lite mode (`UseLiteEndpoints=true`)
- `GET /senses/{senseId}` - Get sense by ID
- `GET /senses-lite/{senseId}` - Get sense by ID in lite mode (`UseLiteEndpoints=true`)
- `GET /rdf/{entryId}` - Get entry in RDF/JSON-LD format
- `POST /search-advanced` - Advanced search
- `POST /search-entries-advanced` - Advanced search with full entries
- `POST /search-rdf-advanced` - Advanced search in RDF/JSON-LD format

Missing endpoints compared to Rapid Api test console / Lexicala MCP tooling - these endpoints are NOT listed in the official documentation!:

- `GET /abbreviations`
- `GET /reverse-abbreviations`
- `GET /antonyms`
- `GET /definitions`
- `GET /examples`
- `GET /frequencies`
- `GET /phrases`
- `GET /pronunciations`
- `GET /registers`
- `GET /semantic-categories`
- `GET /subcategorizations`
- `GET /synonyms`
- `GET /translate-to`
- `GET /translate-example`
- `GET /translate-phrase`

For React frontend development, CORS is enabled for:

- `http://localhost:3000`
Expand Down Expand Up @@ -312,6 +341,16 @@ For complete game documentation, features, and tips, see the [Sense Sprint READM

The legacy `source/Lexicala.NET.sln` file has been removed in favor of `source/Lexicala.NET.slnx`.

## Supported API Values

The library validates and supports these commonly used API parameter values:

- `source` values for `FlukySearchAsync` and `AdvancedSearch*Async`: `global`, `password`, `random`, `multigloss`
- Language parameters (`language`, `sourceLanguage`) must be 2-character language codes
- `AdvancedSearchRequest.Page` accepts values up to `1000`
- `AdvancedSearchRequest.Sample` accepts values up to `1000`
- `AdvancedSearchRequest.PageLength` accepts values between `1` and `30` (default `10`)

## API Coverage

The library implements the following Lexicala API endpoints:
Expand All @@ -325,6 +364,7 @@ The library implements the following Lexicala API 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
- `/fluky-search` - Random word discovery
Expand All @@ -338,7 +378,9 @@ The library implements the following Lexicala API 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

For complete API documentation, visit the [Lexicala API Documentation](https://api.lexicala.com/documentation).
Expand Down
7 changes: 7 additions & 0 deletions source/Demo/Lexicala.NET.Demo.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ await client.BasicSearchAsync(text, language, etag, cancellationToken))
.WithTags("Search")
.WithSummary("Search by headword")
.WithDescription("Search for entries in the Global source by headword. Returns partial lexical information. Use /entry/{entryId} to retrieve a full entry.");

app.MapGet("/search-with-parser", async (ILexicalaSearchParser parser, string text, string language) =>
await parser.SearchAsync(text, language))
.WithName("ParserSearch")
.WithTags("Search")
.WithSummary("Search using ILexicalaSearchParser instead of ILexicalaClient")
.WithDescription("Search for entries in the Global source by headword. Returns partial lexical information");

app.MapGet("/search-entries", async (ILexicalaClient client, string text, string language, string? etag, CancellationToken cancellationToken) =>
await client.SearchEntriesAsync(text, language, etag, cancellationToken))
Expand Down
77 changes: 77 additions & 0 deletions source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ public async Task LexicalaClient_SearchEntries_Basic_IncludesSearchEntriesEndpoi
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_SearchEntries_Basic_UsesLiteEndpoint_WhenConfigured()
{
InitializeClient(useLiteEndpoints: true);
string response = await LoadResponseFromFile("Entry_EN_DE00009032.json");

HandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(SetupOkResponseMessage($"[{response}]"));

var result = await Client.SearchEntriesAsync("text", "xx");

result.ShouldNotBeNull();
HandlerMock.Protected().Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri.ToString() == "http://www.tempuri.org/search-entries-lite?language=xx&text=text"),
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_AdvancedSearchEntries_IncludesSearchEntriesEndpoint()
{
Expand All @@ -159,6 +177,31 @@ public async Task LexicalaClient_AdvancedSearchEntries_IncludesSearchEntriesEndp
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_AdvancedSearchEntries_UsesLiteEndpoint_WhenConfigured()
{
InitializeClient(useLiteEndpoints: true);
string response = await LoadResponseFromFile("Entry_EN_DE00009032.json");

HandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(SetupOkResponseMessage($"[{response}]"));

var searchRequest = new AdvancedSearchRequest
{
Language = "xx",
SearchText = "text",
Synonyms = true
};

var result = await Client.AdvancedSearchEntriesAsync(searchRequest);

result.ShouldNotBeNull();
HandlerMock.Protected().Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri.ToString() == "http://www.tempuri.org/search-entries-lite?language=xx&text=text&source=global&synonyms=true"),
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_GetEntryAsync_EncodesEntryIdInPath()
{
Expand All @@ -175,6 +218,23 @@ public async Task LexicalaClient_GetEntryAsync_EncodesEntryIdInPath()
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_GetEntryAsync_UsesLitePath_WhenConfigured()
{
InitializeClient(useLiteEndpoints: true);
const string response = "{\"id\":\"id\",\"headword\":[],\"senses\":[],\"related_entries\":[]}";

HandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(SetupOkResponseMessage(response));

await Client.GetEntryAsync("EN_DE/unsafe");

HandlerMock.Protected().Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri.ToString() == "http://www.tempuri.org/entries-lite/EN_DE%2Funsafe"),
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_GetSenseAsync_EncodesSenseIdInPath()
{
Expand All @@ -191,6 +251,23 @@ public async Task LexicalaClient_GetSenseAsync_EncodesSenseIdInPath()
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public async Task LexicalaClient_GetSenseAsync_UsesLitePath_WhenConfigured()
{
InitializeClient(useLiteEndpoints: true);
const string response = "{\"id\":\"sense-id\"}";

HandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(SetupOkResponseMessage(response));

await Client.GetSenseAsync("EN_SE/unsafe");

HandlerMock.Protected().Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri.ToString() == "http://www.tempuri.org/senses-lite/EN_SE%2Funsafe"),
ItExpr.IsAny<CancellationToken>());
}

[TestMethod]
public void Headword_PartOfSpeeches_ReturnsEmpty_WhenPosIsAbsent()
{
Expand Down
18 changes: 17 additions & 1 deletion source/Lexicala.NET.Tests/LexicalaClientTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,26 @@ public void Initialize()
BaseAddress = new Uri("http://www.tempuri.org")
};

Client = CreateClient(httpClient, useLiteEndpoints: false);
}

protected void InitializeClient(bool useLiteEndpoints)
{
var httpClient = new HttpClient(HandlerMock.Object)
{
BaseAddress = new Uri("http://www.tempuri.org")
};

Client = CreateClient(httpClient, useLiteEndpoints);
}

private static LexicalaClient CreateClient(HttpClient httpClient, bool useLiteEndpoints)
{
var mocker = new AutoMocker(MockBehavior.Loose);
mocker.Use(httpClient);
mocker.Use(new LexicalaConfig("test-key", useLiteEndpoints));

Client = mocker.CreateInstance<LexicalaClient>();
return mocker.CreateInstance<LexicalaClient>();
}

protected static HttpResponseMessage SetupOkResponseMessage(string content)
Expand Down
15 changes: 15 additions & 0 deletions source/Lexicala.NET/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,21 @@ internal static class Constants
/// </summary>
internal const string SearchEntries = "/search-entries";

/// <summary>
/// Search entries lite endpoint path.
/// </summary>
internal const string SearchEntriesLite = "/search-entries-lite";

/// <summary>
/// Entries endpoint path.
/// </summary>
internal const string Entries = "/entries";

/// <summary>
/// Entries lite endpoint path.
/// </summary>
internal const string EntriesLite = "/entries-lite";

/// <summary>
/// Search RDF endpoint path.
/// </summary>
Expand All @@ -45,6 +55,11 @@ internal static class Constants
/// </summary>
internal const string Senses = "/senses";

/// <summary>
/// Senses lite endpoint path.
/// </summary>
internal const string SensesLite = "/senses-lite";

/// <summary>
/// Search definitions endpoint path.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions source/Lexicala.NET/DependencyRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public static IServiceCollection RegisterLexicala(this IServiceCollection servic
})
.AddPolicyHandler(CreateRetryPolicy());

services.AddSingleton(config);
services.AddMemoryCache();
services.AddSingleton<ILexicalaSearchParser, LexicalaSearchParser>();

Expand Down
10 changes: 5 additions & 5 deletions source/Lexicala.NET/Lexicala.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@
<PackageReleaseNotes>A .NET client for the Lexicala api.
See readme file on project page for further details.</PackageReleaseNotes>
<Copyright></Copyright>
<Version>3.0.0</Version>
<Version>3.1.0</Version>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net10.0' " >
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' " >
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.3" />
Expand Down
22 changes: 18 additions & 4 deletions source/Lexicala.NET/LexicalaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class LexicalaClient : ILexicalaClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<LexicalaClient> _logger;
private readonly bool _useLiteEndpoints;

private string SearchEntriesEndpoint => _useLiteEndpoints ? Constants.SearchEntriesLite : Constants.SearchEntries;
private string EntriesEndpoint => _useLiteEndpoints ? Constants.EntriesLite : Constants.Entries;
private string SensesEndpoint => _useLiteEndpoints ? Constants.SensesLite : Constants.Senses;

/// <summary>
/// Creates a new instance of the <see cref="LexicalaClient"/> class.
Expand All @@ -30,9 +35,18 @@ public class LexicalaClient : ILexicalaClient
/// This class should not be instantiated directly, but registered as implementation of the <see cref="ILexicalaClient"/> interface in the dependency injection framework.
/// </remarks>
public LexicalaClient(HttpClient httpClient, ILogger<LexicalaClient> logger)
: this(httpClient, logger, new LexicalaConfig())
{
}

/// <summary>
/// Creates a new instance of the <see cref="LexicalaClient"/> class with endpoint mode configuration.
/// </summary>
public LexicalaClient(HttpClient httpClient, ILogger<LexicalaClient> logger, LexicalaConfig config)
{
_httpClient = httpClient;
_logger = logger;
_useLiteEndpoints = config?.UseLiteEndpoints ?? false;
}

/// <inheritdoc />
Expand Down Expand Up @@ -81,7 +95,7 @@ public Task<IEnumerable<Entry>> SearchEntriesAsync(string searchText, string sou
ValidateLanguageCode(sourceLanguage, nameof(sourceLanguage));
ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText));

var queryString = $"{Constants.SearchEntries}?language={Uri.EscapeDataString(sourceLanguage)}&text={Uri.EscapeDataString(searchText)}";
var queryString = $"{SearchEntriesEndpoint}?language={Uri.EscapeDataString(sourceLanguage)}&text={Uri.EscapeDataString(searchText)}";
return ExecuteSearchEntries(queryString, etag, cancellationToken);
}

Expand All @@ -90,7 +104,7 @@ public Task<IEnumerable<Entry>> AdvancedSearchEntriesAsync(AdvancedSearchRequest
{
ValidateSearchRequest(searchRequest);

var queryString = BuildAdvancedSearchQueryString(Constants.SearchEntries, searchRequest);
var queryString = BuildAdvancedSearchQueryString(SearchEntriesEndpoint, searchRequest);
return ExecuteSearchEntries(queryString, searchRequest.ETag, cancellationToken);
}

Expand Down Expand Up @@ -124,7 +138,7 @@ public Task<string> GetRdfAsync(string entryId, string etag = null, Cancellation
public async Task<Entry> GetEntryAsync(string entryId, string etag = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(entryId, nameof(entryId));
using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{Constants.Entries}/{Uri.EscapeDataString(entryId)}", etag, cancellationToken);
using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{EntriesEndpoint}/{Uri.EscapeDataString(entryId)}", etag, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var responseObject = JsonSerializer.Deserialize<Entry>(content, JsonSerializerDefaults.Options);
responseObject.Metadata = GetResponseMetadata(response.Headers);
Expand All @@ -135,7 +149,7 @@ public async Task<Entry> GetEntryAsync(string entryId, string etag = null, Cance
public async Task<Sense> GetSenseAsync(string senseId, string etag = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(senseId, nameof(senseId));
using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{Constants.Senses}/{Uri.EscapeDataString(senseId)}", etag, cancellationToken);
using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{SensesEndpoint}/{Uri.EscapeDataString(senseId)}", etag, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var responseObject = JsonSerializer.Deserialize<Sense>(content, JsonSerializerDefaults.Options);
responseObject.Metadata = GetResponseMetadata(response.Headers);
Expand Down
Loading
Loading