Skip to content

Commit 0905483

Browse files
committed
fix(iap-localizations): trim name and description to ASC limits
- Enforce 30-char limit on `name` and 45-char limit on `description` before sending to ASC - Surface ASC validation errors as 400 with detailed messages instead of generic 500 - Align server-side trimming with iOS app's `LocaleCard` constraints to prevent data loss
1 parent 4b50607 commit 0905483

2 files changed

Lines changed: 33 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- **Cursor pagination for IAP/Subscription price points**`listPricePoints` now accepts `limit` + `cursor` and returns `PaginatedResponse { data, nextCursor, totalCount }`. CLI: `asc iap price-points list --iap-id X --limit 200 --cursor <token>`. REST: `GET /api/v1/iap/{id}/price-points?territory=CHN&limit=200&cursor=<token>` returns `{ data, nextCursor, totalCount }`. Frontends loop until `nextCursor` is absent. Mirrors iOS's `loadPricePoints(territory:limit:cursor:) → PaginatedResult`.
2424

2525
### Fixed
26+
- **`POST/PATCH /api/v1/iap/{id}/localizations` returned bare 500 on ASC validation failures + dropped over-limit content** — when the description exceeded ASC's 45-character cap (or name exceeded 30), Hummingbird's default error path returned 500 with no body, browsers showed "Failed to fetch", and the user lost the typed text. The controller now (1) trims `name` to 30 grapheme clusters and `description` to 45 before sending, matching the iOS app's `LocaleCard` `limit:` constants, and (2) catches any remaining ASC error (e.g., invalid locale) and surfaces it as `400 Bad Request` with the underlying message so the UI can render a meaningful inline error.
2627
- **IAP/Subscription availability returned only ~10 territories instead of all ~175** — the parent endpoint's `include=availableTerritories` truncates the relationship to a single page. `getAvailability` now issues two parallel calls (attributes + dedicated `/availableTerritories?limit=200`) so the full territory list reaches the frontend's Availability tab. Matches the iOS SDK's `fetchAvailability` composition.
2728
- **`SubscriptionGroup._links` was empty**`GET /api/v1/apps/{id}/subscription-groups` items now embed populated `_links` for `listSubscriptions`, `listLocalizations`, `createSubscription`, `createLocalization`, `update`, `delete`. Migrated `SubscriptionGroup` from raw `affordances` to `structuredAffordances` so `apiLinks` auto-derives, and registered `_subscriptionGroupLocalizationRoutes` in `RESTPathResolver.ensureInitialized` so the nested localizations path resolves.
2829
- **`/api/v1/subscription-groups/{id}/subscriptions` returned 404** — the `_links.listSubscriptions` URL had no controller. Added `SubscriptionsController` and wired it in `RESTRoutes.swift`. Each subscription's `_links` already advertise the full child surface (localizations, availability, price-schedule, offers, etc.).

Sources/ASCCommand/Commands/Web/Controllers/IAPLocalizationsController.swift

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import Hummingbird
44
import HummingbirdWebSocket
55
import Infrastructure
66

7+
/// ASC's IAP localization field constraints. Public so future controllers and validators
8+
/// can stay in sync with the iOS app's `LocaleCard` limits (`limit: 30` / `limit: 45`).
9+
enum IAPLocalizationLimits {
10+
static let nameMaxChars = 30
11+
static let descriptionMaxChars = 45
12+
}
13+
14+
/// Trims to the given grapheme-cluster count. ASC counts characters the same way Swift's
15+
/// `String.count` does, so `.prefix(_:)` matches what the iOS view counter shows.
16+
private func clamp(_ value: String, to limit: Int) -> String {
17+
value.count <= limit ? value : String(value.prefix(limit))
18+
}
19+
720
/// Routes that return `InAppPurchaseLocalization` resources.
821
struct IAPLocalizationsController: Sendable {
922
let repo: any InAppPurchaseLocalizationRepository
@@ -25,16 +38,20 @@ struct IAPLocalizationsController: Sendable {
2538
guard let name = json["name"] as? String else {
2639
return jsonError("Missing name", status: .badRequest)
2740
}
28-
// ASC enforces field constraints (name ≤30, description ≤45 chars) and validates
29-
// the locale code. Without an explicit catch, Hummingbird returns a generic 500
30-
// with no body and the browser shows "Failed to fetch". Surface the underlying
31-
// message so the UI can render ASC's actual reason.
41+
// ASC enforces field constraints (name ≤30, description ≤45 grapheme clusters).
42+
// Trim before sending so callers don't have to validate client-side, and surface
43+
// any remaining ASC error (e.g. invalid locale) as a 400 with the real message
44+
// instead of Hummingbird's generic empty 500.
45+
let trimmedName = clamp(name, to: IAPLocalizationLimits.nameMaxChars)
46+
let trimmedDescription = (json["description"] as? String).map {
47+
clamp($0, to: IAPLocalizationLimits.descriptionMaxChars)
48+
}
3249
do {
3350
let created = try await self.repo.createLocalization(
3451
iapId: iapId,
3552
locale: locale,
36-
name: name,
37-
description: json["description"] as? String
53+
name: trimmedName,
54+
description: trimmedDescription
3855
)
3956
return try restFormat(created)
4057
} catch {
@@ -48,11 +65,18 @@ struct IAPLocalizationsController: Sendable {
4865
}
4966
let body = try await request.body.collect(upTo: 64 * 1024)
5067
let json = (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:]
68+
// Trim to ASC's limits (name ≤30, description ≤45) — same reasoning as POST.
69+
let trimmedName = (json["name"] as? String).map {
70+
clamp($0, to: IAPLocalizationLimits.nameMaxChars)
71+
}
72+
let trimmedDescription = (json["description"] as? String).map {
73+
clamp($0, to: IAPLocalizationLimits.descriptionMaxChars)
74+
}
5175
do {
5276
let updated = try await self.repo.updateLocalization(
5377
localizationId: localizationId,
54-
name: json["name"] as? String,
55-
description: json["description"] as? String
78+
name: trimmedName,
79+
description: trimmedDescription
5680
)
5781
return try restFormat(updated)
5882
} catch {

0 commit comments

Comments
 (0)