Skip to content

Commit 59156f0

Browse files
Complete Typesense search integration with Docker Compose configuration
Co-authored-by: BenjaminMichaelis <[email protected]>
1 parent a156945 commit 59156f0

14 files changed

+1684
-4
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using EssentialCSharp.Web.Models;
2+
using EssentialCSharp.Web.Services;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.AspNetCore.RateLimiting;
5+
6+
namespace EssentialCSharp.Web.Controllers;
7+
8+
[ApiController]
9+
[Route("api/[controller]")]
10+
[EnableRateLimiting("SearchEndpoint")]
11+
public class SearchController : ControllerBase
12+
{
13+
private readonly ITypesenseSearchService _searchService;
14+
private readonly ILogger<SearchController> _logger;
15+
16+
public SearchController(ITypesenseSearchService searchService, ILogger<SearchController> logger)
17+
{
18+
_searchService = searchService;
19+
_logger = logger;
20+
}
21+
22+
/// <summary>
23+
/// Search for content using Typesense
24+
/// </summary>
25+
/// <param name="request">The search request parameters</param>
26+
/// <param name="cancellationToken">Cancellation token</param>
27+
/// <returns>Search results</returns>
28+
[HttpPost]
29+
public async Task<IActionResult> Search([FromBody] SearchRequest request, CancellationToken cancellationToken = default)
30+
{
31+
if (string.IsNullOrWhiteSpace(request.Query))
32+
{
33+
return BadRequest(new { error = "Search query cannot be empty." });
34+
}
35+
36+
if (request.Query.Length > 500)
37+
{
38+
return BadRequest(new { error = "Search query is too long. Maximum 500 characters." });
39+
}
40+
41+
try
42+
{
43+
var result = await _searchService.SearchAsync(
44+
request.Query,
45+
request.Page,
46+
Math.Min(request.PerPage, 50), // Limit max results per page
47+
cancellationToken);
48+
49+
return Ok(result);
50+
}
51+
catch (Exception ex)
52+
{
53+
_logger.LogError(ex, "Search failed for query: {Query}", request.Query);
54+
return StatusCode(500, new { error = "Search service temporarily unavailable." });
55+
}
56+
}
57+
58+
/// <summary>
59+
/// Search for content using GET method for simple queries
60+
/// </summary>
61+
/// <param name="q">Search query</param>
62+
/// <param name="page">Page number (default: 1)</param>
63+
/// <param name="per_page">Results per page (default: 10, max: 50)</param>
64+
/// <param name="cancellationToken">Cancellation token</param>
65+
/// <returns>Search results</returns>
66+
[HttpGet]
67+
public async Task<IActionResult> Search(
68+
[FromQuery] string q,
69+
[FromQuery] int page = 1,
70+
[FromQuery] int perPage = 10,
71+
CancellationToken cancellationToken = default)
72+
{
73+
if (string.IsNullOrWhiteSpace(q))
74+
{
75+
return BadRequest(new { error = "Search query cannot be empty." });
76+
}
77+
78+
var request = new SearchRequest
79+
{
80+
Query = q,
81+
Page = Math.Max(1, page),
82+
PerPage = Math.Min(Math.Max(1, perPage), 50)
83+
};
84+
85+
return await Search(request, cancellationToken);
86+
}
87+
88+
/// <summary>
89+
/// Get search health status
90+
/// </summary>
91+
/// <param name="cancellationToken">Cancellation token</param>
92+
/// <returns>Health status</returns>
93+
[HttpGet("health")]
94+
public async Task<IActionResult> Health(CancellationToken cancellationToken = default)
95+
{
96+
try
97+
{
98+
var isHealthy = await _searchService.IsHealthyAsync(cancellationToken);
99+
100+
if (isHealthy)
101+
{
102+
return Ok(new { status = "healthy", timestamp = DateTime.UtcNow });
103+
}
104+
else
105+
{
106+
return StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
107+
}
108+
}
109+
catch (Exception ex)
110+
{
111+
_logger.LogError(ex, "Health check failed");
112+
return StatusCode(503, new { status = "error", timestamp = DateTime.UtcNow, error = ex.Message });
113+
}
114+
}
115+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace EssentialCSharp.Web.Models;
4+
5+
public class SearchDocument
6+
{
7+
[JsonPropertyName("id")]
8+
public string Id { get; set; } = string.Empty;
9+
10+
[JsonPropertyName("title")]
11+
public string Title { get; set; } = string.Empty;
12+
13+
[JsonPropertyName("content")]
14+
public string Content { get; set; } = string.Empty;
15+
16+
[JsonPropertyName("url")]
17+
public string Url { get; set; } = string.Empty;
18+
19+
[JsonPropertyName("chapter")]
20+
public string Chapter { get; set; } = string.Empty;
21+
22+
[JsonPropertyName("section")]
23+
public string Section { get; set; } = string.Empty;
24+
25+
[JsonPropertyName("tags")]
26+
public List<string> Tags { get; set; } = [];
27+
28+
[JsonPropertyName("created_at")]
29+
public long CreatedAt { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
30+
}
31+
32+
public class SearchResult
33+
{
34+
public List<SearchDocument> Results { get; set; } = [];
35+
public int TotalCount { get; set; }
36+
public int Page { get; set; }
37+
public int PerPage { get; set; }
38+
public double SearchTimeMs { get; set; }
39+
public string Query { get; set; } = string.Empty;
40+
}
41+
42+
public class SearchRequest
43+
{
44+
public string Query { get; set; } = string.Empty;
45+
public int Page { get; set; } = 1;
46+
public int PerPage { get; set; } = 10;
47+
public List<string> Filters { get; set; } = [];
48+
}

EssentialCSharp.Web/Program.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,15 @@ private static void Main(string[] args)
153153
builder.Services.AddSingleton<ISiteMappingService, SiteMappingService>();
154154
builder.Services.AddSingleton<IRouteConfigurationService, RouteConfigurationService>();
155155
builder.Services.AddHostedService<DatabaseMigrationService>();
156+
builder.Services.AddHostedService<SearchIndexingHostedService>();
156157
builder.Services.AddScoped<IReferralService, ReferralService>();
157158

159+
// Add Typesense search services
160+
builder.Services.Configure<TypesenseOptions>(
161+
builder.Configuration.GetSection(TypesenseOptions.SectionName));
162+
builder.Services.AddHttpClient<ITypesenseSearchService, TypesenseSearchService>();
163+
builder.Services.AddScoped<IContentIndexingService, ContentIndexingService>();
164+
158165
// Add AI Chat services
159166
if (!builder.Environment.IsDevelopment())
160167
{
@@ -198,6 +205,14 @@ private static void Main(string[] args)
198205
rateLimiterOptions.QueueLimit = 0; // No queuing for anonymous users
199206
});
200207

208+
options.AddFixedWindowLimiter("SearchEndpoint", rateLimiterOptions =>
209+
{
210+
rateLimiterOptions.PermitLimit = 50; // search requests per window (higher limit for search)
211+
rateLimiterOptions.Window = TimeSpan.FromMinutes(1); // minute window
212+
rateLimiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
213+
rateLimiterOptions.QueueLimit = 0; // No queuing for immediate response
214+
});
215+
201216
// Custom response when rate limit is exceeded
202217
options.OnRejected = async (context, cancellationToken) =>
203218
{
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using EssentialCSharp.Web.Models;
2+
using HtmlAgilityPack;
3+
using System.Text.RegularExpressions;
4+
5+
namespace EssentialCSharp.Web.Services;
6+
7+
public interface IContentIndexingService
8+
{
9+
Task<bool> IndexAllContentAsync(CancellationToken cancellationToken = default);
10+
Task<bool> IndexSiteMappingAsync(SiteMapping siteMapping, CancellationToken cancellationToken = default);
11+
}
12+
13+
public class ContentIndexingService : IContentIndexingService
14+
{
15+
private readonly ITypesenseSearchService _searchService;
16+
private readonly ISiteMappingService _siteMappingService;
17+
private readonly IWebHostEnvironment _environment;
18+
private readonly ILogger<ContentIndexingService> _logger;
19+
20+
public ContentIndexingService(
21+
ITypesenseSearchService searchService,
22+
ISiteMappingService siteMappingService,
23+
IWebHostEnvironment environment,
24+
ILogger<ContentIndexingService> logger)
25+
{
26+
_searchService = searchService;
27+
_siteMappingService = siteMappingService;
28+
_environment = environment;
29+
_logger = logger;
30+
}
31+
32+
public async Task<bool> IndexAllContentAsync(CancellationToken cancellationToken = default)
33+
{
34+
try
35+
{
36+
_logger.LogInformation("Starting to index all content");
37+
38+
// Initialize the collection if it doesn't exist
39+
if (!await _searchService.InitializeCollectionAsync(cancellationToken))
40+
{
41+
_logger.LogError("Failed to initialize Typesense collection");
42+
return false;
43+
}
44+
45+
var documents = new List<SearchDocument>();
46+
47+
foreach (var siteMapping in _siteMappingService.SiteMappings)
48+
{
49+
var document = await CreateSearchDocumentAsync(siteMapping);
50+
if (document != null)
51+
{
52+
documents.Add(document);
53+
}
54+
}
55+
56+
if (documents.Count > 0)
57+
{
58+
var success = await _searchService.IndexDocumentsAsync(documents, cancellationToken);
59+
_logger.LogInformation("Indexed {Count} documents, success: {Success}", documents.Count, success);
60+
return success;
61+
}
62+
63+
_logger.LogWarning("No documents to index");
64+
return true;
65+
}
66+
catch (Exception ex)
67+
{
68+
_logger.LogError(ex, "Failed to index all content");
69+
return false;
70+
}
71+
}
72+
73+
public async Task<bool> IndexSiteMappingAsync(SiteMapping siteMapping, CancellationToken cancellationToken = default)
74+
{
75+
try
76+
{
77+
var document = await CreateSearchDocumentAsync(siteMapping);
78+
if (document == null)
79+
{
80+
return false;
81+
}
82+
83+
return await _searchService.IndexDocumentAsync(document, cancellationToken);
84+
}
85+
catch (Exception ex)
86+
{
87+
_logger.LogError(ex, "Failed to index site mapping {Key}", siteMapping.PrimaryKey);
88+
return false;
89+
}
90+
}
91+
92+
private async Task<SearchDocument?> CreateSearchDocumentAsync(SiteMapping siteMapping)
93+
{
94+
try
95+
{
96+
var filePath = Path.Combine(_environment.ContentRootPath, Path.Combine(siteMapping.PagePath));
97+
if (!File.Exists(filePath))
98+
{
99+
_logger.LogWarning("File not found: {FilePath}", filePath);
100+
return null;
101+
}
102+
103+
var htmlContent = await File.ReadAllTextAsync(filePath);
104+
var doc = new HtmlDocument();
105+
doc.LoadHtml(htmlContent);
106+
107+
// Extract content from body
108+
var bodyNode = doc.DocumentNode.SelectSingleNode("//body");
109+
if (bodyNode == null)
110+
{
111+
_logger.LogWarning("No body content found in {FilePath}", filePath);
112+
return null;
113+
}
114+
115+
// Remove script and style elements
116+
var scriptsAndStyles = bodyNode.SelectNodes("//script | //style");
117+
if (scriptsAndStyles != null)
118+
{
119+
foreach (var node in scriptsAndStyles)
120+
{
121+
node.Remove();
122+
}
123+
}
124+
125+
// Extract plain text content
126+
var textContent = bodyNode.InnerText;
127+
var cleanContent = CleanTextContent(textContent);
128+
129+
// Create tags based on the content
130+
var tags = new List<string>();
131+
if (!string.IsNullOrEmpty(siteMapping.ChapterTitle))
132+
{
133+
tags.Add($"chapter-{siteMapping.ChapterNumber}");
134+
}
135+
136+
// Extract URL from the first key
137+
var url = $"/{siteMapping.Keys.First()}";
138+
if (!string.IsNullOrEmpty(siteMapping.AnchorId))
139+
{
140+
url += $"#{siteMapping.AnchorId}";
141+
}
142+
143+
return new SearchDocument
144+
{
145+
Id = siteMapping.PrimaryKey,
146+
Title = siteMapping.RawHeading ?? siteMapping.ChapterTitle ?? "Unknown",
147+
Content = cleanContent,
148+
Url = url,
149+
Chapter = $"Chapter {siteMapping.ChapterNumber}: {siteMapping.ChapterTitle}",
150+
Section = siteMapping.RawHeading ?? string.Empty,
151+
Tags = tags,
152+
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
153+
};
154+
}
155+
catch (Exception ex)
156+
{
157+
_logger.LogError(ex, "Failed to create search document for {Key}", siteMapping.PrimaryKey);
158+
return null;
159+
}
160+
}
161+
162+
private static string CleanTextContent(string htmlText)
163+
{
164+
if (string.IsNullOrEmpty(htmlText))
165+
{
166+
return string.Empty;
167+
}
168+
169+
// Decode HTML entities
170+
var decodedText = HtmlEntity.DeEntitize(htmlText);
171+
172+
// Remove extra whitespace and normalize line breaks
173+
var cleanText = Regex.Replace(decodedText, @"\s+", " ");
174+
175+
// Remove leading/trailing whitespace
176+
cleanText = cleanText.Trim();
177+
178+
// Limit content length for search indexing (Typesense has limits)
179+
if (cleanText.Length > 10000)
180+
{
181+
cleanText = cleanText[..10000] + "...";
182+
}
183+
184+
return cleanText;
185+
}
186+
}

0 commit comments

Comments
 (0)