Skip to content

Commit ee5a7d5

Browse files
committed
Add OTA API endpoints for over-the-air localization
1 parent 2620b84 commit ee5a7d5

5 files changed

Lines changed: 685 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using System.Security.Claims;
5+
using LrmCloud.Api.Services;
6+
using LrmCloud.Shared.Api;
7+
using LrmCloud.Shared.DTOs.Ota;
8+
using Microsoft.AspNetCore.Authorization;
9+
using Microsoft.AspNetCore.Mvc;
10+
11+
namespace LrmCloud.Api.Controllers;
12+
13+
/// <summary>
14+
/// OTA (Over-The-Air) localization endpoints for .NET client library.
15+
/// Provides lightweight bundle delivery for runtime translation updates.
16+
/// </summary>
17+
[Authorize]
18+
[Route("api/ota")]
19+
public class OtaController : ApiControllerBase
20+
{
21+
private readonly IProjectService _projectService;
22+
private readonly IOtaService _otaService;
23+
private readonly ILogger<OtaController> _logger;
24+
25+
public OtaController(
26+
IProjectService projectService,
27+
IOtaService otaService,
28+
ILogger<OtaController> logger)
29+
{
30+
_projectService = projectService;
31+
_otaService = otaService;
32+
_logger = logger;
33+
}
34+
35+
// ============================================================
36+
// User Project Endpoints
37+
// ============================================================
38+
39+
/// <summary>
40+
/// Gets the OTA bundle for a user project.
41+
/// </summary>
42+
/// <param name="username">Username of the project owner</param>
43+
/// <param name="project">Project slug</param>
44+
/// <param name="languages">Optional comma-separated language filter</param>
45+
/// <param name="since">Optional timestamp for delta updates (ISO 8601)</param>
46+
/// <returns>OTA bundle with translations</returns>
47+
[HttpGet("users/{username}/{project}/bundle")]
48+
[ProducesResponseType(typeof(OtaBundleDto), 200)]
49+
[ProducesResponseType(304)] // Not Modified
50+
[ProducesResponseType(typeof(ProblemDetails), 401)]
51+
[ProducesResponseType(typeof(ProblemDetails), 403)]
52+
[ProducesResponseType(typeof(ProblemDetails), 404)]
53+
public async Task<IActionResult> GetUserProjectBundle(
54+
string username,
55+
string project,
56+
[FromQuery] string? languages = null,
57+
[FromQuery] DateTime? since = null)
58+
{
59+
// Validate read scope
60+
var scopeError = ValidateReadScope();
61+
if (scopeError != null) return scopeError;
62+
63+
// Get user ID from claims
64+
var userId = GetUserId();
65+
if (userId == null)
66+
return Unauthorized("OTA_UNAUTHORIZED", "Authentication required");
67+
68+
// Resolve project
69+
var projectDto = await _projectService.GetProjectByNameAsync(username, project, userId.Value);
70+
if (projectDto == null)
71+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found or access denied");
72+
73+
// Validate project-scoped API key if applicable
74+
var projectScopeError = ValidateProjectScope(projectDto.Id);
75+
if (projectScopeError != null) return projectScopeError;
76+
77+
// Parse languages
78+
var languageList = ParseLanguages(languages);
79+
80+
// Get bundle
81+
var projectPath = $"@{username}/{project}";
82+
var bundle = await _otaService.GetBundleAsync(projectDto.Id, projectPath, languageList, since);
83+
84+
if (bundle == null)
85+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found");
86+
87+
// Handle ETag for caching
88+
var etag = $"\"{_otaService.ComputeETag(bundle.Version)}\"";
89+
if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
90+
{
91+
if (ifNoneMatch.ToString() == etag)
92+
{
93+
return StatusCode(304);
94+
}
95+
}
96+
97+
Response.Headers.ETag = etag;
98+
Response.Headers.CacheControl = "private, max-age=60";
99+
100+
_logger.LogInformation("OTA bundle served for @{Username}/{Project}, version {Version}",
101+
username, project, bundle.Version);
102+
103+
return Ok(bundle);
104+
}
105+
106+
/// <summary>
107+
/// Gets the version timestamp for a user project (for efficient polling).
108+
/// </summary>
109+
/// <param name="username">Username of the project owner</param>
110+
/// <param name="project">Project slug</param>
111+
/// <returns>Version timestamp</returns>
112+
[HttpGet("users/{username}/{project}/version")]
113+
[ProducesResponseType(typeof(OtaVersionDto), 200)]
114+
[ProducesResponseType(typeof(ProblemDetails), 401)]
115+
[ProducesResponseType(typeof(ProblemDetails), 403)]
116+
[ProducesResponseType(typeof(ProblemDetails), 404)]
117+
public async Task<IActionResult> GetUserProjectVersion(
118+
string username,
119+
string project)
120+
{
121+
// Validate read scope
122+
var scopeError = ValidateReadScope();
123+
if (scopeError != null) return scopeError;
124+
125+
// Get user ID from claims
126+
var userId = GetUserId();
127+
if (userId == null)
128+
return Unauthorized("OTA_UNAUTHORIZED", "Authentication required");
129+
130+
// Resolve project
131+
var projectDto = await _projectService.GetProjectByNameAsync(username, project, userId.Value);
132+
if (projectDto == null)
133+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found or access denied");
134+
135+
// Validate project-scoped API key if applicable
136+
var projectScopeError = ValidateProjectScope(projectDto.Id);
137+
if (projectScopeError != null) return projectScopeError;
138+
139+
// Get version
140+
var version = await _otaService.GetVersionAsync(projectDto.Id);
141+
if (version == null)
142+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found");
143+
144+
return Ok(version);
145+
}
146+
147+
// ============================================================
148+
// Organization Project Endpoints
149+
// ============================================================
150+
151+
/// <summary>
152+
/// Gets the OTA bundle for an organization project.
153+
/// </summary>
154+
/// <param name="orgSlug">Organization slug</param>
155+
/// <param name="project">Project slug</param>
156+
/// <param name="languages">Optional comma-separated language filter</param>
157+
/// <param name="since">Optional timestamp for delta updates (ISO 8601)</param>
158+
/// <returns>OTA bundle with translations</returns>
159+
[HttpGet("orgs/{orgSlug}/{project}/bundle")]
160+
[ProducesResponseType(typeof(OtaBundleDto), 200)]
161+
[ProducesResponseType(304)] // Not Modified
162+
[ProducesResponseType(typeof(ProblemDetails), 401)]
163+
[ProducesResponseType(typeof(ProblemDetails), 403)]
164+
[ProducesResponseType(typeof(ProblemDetails), 404)]
165+
public async Task<IActionResult> GetOrgProjectBundle(
166+
string orgSlug,
167+
string project,
168+
[FromQuery] string? languages = null,
169+
[FromQuery] DateTime? since = null)
170+
{
171+
// Validate read scope
172+
var scopeError = ValidateReadScope();
173+
if (scopeError != null) return scopeError;
174+
175+
// Get user ID from claims
176+
var userId = GetUserId();
177+
if (userId == null)
178+
return Unauthorized("OTA_UNAUTHORIZED", "Authentication required");
179+
180+
// Resolve project
181+
var projectDto = await _projectService.GetProjectByOrgSlugAsync(orgSlug, project, userId.Value);
182+
if (projectDto == null)
183+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found or access denied");
184+
185+
// Validate project-scoped API key if applicable
186+
var projectScopeError = ValidateProjectScope(projectDto.Id);
187+
if (projectScopeError != null) return projectScopeError;
188+
189+
// Parse languages
190+
var languageList = ParseLanguages(languages);
191+
192+
// Get bundle
193+
var projectPath = $"{orgSlug}/{project}";
194+
var bundle = await _otaService.GetBundleAsync(projectDto.Id, projectPath, languageList, since);
195+
196+
if (bundle == null)
197+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found");
198+
199+
// Handle ETag for caching
200+
var etag = $"\"{_otaService.ComputeETag(bundle.Version)}\"";
201+
if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
202+
{
203+
if (ifNoneMatch.ToString() == etag)
204+
{
205+
return StatusCode(304);
206+
}
207+
}
208+
209+
Response.Headers.ETag = etag;
210+
Response.Headers.CacheControl = "private, max-age=60";
211+
212+
_logger.LogInformation("OTA bundle served for {OrgSlug}/{Project}, version {Version}",
213+
orgSlug, project, bundle.Version);
214+
215+
return Ok(bundle);
216+
}
217+
218+
/// <summary>
219+
/// Gets the version timestamp for an organization project (for efficient polling).
220+
/// </summary>
221+
/// <param name="orgSlug">Organization slug</param>
222+
/// <param name="project">Project slug</param>
223+
/// <returns>Version timestamp</returns>
224+
[HttpGet("orgs/{orgSlug}/{project}/version")]
225+
[ProducesResponseType(typeof(OtaVersionDto), 200)]
226+
[ProducesResponseType(typeof(ProblemDetails), 401)]
227+
[ProducesResponseType(typeof(ProblemDetails), 403)]
228+
[ProducesResponseType(typeof(ProblemDetails), 404)]
229+
public async Task<IActionResult> GetOrgProjectVersion(
230+
string orgSlug,
231+
string project)
232+
{
233+
// Validate read scope
234+
var scopeError = ValidateReadScope();
235+
if (scopeError != null) return scopeError;
236+
237+
// Get user ID from claims
238+
var userId = GetUserId();
239+
if (userId == null)
240+
return Unauthorized("OTA_UNAUTHORIZED", "Authentication required");
241+
242+
// Resolve project
243+
var projectDto = await _projectService.GetProjectByOrgSlugAsync(orgSlug, project, userId.Value);
244+
if (projectDto == null)
245+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found or access denied");
246+
247+
// Validate project-scoped API key if applicable
248+
var projectScopeError = ValidateProjectScope(projectDto.Id);
249+
if (projectScopeError != null) return projectScopeError;
250+
251+
// Get version
252+
var version = await _otaService.GetVersionAsync(projectDto.Id);
253+
if (version == null)
254+
return NotFound("OTA_PROJECT_NOT_FOUND", "Project not found");
255+
256+
return Ok(version);
257+
}
258+
259+
// ============================================================
260+
// Helper Methods
261+
// ============================================================
262+
263+
/// <summary>
264+
/// Gets the user ID from claims.
265+
/// </summary>
266+
private int? GetUserId()
267+
{
268+
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
269+
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
270+
{
271+
return null;
272+
}
273+
return userId;
274+
}
275+
276+
/// <summary>
277+
/// Validates that the API key has read scope.
278+
/// </summary>
279+
private IActionResult? ValidateReadScope()
280+
{
281+
// Check if using API key authentication
282+
var authType = User.FindFirst("auth_type")?.Value;
283+
if (authType != "api_key")
284+
{
285+
// JWT or other auth - all scopes implied
286+
return null;
287+
}
288+
289+
// Get scopes claim
290+
var scopes = User.FindFirst("scopes")?.Value ?? "";
291+
var scopeList = scopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
292+
293+
// Check for read, write, or admin scope (write/admin imply read)
294+
if (!scopeList.Any(s => s.Equals("read", StringComparison.OrdinalIgnoreCase) ||
295+
s.Equals("write", StringComparison.OrdinalIgnoreCase) ||
296+
s.Equals("admin", StringComparison.OrdinalIgnoreCase)))
297+
{
298+
return Forbid("OTA_INSUFFICIENT_SCOPE", "API key requires read scope for OTA access");
299+
}
300+
301+
return null;
302+
}
303+
304+
/// <summary>
305+
/// Validates that a project-scoped API key matches the requested project.
306+
/// </summary>
307+
private IActionResult? ValidateProjectScope(int requestedProjectId)
308+
{
309+
// Check if API key is project-scoped
310+
var projectIdClaim = User.FindFirst("project_id")?.Value;
311+
if (string.IsNullOrEmpty(projectIdClaim))
312+
{
313+
// Not project-scoped, access to all user's projects
314+
return null;
315+
}
316+
317+
if (!int.TryParse(projectIdClaim, out var scopedProjectId))
318+
{
319+
return Forbid("OTA_INVALID_PROJECT_SCOPE", "Invalid project scope in API key");
320+
}
321+
322+
if (scopedProjectId != requestedProjectId)
323+
{
324+
return Forbid("OTA_PROJECT_SCOPE_MISMATCH", "API key does not have access to this project");
325+
}
326+
327+
return null;
328+
}
329+
330+
/// <summary>
331+
/// Parses comma-separated languages string.
332+
/// </summary>
333+
private static List<string>? ParseLanguages(string? languages)
334+
{
335+
if (string.IsNullOrWhiteSpace(languages))
336+
{
337+
return null;
338+
}
339+
340+
return languages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
341+
.Where(l => !string.IsNullOrEmpty(l))
342+
.ToList();
343+
}
344+
}

cloud/src/LrmCloud.Api/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ public static int Main(string[] args)
149149
builder.Services.AddScoped<IOrganizationService, OrganizationService>();
150150
builder.Services.AddScoped<IProjectService, ProjectService>();
151151
builder.Services.AddScoped<IResourceService, ResourceService>();
152+
builder.Services.AddScoped<IOtaService, OtaService>(); // OTA localization bundle delivery
152153
builder.Services.AddScoped<IKeySyncService, KeySyncService>(); // Key-level sync with three-way merge
153154
builder.Services.AddScoped<ISyncHistoryService, SyncHistoryService>(); // Sync history and revert
154155
builder.Services.AddScoped<SnapshotService>(); // Point-in-time snapshot management
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using LrmCloud.Shared.DTOs.Ota;
5+
6+
namespace LrmCloud.Api.Services;
7+
8+
/// <summary>
9+
/// Service for OTA (Over-The-Air) localization bundle generation.
10+
/// </summary>
11+
public interface IOtaService
12+
{
13+
/// <summary>
14+
/// Gets the OTA bundle for a project.
15+
/// </summary>
16+
/// <param name="projectId">Project ID</param>
17+
/// <param name="projectPath">Project path for display (@username/project or org/project)</param>
18+
/// <param name="languages">Optional language filter</param>
19+
/// <param name="since">Optional timestamp for delta updates</param>
20+
/// <param name="ct">Cancellation token</param>
21+
/// <returns>OTA bundle or null if project not found</returns>
22+
Task<OtaBundleDto?> GetBundleAsync(
23+
int projectId,
24+
string projectPath,
25+
IEnumerable<string>? languages = null,
26+
DateTime? since = null,
27+
CancellationToken ct = default);
28+
29+
/// <summary>
30+
/// Gets the version timestamp for a project (for efficient polling).
31+
/// </summary>
32+
/// <param name="projectId">Project ID</param>
33+
/// <param name="ct">Cancellation token</param>
34+
/// <returns>Version DTO or null if project not found</returns>
35+
Task<OtaVersionDto?> GetVersionAsync(int projectId, CancellationToken ct = default);
36+
37+
/// <summary>
38+
/// Computes an ETag for the bundle based on version.
39+
/// </summary>
40+
string ComputeETag(string version);
41+
}

0 commit comments

Comments
 (0)