feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -0,0 +1,145 @@
|
||||
# Site languages, language setup and public URL pattern
|
||||
|
||||
Contract for `features/site_languages.feature`, `features/language_setup.feature` and `features/site_public_url_pattern.feature`. The SPA mocks `/v1` from this page.
|
||||
|
||||
Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without `settings.manage_sites` is 403 `missing_permission`. Writes need a real session; API keys are refused.
|
||||
|
||||
An owner is a verified user holding `settings.manage_sites`. A reader is any other verified user.
|
||||
|
||||
## Visibility exception (accepted)
|
||||
|
||||
`version_count` and `published_count` in `GET /v1/site/{key}/languages` are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of `docs/design/localization-tables.md` ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read.
|
||||
|
||||
## Languages of a site
|
||||
|
||||
The supported tags are the list of `internal/domain/language` (`en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`). Tags are normalized: `pt-br` is stored as `pt-BR`.
|
||||
|
||||
### `GET /v1/site/{key}/languages`
|
||||
|
||||
Any verified user (session or API key). 404 `site_not_found`.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"needs_setup": false,
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"},
|
||||
{"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"}
|
||||
],
|
||||
"available": [
|
||||
{"lang": "pt", "substitute": null},
|
||||
{"lang": "pt-PT", "substitute": "pt"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `revision` is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on `PUT`.
|
||||
- `languages` lists the default first, then the other enabled languages by tag, then the disabled ones by tag.
|
||||
- `social_locale_state` is `set` (an explicit `social_locale`), `derived` (no explicit value and the tag has a region: the effective value is the tag with an underscore, `en-GB` gives `en_GB`) or `not_set`. `social_locale` is only the explicit value, so the client computes the derived one from the tag.
|
||||
- `available` lists the tags an owner can pick. A tag with a non-null `substitute` is not supported itself; only the broader tag is. The server refuses it with `unsupported_language`, so the client sends the substitute once the user confirms.
|
||||
- `needs_setup` is true when the site has no confirmed default language. Then `default` is `null`, `languages` is `[]` and `PUT` answers 409 `setup_required`. Sites created after the migration have no language row and need setup too.
|
||||
|
||||
### `PUT /v1/site/{key}/languages`
|
||||
|
||||
Owner only. The whole set is replaced in one transaction under the site lock.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "social_locale": null},
|
||||
{"lang": "fr", "enabled": true, "social_locale": "fr_CA"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`revision`, `default`, `languages` and each `enabled` are required (400 when absent). `social_locale` is optional and `null` means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision.
|
||||
|
||||
Checks run in this order:
|
||||
|
||||
| Order | Status | Body | When |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 404 | `site_not_found` | unknown key |
|
||||
| 2 | 409 | `setup_required` | the site has no confirmed default (any body) |
|
||||
| 3 | 409 | `languages_changed` | `revision` is not the current one; nothing is applied |
|
||||
| 4 | 422 | `invalid_language` | a tag is not a language tag (`english`, empty) |
|
||||
| 4 | 422 | `unsupported_language` | a well formed tag that is not supported (`xx`, `pt-PT`) |
|
||||
| 4 | 422 | `duplicate_language` | the same tag twice, case ignored |
|
||||
| 4 | 422 | `invalid_social_locale` | not `ll_RR` or `lll_RR` (letters, one underscore), empty string, or the language part differs from the tag's language |
|
||||
| 5 | 422 | `no_enabled_language` | no language is enabled, `languages: []` included |
|
||||
| 6 | 422 | `default_not_enabled` | `default` is not in the list, not enabled, or not a supported tag |
|
||||
| 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) |
|
||||
| any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
|
||||
|
||||
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6.
|
||||
|
||||
## First-time language setup
|
||||
|
||||
One global setup for every site with no confirmed default language. Owner only (`GET` as well: a reader is 403).
|
||||
|
||||
### `GET /v1/language/setup`
|
||||
|
||||
```json
|
||||
{
|
||||
"sites": [
|
||||
{"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}},
|
||||
{"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null}
|
||||
],
|
||||
"available": [{"lang": "en", "substitute": null}]
|
||||
}
|
||||
```
|
||||
|
||||
`sites` lists the pending sites by id. `suggestion` comes from `Site.Locale` and keeps the region (`pt-BR` stays `pt-BR`); `substituted` is true when only the broader tag is supported (`fr-BE` gives `fr`). `suggestion` is `null` without a locale or without a supported language. An empty `sites` list means nothing is pending.
|
||||
|
||||
### `POST /v1/language/setup`
|
||||
|
||||
```json
|
||||
{"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]}
|
||||
```
|
||||
|
||||
200 with `{"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}`. `assigned` counts the articles that got a language (soft-deleted ones included). `sites` lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with `assigned: 0`.
|
||||
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | body-mismatch message | no `sites` key or malformed JSON |
|
||||
| 404 | `site_not_found` | a key does not exist |
|
||||
| 409 | `setup_in_progress` | another setup holds the site locks (nothing applied) |
|
||||
| 422 | `invalid_language` / `unsupported_language` | a bad `lang`, empty included |
|
||||
| 422 | `duplicate_site` | the same key twice |
|
||||
| 422 | `setup_incomplete` | a pending site is missing from the request |
|
||||
| 500 | `Internal server error` | a write failed; everything is rolled back |
|
||||
|
||||
All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is `setup_in_progress`. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled.
|
||||
|
||||
The 200 body also has `skipped`: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, `[]` when none). Their requested `lang` is ignored, never applied.
|
||||
|
||||
```json
|
||||
{"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]}
|
||||
```
|
||||
|
||||
## Lock contention
|
||||
|
||||
Every locking path of `PUT /v1/site/{key}/languages` and `POST /v1/language/setup` runs inside the transactor. A lock that cannot be taken in time (SQLite `SQLITE_BUSY`/`SQLITE_LOCKED`, MySQL 3572 and 1205, PostgreSQL `55P03`) is recognised once, by the database driver, and reaches the use case as `transaction.ErrLockContention`. The PUT answers 409 `languages_changed` and the setup answers 409 `setup_in_progress`. Nothing is applied and a retry is safe. It is never a 500.
|
||||
|
||||
## SQLite does not lock rows
|
||||
|
||||
The pinned SQLite driver (`github.com/glebarez/sqlite`) drops the `FOR UPDATE` clause: the `SELECT ... FOR UPDATE [NOWAIT]` that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with `SQLITE_BUSY` when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. `test/localization_concurrency_test.go` pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups.
|
||||
|
||||
## Activity log
|
||||
|
||||
Both writes are recorded in the `sites` category, once after the commit and never for a read or a no-op:
|
||||
|
||||
| Action | Target | Changes and params |
|
||||
| --- | --- | --- |
|
||||
| `site.languages_edit` | the site | `default_language` (before, after) when the default changed, `languages` (before, after: tags sorted, `(off)` for disabled, `[social_locale]`). A PUT that changes nothing records nothing. |
|
||||
| `site.language_setup` | one event per site confirmed by the POST | `default_language` ("" to the tag), param `assigned` (articles that got the language) |
|
||||
|
||||
A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded.
|
||||
|
||||
## Public URL pattern
|
||||
|
||||
`public_url_pattern` is a string or `null` on every site JSON (`GET /v1/site/`, `POST`, `PATCH`). It is read with the existing list and written with the existing `PATCH /v1/site/{id}` (owner). No new route. A `PATCH` without the key leaves it unchanged; `null` or `""` unsets it; surrounding spaces are trimmed.
|
||||
|
||||
Validation, in this order, answers 422 with the code as plain text: `invalid_pattern` (more than 512 characters), `invalid_scheme` (not `https://` or `http://`), `invalid_host` (no host, for example `https:///{slug}`), `unknown_placeholder` (anything between braces other than `{lang}` and `{slug}`, or a stray brace), `not_encoded` (a space, a non-ASCII character or other character that needs URL encoding; `%XX` sequences are fine), `missing_slug` (no `{slug}`). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the `public_url_pattern` field.
|
||||
Reference in new issue
Block a user