feat: expose mobile localization contracts
This commit is contained in:
@@ -19,7 +19,7 @@ export function GET() {
|
|||||||
discoveryVersion: 1,
|
discoveryVersion: 1,
|
||||||
error: {
|
error: {
|
||||||
code: "SERVICE_UNAVAILABLE",
|
code: "SERVICE_UNAVAILABLE",
|
||||||
message: "Instance keşif bilgisi geçici olarak kullanılamıyor.",
|
message: "Instance discovery is temporarily unavailable.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+32
-1
@@ -1,9 +1,14 @@
|
|||||||
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
|
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
|
||||||
|
import { negotiateLocale } from "@/server/api/v1/localization";
|
||||||
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||||
import { getSessionContextFromHeaders } from "@/server/auth/session";
|
import { getSessionContextFromHeaders } from "@/server/auth/session";
|
||||||
import { getServerConfig } from "@/server/config";
|
import { getServerConfig } from "@/server/config";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { clients } from "@/server/db/schema";
|
||||||
import { DomainError } from "@/server/domain/errors";
|
import { DomainError } from "@/server/domain/errors";
|
||||||
|
import { getPublicLocalizationMetadata } from "@/server/i18n/runtime";
|
||||||
import { getUserPreferences } from "@/server/settings/preferences";
|
import { getUserPreferences } from "@/server/settings/preferences";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,9 +17,26 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const context = await getSessionContextFromHeaders(new Headers(request.headers));
|
const context = await getSessionContextFromHeaders(new Headers(request.headers));
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
|
throw new DomainError("UNAUTHENTICATED", "Authentication required.", {
|
||||||
|
messageKey: "api.errors.unauthenticated",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
const preferences = getUserPreferences(domainActorFromSession(context));
|
const preferences = getUserPreferences(domainActorFromSession(context));
|
||||||
|
const portalLocale = context.profile.clientId
|
||||||
|
? getSqliteConnection().db
|
||||||
|
.select({ portalLocale: clients.portalLocale })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, context.profile.clientId))
|
||||||
|
.get()?.portalLocale ?? null
|
||||||
|
: null;
|
||||||
|
const resolvedLocale = negotiateLocale({
|
||||||
|
metadata: getPublicLocalizationMetadata(),
|
||||||
|
requestedLocale: requestUrl.searchParams.get("locale"),
|
||||||
|
acceptLanguage: request.headers.get("accept-language"),
|
||||||
|
preferredLocale: preferences.language,
|
||||||
|
portalLocale,
|
||||||
|
});
|
||||||
|
|
||||||
return apiV1Success({
|
return apiV1Success({
|
||||||
user: {
|
user: {
|
||||||
@@ -29,6 +51,15 @@ export async function GET(request: Request) {
|
|||||||
expiresAt: context.session.expiresAt.toISOString(),
|
expiresAt: context.session.expiresAt.toISOString(),
|
||||||
},
|
},
|
||||||
preferences,
|
preferences,
|
||||||
|
localization: {
|
||||||
|
language: preferences.language,
|
||||||
|
portalLocale,
|
||||||
|
resolvedLocale: resolvedLocale.locale,
|
||||||
|
requestedLocale: resolvedLocale.requestedLocale,
|
||||||
|
defaultLocale: resolvedLocale.defaultLocale,
|
||||||
|
source: resolvedLocale.source,
|
||||||
|
fallbackChain: resolvedLocale.fallbackChain,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return apiV1Error(error);
|
return apiV1Error(error);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"scenario": "English mobile client receives resolved user locale from /me",
|
||||||
|
"request": {
|
||||||
|
"path": "/api/v1/me?locale=en",
|
||||||
|
"headers": {
|
||||||
|
"Accept-Language": "en-US,en;q=0.9,tr;q=0.7",
|
||||||
|
"Cookie": "better-auth.session_token=<redacted>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"localization": {
|
||||||
|
"language": "en",
|
||||||
|
"portalLocale": null,
|
||||||
|
"resolvedLocale": "en",
|
||||||
|
"requestedLocale": "en",
|
||||||
|
"defaultLocale": "tr",
|
||||||
|
"source": "query",
|
||||||
|
"fallbackChain": ["en", "tr"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"scenario": "French is requested before the self-host instance activates it",
|
||||||
|
"request": {
|
||||||
|
"path": "/api/v1/me?locale=fr",
|
||||||
|
"headers": {
|
||||||
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8,tr;q=0.7",
|
||||||
|
"Cookie": "better-auth.session_token=<redacted>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"code": "UNSUPPORTED_LOCALE",
|
||||||
|
"details": {
|
||||||
|
"requestedLocale": "fr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"scenario": "Turkish mobile client discovers localization support",
|
||||||
|
"request": {
|
||||||
|
"path": "/api/v1/meta",
|
||||||
|
"headers": {
|
||||||
|
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "instance.localization",
|
||||||
|
"version": 1,
|
||||||
|
"status": "available",
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localization": {
|
||||||
|
"defaultLocale": "tr",
|
||||||
|
"negotiator": {
|
||||||
|
"queryParam": "locale",
|
||||||
|
"header": "Accept-Language",
|
||||||
|
"unsupportedLocaleCode": "UNSUPPORTED_LOCALE",
|
||||||
|
"matching": "exact-or-base-language"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
title: I18n Faz 8 — API v1 ve mobil hazırlık
|
||||||
|
status: completed
|
||||||
|
completed_at: 2026-07-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# I18n Faz 8 — API v1 ve mobil hazırlık
|
||||||
|
|
||||||
|
Bu fazda self-host instance içindeki dil modeli, gelecekteki React Native
|
||||||
|
istemcilerinin güvenli şekilde keşfedebileceği bir API v1 sözleşmesine
|
||||||
|
taşındı. Değişiklikler geriye uyumludur; mevcut v1 response alanları
|
||||||
|
değiştirilmedi, sadece additive alanlar eklendi.
|
||||||
|
|
||||||
|
## Tamamlananlar
|
||||||
|
|
||||||
|
- `instance.localization` capability kaydı eklendi.
|
||||||
|
- `/.well-known/neta` discovery document içine additive locale özeti eklendi.
|
||||||
|
- `/api/v1/meta` içinde localization contract genişletildi:
|
||||||
|
- `supportedLocales`
|
||||||
|
- `fallbacks`
|
||||||
|
- `catalogVersion`
|
||||||
|
- `negotiator`
|
||||||
|
- `responseContract`
|
||||||
|
- `/api/v1/me` response'u kullanıcının dil bilgisini ayrı döner:
|
||||||
|
- `language`
|
||||||
|
- `portalLocale`
|
||||||
|
- `resolvedLocale`
|
||||||
|
- `requestedLocale`
|
||||||
|
- `source`
|
||||||
|
- `fallbackChain`
|
||||||
|
- `Accept-Language` parser ve locale negotiation helper'ı eklendi.
|
||||||
|
- Gelecek resource endpoint'leri için localized response contract'ı yazıldı.
|
||||||
|
- Owner mutation contract'ında `translations` shape'i standartlaştırıldı:
|
||||||
|
`Record<locale, Record<field, string | null>>`.
|
||||||
|
- `UNSUPPORTED_LOCALE` hata kodu API response mapping'e eklendi.
|
||||||
|
- API hata response'larında client localization için `messageKey` kullanımı
|
||||||
|
netleştirildi.
|
||||||
|
- `tr`, `en`, `fr` için contract fixture'ları eklendi.
|
||||||
|
- `i18n:phase8-smoke` mobile localization negotiation ve contract shape'lerini
|
||||||
|
runtime olarak doğrulayacak şekilde eklendi.
|
||||||
|
|
||||||
|
## Mobil istemci davranışı
|
||||||
|
|
||||||
|
Mobil istemci ilk açılışta `/.well-known/neta` endpoint'ine gider ve
|
||||||
|
`instance.localization` capability'sini kontrol eder. Capability bilinmiyorsa
|
||||||
|
istemci bunu fatal hata olarak ele almamalı; capability listesi additive olduğu
|
||||||
|
için unknown capability değerleri yok sayılmalıdır.
|
||||||
|
|
||||||
|
Dil seçimi için önerilen sıra:
|
||||||
|
|
||||||
|
1. Kullanıcının explicit seçimi varsa `/api/v1/me?locale=xx` ile gönder.
|
||||||
|
2. Explicit seçim yoksa `Accept-Language` header'ını gönder.
|
||||||
|
3. Server `/api/v1/me.data.localization.resolvedLocale` değerini gerçek kaynak
|
||||||
|
kabul et.
|
||||||
|
|
||||||
|
`locale` query param'ı aktif olmayan bir locale'e işaret ederse API
|
||||||
|
`UNSUPPORTED_LOCALE` döner. `Accept-Language` içinde desteklenmeyen değer varsa
|
||||||
|
server sessizce instance default locale'e düşebilir.
|
||||||
|
|
||||||
|
Hata response'larında kullanıcıya gösterilecek metin mobile client tarafından
|
||||||
|
locale'e göre çözülmelidir. Server bu amaçla `error.details.messageKey`
|
||||||
|
alanını döndürür:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"code": "UNSUPPORTED_LOCALE",
|
||||||
|
"message": "Unsupported locale.",
|
||||||
|
"details": {
|
||||||
|
"messageKey": "validation.unsupportedLocale",
|
||||||
|
"requestedLocale": "fr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resource response contract
|
||||||
|
|
||||||
|
Gelecek `/api/v1/projects`, `/api/v1/tasks`, `/api/v1/clients` gibi resource
|
||||||
|
endpoint'leri şu shape'i kullanmalı:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
resource: TResource;
|
||||||
|
localized: TResource;
|
||||||
|
locale: string;
|
||||||
|
fallbackChain: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bu contract sayesinde mobil taraf original kaydı ve locale çözülmüş kaydı aynı
|
||||||
|
anda taşıyabilir.
|
||||||
|
|
||||||
|
## Mutation translations contract
|
||||||
|
|
||||||
|
Owner/freelancer mutation endpoint'leri çok dilli alanları şu shape ile kabul
|
||||||
|
etmeli:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
translations: {
|
||||||
|
tr: { name: "Marka sitesi", description: "..." },
|
||||||
|
en: { name: "Brand website", description: "..." },
|
||||||
|
fr: { name: "Site de marque", description: "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server sadece authorized owner mutation'larında bu alanı kabul eder. Müşteri
|
||||||
|
portal oturumu `translations` mutation contract'ını kullanamaz; portal locale
|
||||||
|
yalnızca kendi okuma response'unu etkiler.
|
||||||
|
|
||||||
|
## Cache ve URL notları
|
||||||
|
|
||||||
|
- `/.well-known/neta` ve `/api/v1/meta` public cache kullanır.
|
||||||
|
- Metadata içindeki URL'ler `APP_URL` üzerinden absolute üretilir.
|
||||||
|
- Locale catalog değişikliklerinde `catalogVersion` artacağı için mobil istemci
|
||||||
|
meta cache'ini güvenli şekilde invalidate edebilir.
|
||||||
|
|
||||||
|
## Fixture'lar
|
||||||
|
|
||||||
|
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json`
|
||||||
|
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json`
|
||||||
|
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Çalıştırılan komutlar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i18n:phase8-smoke
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
pnpm phase9:smoke
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
@@ -85,17 +85,23 @@ Hata:
|
|||||||
"ok": false,
|
"ok": false,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "UNAUTHENTICATED",
|
"code": "UNAUTHENTICATED",
|
||||||
"message": "Geçerli bir oturum gerekli.",
|
"message": "Authentication required.",
|
||||||
"details": {}
|
"details": {
|
||||||
|
"messageKey": "api.errors.unauthenticated"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır. Mobil istemci kullanıcıya göstereceği metni `message` alanından alabilir; program akışını yalnızca stabil `code` üzerinden kurmalıdır.
|
`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır.
|
||||||
|
Mobil istemci program akışını yalnızca stabil `code` üzerinden kurmalı, kullanıcıya
|
||||||
|
göstereceği metni mümkünse `details.messageKey` ile kendi catalog'undan çözmelidir.
|
||||||
|
`message` alanı debug/fallback içindir ve lokalizasyon kaynağı kabul edilmemelidir.
|
||||||
|
|
||||||
Mevcut hata kodları:
|
Mevcut hata kodları:
|
||||||
|
|
||||||
- `VALIDATION_ERROR`
|
- `VALIDATION_ERROR`
|
||||||
|
- `UNSUPPORTED_LOCALE`
|
||||||
- `UNAUTHENTICATED`
|
- `UNAUTHENTICATED`
|
||||||
- `FORBIDDEN`
|
- `FORBIDDEN`
|
||||||
- `NOT_FOUND`
|
- `NOT_FOUND`
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const distDir = path.join(process.cwd(), ".next", "i18n-phase8-smoke-dist");
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/phase9-api-boundary.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase8-smoke.json"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
|
||||||
|
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
|
||||||
|
|
||||||
|
const aliasScopeDir = path.join(distDir, "node_modules", "@");
|
||||||
|
fs.mkdirSync(aliasScopeDir, { recursive: true });
|
||||||
|
const serverAlias = path.join(aliasScopeDir, "server");
|
||||||
|
if (!fs.existsSync(serverAlias)) {
|
||||||
|
fs.symlinkSync(path.join(distDir, "server"), serverAlias, "dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase8-smoke.js")], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildLocalizationContract,
|
||||||
|
negotiateLocale,
|
||||||
|
parseAcceptLanguage,
|
||||||
|
UNSUPPORTED_LOCALE_CODE,
|
||||||
|
type ApiLocalizationMetadata,
|
||||||
|
} from "../server/api/v1/localization";
|
||||||
|
import {
|
||||||
|
NETA_CAPABILITIES,
|
||||||
|
type NetaLocalizedResponse,
|
||||||
|
type NetaTranslationMutationShape,
|
||||||
|
} from "../server/api/v1/contracts";
|
||||||
|
import { DomainError } from "../server/domain/errors";
|
||||||
|
|
||||||
|
const metadata: ApiLocalizationMetadata = {
|
||||||
|
defaultLocale: "tr",
|
||||||
|
supportedLocales: [
|
||||||
|
{
|
||||||
|
code: "tr",
|
||||||
|
name: "Turkish",
|
||||||
|
nativeName: "Türkçe",
|
||||||
|
status: "active",
|
||||||
|
fallbackLocale: null,
|
||||||
|
textDirection: "ltr",
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "en",
|
||||||
|
name: "English",
|
||||||
|
nativeName: "English",
|
||||||
|
status: "active",
|
||||||
|
fallbackLocale: "tr",
|
||||||
|
textDirection: "ltr",
|
||||||
|
builtIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "fr",
|
||||||
|
name: "French",
|
||||||
|
nativeName: "Français",
|
||||||
|
status: "draft",
|
||||||
|
fallbackLocale: "en",
|
||||||
|
textDirection: "ltr",
|
||||||
|
builtIn: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fallbacks: {
|
||||||
|
en: "tr",
|
||||||
|
fr: "en",
|
||||||
|
},
|
||||||
|
catalogVersion: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(parseAcceptLanguage("fr-FR, en-US;q=0.9, tr;q=0.7"), ["fr-FR", "en-US", "tr"]);
|
||||||
|
assert.deepEqual(parseAcceptLanguage("en;q=0.4, tr;q=0.9"), ["tr", "en"]);
|
||||||
|
|
||||||
|
const queryLocale = negotiateLocale({
|
||||||
|
metadata,
|
||||||
|
requestedLocale: "en",
|
||||||
|
acceptLanguage: "tr;q=0.9",
|
||||||
|
});
|
||||||
|
assert.equal(queryLocale.locale, "en");
|
||||||
|
assert.equal(queryLocale.source, "query");
|
||||||
|
assert.deepEqual(queryLocale.fallbackChain, ["en", "tr"]);
|
||||||
|
|
||||||
|
const baseLanguageMatch = negotiateLocale({
|
||||||
|
metadata,
|
||||||
|
acceptLanguage: "en-US,en;q=0.9",
|
||||||
|
});
|
||||||
|
assert.equal(baseLanguageMatch.locale, "en");
|
||||||
|
assert.equal(baseLanguageMatch.requestedLocale, "en-US");
|
||||||
|
assert.equal(baseLanguageMatch.source, "accept-language");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => negotiateLocale({ metadata, requestedLocale: "fr" }),
|
||||||
|
(error) =>
|
||||||
|
error instanceof DomainError &&
|
||||||
|
error.code === UNSUPPORTED_LOCALE_CODE &&
|
||||||
|
error.message === "Unsupported locale." &&
|
||||||
|
error.details?.messageKey === "validation.unsupportedLocale" &&
|
||||||
|
error.details?.requestedLocale === "fr",
|
||||||
|
);
|
||||||
|
|
||||||
|
const contract = buildLocalizationContract(metadata);
|
||||||
|
assert.equal(contract.negotiator.queryParam, "locale");
|
||||||
|
assert.equal(contract.negotiator.header, "Accept-Language");
|
||||||
|
assert.equal(contract.negotiator.unsupportedLocaleCode, UNSUPPORTED_LOCALE_CODE);
|
||||||
|
assert.equal(contract.responseContract.localizedResourceField, "localized");
|
||||||
|
assert.equal(contract.responseContract.translationsField, "translations");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
NETA_CAPABILITIES.some((capability) => capability.id === "instance.localization" && capability.status === "available"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const localizedResponse: NetaLocalizedResponse<{ title: string }> = {
|
||||||
|
resource: { title: "Marka sitesi" },
|
||||||
|
localized: { title: "Brand website" },
|
||||||
|
locale: "en",
|
||||||
|
fallbackChain: ["en", "tr"],
|
||||||
|
};
|
||||||
|
assert.equal(localizedResponse.localized.title, "Brand website");
|
||||||
|
|
||||||
|
const mutationShape: NetaTranslationMutationShape = {
|
||||||
|
tr: { title: "Marka sitesi" },
|
||||||
|
en: { title: "Brand website", description: null },
|
||||||
|
};
|
||||||
|
assert.equal(mutationShape.en.title, "Brand website");
|
||||||
|
|
||||||
|
console.log("I18n phase 8 mobile API localization smoke passed.");
|
||||||
@@ -18,16 +18,32 @@ for (const value of [
|
|||||||
'NETA_PROTOCOL = "neta"',
|
'NETA_PROTOCOL = "neta"',
|
||||||
"NETA_DISCOVERY_VERSION = 1",
|
"NETA_DISCOVERY_VERSION = 1",
|
||||||
'NETA_API_VERSION = "1"',
|
'NETA_API_VERSION = "1"',
|
||||||
|
'"instance.localization"',
|
||||||
'"auth.device-pairing"',
|
'"auth.device-pairing"',
|
||||||
'status: "planned"',
|
'status: "planned"',
|
||||||
"minimumSupportedVersion",
|
"minimumSupportedVersion",
|
||||||
"workspaceName",
|
"workspaceName",
|
||||||
"metaTitle",
|
"metaTitle",
|
||||||
"faviconUrl",
|
"faviconUrl",
|
||||||
|
"NetaLocalizedResponse",
|
||||||
|
"NetaTranslationMutationShape",
|
||||||
|
"ownerMutationTranslations",
|
||||||
|
"absoluteUrl",
|
||||||
]) {
|
]) {
|
||||||
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
|
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localization = read("server/api/v1/localization.ts");
|
||||||
|
for (const value of [
|
||||||
|
"parseAcceptLanguage",
|
||||||
|
"negotiateLocale",
|
||||||
|
"UNSUPPORTED_LOCALE",
|
||||||
|
"Accept-Language",
|
||||||
|
"exact-or-base-language",
|
||||||
|
]) {
|
||||||
|
assert.ok(localization.includes(value), `Missing localization contract marker: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
const instanceService = read("server/instance/service.ts");
|
const instanceService = read("server/instance/service.ts");
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
instanceService,
|
instanceService,
|
||||||
@@ -46,6 +62,39 @@ assert.match(
|
|||||||
/getUserPreferences/,
|
/getUserPreferences/,
|
||||||
"Authenticated mobile metadata must expose the persisted user color mode",
|
"Authenticated mobile metadata must expose the persisted user color mode",
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
read("app/api/v1/me/route.ts"),
|
||||||
|
/resolvedLocale/,
|
||||||
|
"Authenticated mobile metadata must expose resolved localization state",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
read("app/api/v1/me/route.ts"),
|
||||||
|
/portalLocale/,
|
||||||
|
"Authenticated mobile metadata must expose client portal locale when available",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
read("app/api/v1/meta/route.ts"),
|
||||||
|
/stale-while-revalidate=300/,
|
||||||
|
"Meta endpoint must keep a public revalidation cache contract",
|
||||||
|
);
|
||||||
|
|
||||||
|
const domainErrors = read("server/domain/errors.ts");
|
||||||
|
assert.match(
|
||||||
|
domainErrors,
|
||||||
|
/UNSUPPORTED_LOCALE/,
|
||||||
|
"Unsupported locale must have a stable API error code",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const fixture of [
|
||||||
|
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json",
|
||||||
|
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json",
|
||||||
|
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json",
|
||||||
|
]) {
|
||||||
|
const parsed = JSON.parse(read(fixture));
|
||||||
|
assert.ok(parsed.request, `Missing request fixture in ${fixture}`);
|
||||||
|
assert.ok(parsed.expected, `Missing expected fixture in ${fixture}`);
|
||||||
|
}
|
||||||
|
|
||||||
for (const route of requiredRoutes.slice(1)) {
|
for (const route of requiredRoutes.slice(1)) {
|
||||||
const content = read(route);
|
const content = read(route);
|
||||||
@@ -66,6 +115,7 @@ for (const futureRoute of [
|
|||||||
const runtimeFiles = [
|
const runtimeFiles = [
|
||||||
...requiredRoutes,
|
...requiredRoutes,
|
||||||
"server/api/v1/contracts.ts",
|
"server/api/v1/contracts.ts",
|
||||||
|
"server/api/v1/localization.ts",
|
||||||
"server/api/v1/responses.ts",
|
"server/api/v1/responses.ts",
|
||||||
"server/api/v1/runtime.ts",
|
"server/api/v1/runtime.ts",
|
||||||
"server/instance/service.ts",
|
"server/instance/service.ts",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PublicBranding } from "../../branding/service";
|
import type { PublicBranding } from "../../branding/service";
|
||||||
import type { InstanceIdentity } from "../../instance/service";
|
import type { InstanceIdentity } from "../../instance/service";
|
||||||
|
import type { getPublicLocalizationMetadata } from "../../i18n/runtime";
|
||||||
|
import { buildLocalizationContract } from "./localization";
|
||||||
|
|
||||||
export const NETA_PROTOCOL = "neta" as const;
|
export const NETA_PROTOCOL = "neta" as const;
|
||||||
export const NETA_DISCOVERY_VERSION = 1 as const;
|
export const NETA_DISCOVERY_VERSION = 1 as const;
|
||||||
@@ -16,9 +18,22 @@ export type NetaCapability = {
|
|||||||
access: CapabilityAccess;
|
access: CapabilityAccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NetaLocalizedResponse<TResource> = {
|
||||||
|
resource: TResource;
|
||||||
|
localized: TResource;
|
||||||
|
locale: string;
|
||||||
|
fallbackChain: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NetaTranslationMutationShape = Record<
|
||||||
|
string,
|
||||||
|
Record<string, string | null>
|
||||||
|
>;
|
||||||
|
|
||||||
export const NETA_CAPABILITIES = [
|
export const NETA_CAPABILITIES = [
|
||||||
{ id: "instance.discovery", version: 1, status: "available", access: "public" },
|
{ id: "instance.discovery", version: 1, status: "available", access: "public" },
|
||||||
{ id: "instance.branding", version: 1, status: "available", access: "public" },
|
{ id: "instance.branding", version: 1, status: "available", access: "public" },
|
||||||
|
{ id: "instance.localization", version: 1, status: "available", access: "public" },
|
||||||
{ id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" },
|
{ id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" },
|
||||||
{ id: "files.local", version: 1, status: "available", access: "session" },
|
{ id: "files.local", version: 1, status: "available", access: "session" },
|
||||||
{ id: "freelancer.core", version: 1, status: "available", access: "freelancer" },
|
{ id: "freelancer.core", version: 1, status: "available", access: "freelancer" },
|
||||||
@@ -43,6 +58,18 @@ export type NetaDiscoveryDocument = {
|
|||||||
httpsRequired: true;
|
httpsRequired: true;
|
||||||
insecureLoopbackAllowed: true;
|
insecureLoopbackAllowed: true;
|
||||||
};
|
};
|
||||||
|
localization: {
|
||||||
|
defaultLocale: string;
|
||||||
|
supportedLocales: Array<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
nativeName: string;
|
||||||
|
status: string;
|
||||||
|
textDirection: string;
|
||||||
|
}>;
|
||||||
|
catalogVersion: number;
|
||||||
|
};
|
||||||
|
capabilities: readonly NetaCapability[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NetaInstanceMetadata = {
|
export type NetaInstanceMetadata = {
|
||||||
@@ -73,6 +100,20 @@ export type NetaInstanceMetadata = {
|
|||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
};
|
};
|
||||||
|
localization: ReturnType<typeof buildLocalizationContract>;
|
||||||
|
contracts: {
|
||||||
|
localizedResponse: {
|
||||||
|
resource: "original database record";
|
||||||
|
localized: "locale-resolved record";
|
||||||
|
locale: "resolved locale code";
|
||||||
|
fallbackChain: "ordered locale fallback chain";
|
||||||
|
};
|
||||||
|
ownerMutationTranslations: {
|
||||||
|
field: "translations";
|
||||||
|
shape: "Record<locale, Record<field, string | null>>";
|
||||||
|
unsupportedLocaleCode: "UNSUPPORTED_LOCALE";
|
||||||
|
};
|
||||||
|
};
|
||||||
client: {
|
client: {
|
||||||
minimumSupportedVersion: string | null;
|
minimumSupportedVersion: string | null;
|
||||||
platforms: readonly ["ios", "android"];
|
platforms: readonly ["ios", "android"];
|
||||||
@@ -96,6 +137,7 @@ type ContractInput = {
|
|||||||
minimumMobileClientVersion: string | null;
|
minimumMobileClientVersion: string | null;
|
||||||
identity: InstanceIdentity;
|
identity: InstanceIdentity;
|
||||||
branding: PublicBranding;
|
branding: PublicBranding;
|
||||||
|
localization: ReturnType<typeof getPublicLocalizationMetadata>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDiscoveryDocument(
|
export function buildDiscoveryDocument(
|
||||||
@@ -118,6 +160,18 @@ export function buildDiscoveryDocument(
|
|||||||
httpsRequired: true,
|
httpsRequired: true,
|
||||||
insecureLoopbackAllowed: true,
|
insecureLoopbackAllowed: true,
|
||||||
},
|
},
|
||||||
|
localization: {
|
||||||
|
defaultLocale: input.localization.defaultLocale,
|
||||||
|
supportedLocales: input.localization.supportedLocales.map((locale) => ({
|
||||||
|
code: locale.code,
|
||||||
|
name: locale.name,
|
||||||
|
nativeName: locale.nativeName,
|
||||||
|
status: locale.status,
|
||||||
|
textDirection: locale.textDirection,
|
||||||
|
})),
|
||||||
|
catalogVersion: input.localization.catalogVersion,
|
||||||
|
},
|
||||||
|
capabilities: NETA_CAPABILITIES,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +206,20 @@ export function buildInstanceMetadata(
|
|||||||
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
||||||
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
||||||
},
|
},
|
||||||
|
localization: buildLocalizationContract(input.localization),
|
||||||
|
contracts: {
|
||||||
|
localizedResponse: {
|
||||||
|
resource: "original database record",
|
||||||
|
localized: "locale-resolved record",
|
||||||
|
locale: "resolved locale code",
|
||||||
|
fallbackChain: "ordered locale fallback chain",
|
||||||
|
},
|
||||||
|
ownerMutationTranslations: {
|
||||||
|
field: "translations",
|
||||||
|
shape: "Record<locale, Record<field, string | null>>",
|
||||||
|
unsupportedLocaleCode: "UNSUPPORTED_LOCALE",
|
||||||
|
},
|
||||||
|
},
|
||||||
client: {
|
client: {
|
||||||
minimumSupportedVersion: input.minimumMobileClientVersion,
|
minimumSupportedVersion: input.minimumMobileClientVersion,
|
||||||
platforms: ["ios", "android"],
|
platforms: ["ios", "android"],
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { getPublicLocalizationMetadata } from "@/server/i18n/runtime";
|
||||||
|
import { normalizeLocaleCode } from "@/server/i18n/locale";
|
||||||
|
import { DomainError } from "@/server/domain/errors";
|
||||||
|
|
||||||
|
export const UNSUPPORTED_LOCALE_CODE = "UNSUPPORTED_LOCALE" as const;
|
||||||
|
|
||||||
|
export type ApiLocalizationMetadata = ReturnType<typeof getPublicLocalizationMetadata>;
|
||||||
|
|
||||||
|
export type LocaleNegotiationSource =
|
||||||
|
| "query"
|
||||||
|
| "accept-language"
|
||||||
|
| "preference"
|
||||||
|
| "portal"
|
||||||
|
| "instance";
|
||||||
|
|
||||||
|
export type LocaleNegotiationInput = {
|
||||||
|
metadata: ApiLocalizationMetadata;
|
||||||
|
requestedLocale?: string | null;
|
||||||
|
acceptLanguage?: string | null;
|
||||||
|
preferredLocale?: string | null;
|
||||||
|
portalLocale?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocaleNegotiationResult = {
|
||||||
|
locale: string;
|
||||||
|
requestedLocale: string | null;
|
||||||
|
defaultLocale: string;
|
||||||
|
source: LocaleNegotiationSource;
|
||||||
|
fallbackChain: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseAcceptLanguage(value: string | null | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((part, index) => {
|
||||||
|
const [rawLocale, ...params] = part.trim().split(";");
|
||||||
|
const qValue = params
|
||||||
|
.map((param) => param.trim())
|
||||||
|
.find((param) => param.startsWith("q="))
|
||||||
|
?.slice(2);
|
||||||
|
const q = qValue ? Number.parseFloat(qValue) : 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: normalizeLocaleCode(rawLocale),
|
||||||
|
q: Number.isFinite(q) ? q : 0,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { locale: string; q: number; index: number } => Boolean(item.locale) && item.q > 0)
|
||||||
|
.sort((a, b) => b.q - a.q || a.index - b.index)
|
||||||
|
.map((item) => item.locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function negotiateLocale(input: LocaleNegotiationInput): LocaleNegotiationResult {
|
||||||
|
const activeLocales = new Set(
|
||||||
|
input.metadata.supportedLocales
|
||||||
|
.filter((locale) => locale.status === "active")
|
||||||
|
.map((locale) => locale.code),
|
||||||
|
);
|
||||||
|
const defaultLocale = activeLocales.has(input.metadata.defaultLocale)
|
||||||
|
? input.metadata.defaultLocale
|
||||||
|
: input.metadata.supportedLocales.find((locale) => locale.status === "active")?.code ?? input.metadata.defaultLocale;
|
||||||
|
|
||||||
|
const candidates: Array<{ locale: string | null; source: LocaleNegotiationSource; strict: boolean }> = [
|
||||||
|
{ locale: normalizeLocaleCode(input.requestedLocale), source: "query", strict: true },
|
||||||
|
{ locale: normalizeLocaleCode(input.portalLocale), source: "portal", strict: false },
|
||||||
|
{ locale: normalizeLocaleCode(input.preferredLocale), source: "preference", strict: false },
|
||||||
|
...parseAcceptLanguage(input.acceptLanguage).map((locale) => ({
|
||||||
|
locale,
|
||||||
|
source: "accept-language" as const,
|
||||||
|
strict: false,
|
||||||
|
})),
|
||||||
|
{ locale: defaultLocale, source: "instance", strict: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate.locale) continue;
|
||||||
|
const matched = matchSupportedLocale(candidate.locale, activeLocales);
|
||||||
|
if (matched) {
|
||||||
|
return {
|
||||||
|
locale: matched,
|
||||||
|
requestedLocale: candidate.locale,
|
||||||
|
defaultLocale,
|
||||||
|
source: candidate.source,
|
||||||
|
fallbackChain: buildFallbackChain(matched, input.metadata, defaultLocale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.strict) {
|
||||||
|
throw new DomainError(
|
||||||
|
UNSUPPORTED_LOCALE_CODE,
|
||||||
|
"Unsupported locale.",
|
||||||
|
{
|
||||||
|
messageKey: "validation.unsupportedLocale",
|
||||||
|
requestedLocale: candidate.locale,
|
||||||
|
supportedLocales: [...activeLocales],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: defaultLocale,
|
||||||
|
requestedLocale: null,
|
||||||
|
defaultLocale,
|
||||||
|
source: "instance",
|
||||||
|
fallbackChain: buildFallbackChain(defaultLocale, input.metadata, defaultLocale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalizationContract(metadata: ApiLocalizationMetadata) {
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
negotiator: {
|
||||||
|
queryParam: "locale",
|
||||||
|
header: "Accept-Language",
|
||||||
|
unsupportedLocaleCode: UNSUPPORTED_LOCALE_CODE,
|
||||||
|
matching: "exact-or-base-language",
|
||||||
|
},
|
||||||
|
responseContract: {
|
||||||
|
localizedResourceField: "localized",
|
||||||
|
translationsField: "translations",
|
||||||
|
fallbackChainField: "fallbackChain",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSupportedLocale(locale: string, activeLocales: Set<string>): string | null {
|
||||||
|
if (activeLocales.has(locale)) return locale;
|
||||||
|
const base = locale.split("-")[0];
|
||||||
|
if (base && activeLocales.has(base)) return base;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackChain(
|
||||||
|
locale: string,
|
||||||
|
metadata: ApiLocalizationMetadata,
|
||||||
|
defaultLocale: string,
|
||||||
|
): string[] {
|
||||||
|
const chain = [locale];
|
||||||
|
const seen = new Set(chain);
|
||||||
|
let cursor = metadata.fallbacks[locale] ?? null;
|
||||||
|
|
||||||
|
while (cursor && !seen.has(cursor)) {
|
||||||
|
chain.push(cursor);
|
||||||
|
seen.add(cursor);
|
||||||
|
cursor = metadata.fallbacks[cursor] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.has(defaultLocale)) {
|
||||||
|
chain.push(defaultLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import "server-only";
|
|||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
import { getPublicBranding } from "../../branding/runtime";
|
import { getPublicBranding } from "../../branding/runtime";
|
||||||
import { getServerConfig } from "../../config";
|
import { getServerConfig } from "../../config";
|
||||||
|
import { getPublicLocalizationMetadata } from "../../i18n/runtime";
|
||||||
import { getInstanceService } from "../../instance/runtime";
|
import { getInstanceService } from "../../instance/runtime";
|
||||||
import {
|
import {
|
||||||
buildDiscoveryDocument,
|
buildDiscoveryDocument,
|
||||||
@@ -25,5 +26,6 @@ function getContractInput() {
|
|||||||
minimumMobileClientVersion: config.minimumMobileClientVersion,
|
minimumMobileClientVersion: config.minimumMobileClientVersion,
|
||||||
identity: getInstanceService().getIdentity(),
|
identity: getInstanceService().getIdentity(),
|
||||||
branding: getPublicBranding(),
|
branding: getPublicBranding(),
|
||||||
|
localization: getPublicLocalizationMetadata(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": ".next/i18n-phase8-smoke-dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": false,
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "ES2022",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"server/api/v1/**/*.ts",
|
||||||
|
"server/domain/errors.ts",
|
||||||
|
"server/i18n/locale.ts",
|
||||||
|
"lib/i18n/types.ts",
|
||||||
|
"scripts/i18n-phase8-smoke.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user