Skip to content

Commit 3773f20

Browse files
committed
feat: Introduce localization helper and integrate localization across API aggregates, handlers, and endpoints, while updating ETag responses to TypedResults.
1 parent 8663763 commit 3773f20

26 files changed

Lines changed: 758 additions & 542 deletions

File tree

docs/localization-guide.md

Lines changed: 104 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -4,247 +4,159 @@ This guide explains how to configure and use localization in the BookStore API.
44

55
## Overview
66

7-
The BookStore API supports multiple languages for localized content (e.g., category names). The API automatically detects the client's preferred language from the `Accept-Language` HTTP header and returns localized content accordingly.
7+
The BookStore API supports multiple languages for localized content (category names, book descriptions, author biographies). The API automatically detects the client's preferred language from the `Accept-Language` HTTP header and returns localized content accordingly.
88

99
## Configuration
1010

11-
Localization is configured in `appsettings.json` under the `Localization` section:
11+
### Two-Letter Language Codes
1212

13-
```json
14-
{
15-
"Localization": {
16-
"DefaultCulture": "en-US",
17-
"SupportedCultures": ["en-US"]
18-
}
19-
}
20-
```
21-
22-
### Configuration Options
23-
24-
#### `DefaultCulture`
25-
- **Type**: `string`
26-
- **Default**: `"en-US"`
27-
- **Description**: The default culture to use when the client's preferred language is not supported
28-
- **Valid Values**: Any valid culture identifier (e.g., `"en-US"`, `"pt-PT"`, `"es-ES"`, `"fr-FR"`, `"de-DE"`)
29-
30-
#### `SupportedCultures`
31-
- **Type**: `string[]`
32-
- **Default**: `["en-US"]`
33-
- **Description**: Array of culture identifiers that the API can respond in
34-
- **Valid Values**: Array of valid culture identifiers
35-
36-
### Example Configurations
37-
38-
**English only (default)**:
39-
```json
40-
{
41-
"Localization": {
42-
"DefaultCulture": "en-US",
43-
"SupportedCultures": ["en-US"]
44-
}
45-
}
46-
```
13+
The API uses **two-letter ISO 639-1 language codes** for universal variant support:
4714

48-
**Multiple languages**:
4915
```json
5016
{
5117
"Localization": {
52-
"DefaultCulture": "en-US",
53-
"SupportedCultures": ["en-US", "pt-PT"]
18+
"DefaultCulture": "en",
19+
"SupportedCultures": ["pt", "en", "fr", "de", "es"]
5420
}
5521
}
5622
```
5723

58-
**Portuguese as default**:
59-
```json
60-
{
61-
"Localization": {
62-
"DefaultCulture": "pt-PT",
63-
"SupportedCultures": ["pt-PT", "en-US"]
64-
}
65-
}
66-
```
24+
**Why two-letter codes?**
25+
- ✅ Any regional variant automatically maps to the base language (pt-BR, pt-PT, pt-AO → pt)
26+
- ✅ Simpler configuration - no need to list every regional variant
27+
- ✅ Easier maintenance - new regional variants work immediately
6728

68-
## How It Works
29+
### Cache Configuration
6930

70-
### 1. Client Request
71-
The client sends an HTTP request with the `Accept-Language` header:
31+
**Critical**: All localized endpoints must vary cache by `Accept-Language`:
7232

73-
```http
74-
GET /api/books HTTP/1.1
75-
Accept-Language: pt-PT,pt;q=0.9,en-US;q=0.8,en;q=0.7
33+
```csharp
34+
.CacheOutput(policy => policy
35+
.Expire(TimeSpan.FromMinutes(5))
36+
.SetVaryByHeader("Accept-Language"))
7637
```
7738

78-
### 2. Language Selection
79-
ASP.NET Core's `RequestLocalizationMiddleware` automatically:
80-
1. Parses the `Accept-Language` header
81-
2. Matches against `SupportedCultures`
82-
3. Sets `CultureInfo.CurrentCulture` to the best match
83-
4. Falls back to `DefaultCulture` if no match is found
39+
Without this, cached responses ignore the language header.
8440

85-
### 3. Localized Response
86-
The API returns localized content based on the selected culture:
41+
## Translation Storage
8742

88-
```json
89-
{
90-
"id": "123e4567-e89b-12d3-a456-426614174000",
91-
"title": "Clean Code",
92-
"categories": [
93-
{
94-
"id": "456e7890-e89b-12d3-a456-426614174001",
95-
"name": "Programação" // Localized to Portuguese
96-
}
97-
]
98-
}
99-
```
43+
Translations can be **culture-specific** or **culture-invariant**:
10044

101-
## Localized Content
102-
103-
### Categories
104-
Category names are stored with translations in multiple languages. The API automatically returns the category name in the client's preferred language.
105-
106-
**Category Data Structure**:
107-
```csharp
108-
public class CategoryProjection
45+
**Culture-Invariant** (recommended):
46+
```json
10947
{
110-
public Guid Id { get; set; }
111-
public Dictionary<string, CategoryTranslation> Translations { get; set; }
112-
public DateTimeOffset LastModified { get; set; }
48+
"pt": "Programação",
49+
"en": "Programming"
11350
}
114-
115-
public record CategoryTranslation(string Name, string? Description);
11651
```
11752

118-
**Translation Dictionary Example**:
53+
**Hybrid** (specific + invariant):
11954
```json
12055
{
121-
"en": { "name": "Programming", "description": null },
122-
"pt": { "name": "Programação", "description": null }
56+
"pt-BR": "Programação (Brasil)",
57+
"pt": "Programação",
58+
"en": "Programming"
12359
}
12460
```
12561

126-
### Fallback Strategy
127-
128-
The API uses a **four-tier fallback strategy** to find the best translation:
62+
## Fallback Strategy
12963

130-
1. **Full culture code**: First tries the complete culture identifier (e.g., `"pt-PT"`)
131-
2. **Two-letter ISO language code**: If not found, uses `CultureInfo.TwoLetterISOLanguageName` to extract the language code (e.g., `"pt"` from `"pt-PT"`)
132-
3. **English fallback**: If still not found, tries to use the English (`"en"`) translation
133-
4. **First available**: As a last resort, uses the first available translation in the dictionary
64+
The API uses a 6-step fallback to find the best translation:
13465

135-
**Example**:
136-
- Client requests `Accept-Language: pt-PT`
137-
- Category has translations for `"en"` and `"pt"` (but not `"pt-PT"`)
138-
- API tries `"pt-PT"` → not found
139-
- API creates `CultureInfo("pt-PT")` and gets `TwoLetterISOLanguageName``"pt"`**found!** Returns Portuguese translation
140-
- If `"pt"` wasn't found → tries `"en"` → returns English translation
141-
- If `"en"` wasn't found → returns first available translation
66+
1. Exact preferred culture (e.g., `"pt-PT"`)
67+
2. Two-letter preferred code (e.g., `"pt"`)
68+
3. Exact default culture (e.g., `"en"`)
69+
4. Two-letter default code (e.g., `"en"`)
70+
5. First available translation
71+
6. Default value (empty string)
14272

143-
This ensures maximum compatibility even when exact culture matches aren't available, and handles edge cases correctly using .NET's built-in culture handling.
73+
**Example**: Request with `Accept-Language: pt-BR`
74+
- Tries `"pt-BR"``"pt"``"en"` → first available → default
75+
- With hybrid translations above, returns `"Programação (Brasil)"`
14476

145-
### Fallback Behavior
146-
If a translation is not available for the requested language:
147-
1. The API falls back through the strategy above (full code → two-letter → English → first available)
148-
2. No error is thrown
149-
3. The response is still valid
77+
## Usage
15078

151-
## Environment-Specific Configuration
79+
### Making Requests
15280

153-
You can configure different languages for different environments:
81+
Include the `Accept-Language` header in your HTTP requests:
15482

155-
**`appsettings.Development.json`** (local development):
156-
```json
157-
{
158-
"Localization": {
159-
"DefaultCulture": "en-US",
160-
"SupportedCultures": ["en-US", "pt-PT"]
161-
}
162-
}
83+
```http
84+
GET /api/books HTTP/1.1
85+
Accept-Language: pt-BR
16386
```
16487

165-
**`appsettings.Production.json`** (production):
166-
```json
167-
{
168-
"Localization": {
169-
"DefaultCulture": "en-US",
170-
"SupportedCultures": ["en-US", "pt-PT"]
171-
}
172-
}
173-
```
88+
The API returns localized content based on the header value.
17489

175-
## Testing Localization
90+
### Testing Different Languages
17691

177-
### Using curl
92+
**Using curl**:
17893
```bash
179-
# Request in Portuguese
180-
curl -H "Accept-Language: pt-PT" https://localhost:5001/api/books
94+
curl -H "Accept-Language: pt" https://localhost:7001/api/categories
95+
curl -H "Accept-Language: en" https://localhost:7001/api/categories
96+
```
18197

182-
# Request in Spanish
183-
curl -H "Accept-Language: es-ES" https://localhost:5001/api/books
98+
**Using Postman/Insomnia**:
99+
1. Add header: `Accept-Language: pt`
100+
2. Send request
101+
3. Observe localized response
184102

185-
# Request with quality values
186-
curl -H "Accept-Language: fr-FR,fr;q=0.9,en-US;q=0.8" https://localhost:5001/api/books
187-
```
103+
## Localized Endpoints
188104

189-
### Using Browser DevTools
190-
1. Open browser DevTools (F12)
191-
2. Go to Network tab
192-
3. Find the API request
193-
4. Check the `Accept-Language` header in Request Headers
194-
5. Modify the header using browser extensions or DevTools
105+
All public-facing endpoints return localized content:
195106

196-
### Using Postman
197-
1. Create a new request
198-
2. Go to Headers tab
199-
3. Add `Accept-Language` header with desired value (e.g., `pt-PT`)
200-
4. Send the request
107+
- **Categories** (`/api/categories`): Category names
108+
- **Books** (`/api/books`): Descriptions, category names, author biographies, language display names
109+
- **Authors** (`/api/authors`): Author biographies
201110

202111
## Implementation Details
203112

204113
The localization system uses:
205-
- **ASP.NET Core's `RequestLocalizationMiddleware`**: Handles `Accept-Language` parsing and culture selection
206-
- **`CultureInfo.CurrentCulture`**: Provides the current culture throughout the request pipeline
207-
- **`LocalizationOptions`**: Strongly-typed configuration class
208-
- **Query-time localization**: Categories are localized when mapping to DTOs, not in the database
114+
- **ASP.NET Core Middleware**: `RequestLocalizationMiddleware` determines culture from `Accept-Language` header
115+
- **LocalizationHelper**: Centralized helper with reusable methods:
116+
- `GetLocalizedValue<T>()`: Generic method for translating any `Dictionary<string, T>`
117+
- `LocalizeLanguageName()`: Gets localized display names for language codes
118+
- `GetPreferredCulture()`: Retrieves user's preferred culture from middleware
119+
- **Generic Translation Support**: Works with any translation type via selector functions
120+
- **Null Safety**: Uses `[NotNullWhen(true)]` attribute for compile-time null safety
121+
122+
### Usage Example
123+
124+
```csharp
125+
// Localize category name
126+
var localizedName = LocalizationHelper.GetLocalizedValue(
127+
context,
128+
options,
129+
category.Translations,
130+
translation => translation.Name,
131+
defaultValue: "Unknown");
132+
133+
// Get localized language name
134+
var languageName = LocalizationHelper.LocalizeLanguageName(
135+
"en",
136+
context,
137+
options); // Returns "English" or "Inglês" depending on user's language
138+
```
209139

210140
## Best Practices
211141

212-
1. **Always include a default culture**: Ensure `DefaultCulture` is set to a language you fully support
213-
2. **Use standard culture codes**: Use ISO culture identifiers (e.g., `en-US`, not `english`)
214-
3. **Test fallback behavior**: Verify the API works when translations are missing
215-
4. **Document supported languages**: Keep this guide updated when adding new languages
216-
5. **Consider regional variants**: Use specific cultures (`en-US`, `en-GB`) rather than generic ones (`en`)
217-
218-
## Adding a New Language
219-
220-
To add support for a new language:
221-
222-
1. **Update configuration** in `appsettings.json`:
223-
```json
224-
{
225-
"Localization": {
226-
"SupportedCultures": ["en-US", "pt-PT", "ja-JP"] // Added Japanese
227-
}
228-
}
229-
```
230-
231-
2. **Add translations** to category data (via admin API or database):
232-
```csharp
233-
var translations = new Dictionary<string, CategoryTranslation>
234-
{
235-
["en"] = new("Programming", null),
236-
["pt"] = new("Programação", null),
237-
["ja"] = new("プログラミング", null) // Japanese translation
238-
};
239-
```
240-
241-
3. **Test** with the new language:
242-
```bash
243-
curl -H "Accept-Language: ja-JP" https://localhost:5001/api/books
244-
```
245-
246-
## Related Documentation
247-
248-
- [Architecture Guide](architecture.md) - Overall system architecture
249-
- [Marten Guide](marten-guide.md) - Document database and projections
250-
- [Getting Started](getting-started.md) - Initial setup and configuration
142+
1. **Use two-letter codes**: Configure `SupportedCultures` with two-letter codes for universal variant support
143+
2. **Use culture-invariant translations**: Store translations with two-letter keys unless region-specific content is needed
144+
3. **Always vary cache by Accept-Language**: Ensure cached responses are language-specific
145+
4. **Provide fallbacks**: Always include default culture translations
146+
5. **Validate culture codes**: Use `CultureCache.IsValidCultureCode()` to validate codes before use
147+
148+
## Troubleshooting
149+
150+
### Translations not working
151+
- ✅ Check `Accept-Language` header is being sent
152+
- ✅ Verify culture code is in `SupportedCultures`
153+
- ✅ Ensure cache varies by `Accept-Language`
154+
- ✅ Check translation dictionary has the expected keys
155+
156+
### Always returning same language
157+
- ✅ Verify cache configuration includes `.SetVaryByHeader("Accept-Language")`
158+
- ✅ Clear cache and retry
159+
160+
### Regional variant not working
161+
- ✅ Ensure `SupportedCultures` uses two-letter codes
162+
- ✅ Check translation dictionary has two-letter key (e.g., `"pt"` not `"pt-PT"`)

docs/marten-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ Get stream metadata without loading the aggregate:
642642
var streamState = await session.Events
643643
.FetchStreamStateAsync(bookId);
644644

645-
if (streamState != null)
645+
if (streamState is not null)
646646
{
647647
Console.WriteLine($"Stream ID: {streamState.Id}");
648648
Console.WriteLine($"Stream version: {streamState.Version}");
@@ -1064,7 +1064,7 @@ public class MartenMetadataMiddleware
10641064
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
10651065
{
10661066
var session = context.RequestServices.GetService<IDocumentSession>();
1067-
if (session != null)
1067+
if (session is not null)
10681068
{
10691069
// Set correlation ID from header or generate new one
10701070
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()

0 commit comments

Comments
 (0)