20 KiB
Article versions, translations, revisions and story move
Contract for features/article_translation_create.feature, features/article_edit_revision.feature and features/article_story_lifecycle.feature. The SPA mocks /v1 from this page. The article object itself is described in articles.md, the languages of a site in site-languages.md.
Vocabulary
- A story groups the language versions of one article inside one site. A version is one article row: it has its own id,
lang, title, slug, content, publication andcreated_by. A story holds at most one live version per language. revisionis the edit counter of a version, starting at1, raised by1per saved change (see What raises revision). An internal content counter is also kept; it is never exposed on a version, only assource.revision.- A reader of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose
content.readcovers it (all, orownand the caller created it). - Missing means no readable version. Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
Errors
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
| Header | Set on | Value |
|---|---|---|
X-Current-Revision |
409 revision_conflict |
the current revision of the version, an integer |
X-Existing-Article-Id |
409 language_exists |
id of the readable version that already holds the language |
X-Conflicting-Articles |
409 slug_conflict |
ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
X-Missing-Languages |
422 language_not_enabled on a move or on PATCH site_id |
tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
X-Slug-Suggestion |
409 slug_taken |
first free <slug>-N (existing) |
X-Missing-Permission |
403 missing_permission |
the permission key (existing) |
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (Access-Control-Expose-Headers is unchanged).
| Status | Body | Meaning |
|---|---|---|
| 400 | The request cannot be processed due to a mismatch in the format of the body. |
undecodable or invalid body, missing copy, copy: false with a source, if_revision invalid, an unknown or empty update |
| 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
invalid id in the path |
| 400 | the validator text (for example Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag) |
lang or title missing, title shorter than 3 or longer than 200 characters on a create |
| 400 | Article title must be between 3 and 200 characters. |
a PATCH title outside 3 to 200 runes |
| 400 | invalid_slug |
a PATCH slug that is not a slug |
| 401 | You must be logged in to access this resource. |
no valid session or key |
| 403 | missing_permission |
the caller lacks the permission (X-Missing-Permission) |
| 404 | Article not found |
the version, or the story, does not exist or the caller cannot read it |
| 404 | source_unavailable |
the chosen source does not exist or is not readable; the same answer for both |
| 404 | site_not_found |
POST /v1/story/{story_id}/move with an unknown site key |
| 409 | revision_conflict |
if_revision is not the current revision (X-Current-Revision) |
| 409 | language_exists |
the language already has a readable version (X-Existing-Article-Id) |
| 409 | language_unavailable |
one neutral answer: the language is disabled on the site, or its version exists but is not readable by the caller, or a concurrent request created it first |
| 409 | setup_required |
the version has no language yet (lang ""): the site default is not confirmed |
| 409 | slug_taken |
a PATCH slug used by another article of the same site and language (X-Slug-Suggestion) |
| 409 | slug_conflict |
a move would put an address that already exists at the destination (X-Conflicting-Articles) |
| 409 | story_move_required |
PATCH site_id on a version whose story holds several versions |
| 409 | story_busy |
the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
| 422 | invalid_language |
the tag is not a language tag |
| 422 | unsupported_language |
a valid tag outside the supported list |
| 422 | language_not_enabled |
the site has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, X-Missing-Languages) |
| 422 | language_disabled |
publishing an offline version whose language is disabled on its site |
| 422 | source_other_story |
the source belongs to another story |
Fields added to an article read
Added to the article object of GET /v1/article/{id}, POST, PATCH, publish, unpublish, and for revision also to each item of GET /v1/article/. Every other key keeps its name, value and type.
{
"id": 10,
"lang": "en",
"story_id": 5,
"revision": 3,
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
"translations": [
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
],
"source": {"article_id": 10, "revision": 4}
}
revisionis a positive integer on every read and write, public reads included.capabilitieslists what the caller may do with this version, always in this order and always starting withread:edit(content.updatecovers it),publish(content.publish),delete(content.delete),create_translation(the caller holdscontent.create). An anonymous caller gets["read"].translationslists the readable versions of the story, the version being read included, withid,lang,title,is_online,language_enabledand their owncapabilities. A version without language (lang"") reportslanguage_enabled: true. The order is the default language of the site first, then the other tags in ascending order (en,ar,de,fr); a disabled language is still listed withlanguage_enabled: false. A single-version article lists itself. An anonymous caller gets[].sourceis{"article_id", "revision"}when the version was created by copying another version and that source still exists and is readable by the caller, otherwisenull(a version created empty, a deleted source, an unreadable source, an anonymous caller).source.revisionis the content revision of the source at the moment of the copy.
What raises revision
A PATCH raises revision by exactly 1 when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
| Changed field | revision |
internal content revision |
|---|---|---|
title, body, excerpt, slug, cover_image_alt, seo_title, seo_description, social_title, social_description |
+1 | +1 |
author_ids, cover_image_id, social_image_id, canonical_url, noindex, seo_dismissed, site_id |
+1 | unchanged |
Existing rows start at revision 1. A story move raises each moved version by 1.
Editing: PATCH /v1/article/{id}
Verified session or API key with content.update on the version (403 missing_permission otherwise, checked first) and able to read it (404 Article not found otherwise, checked second), so a stale if_revision never reveals anything to a caller who cannot edit or read the version.
title: 3 to 200 runes (a 4-byte emoji counts as one)."", 2 and 201 runes answer400 Article title must be between 3 and 200 characters.;nullanswers400with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.slug: changed only when sent.""regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers409 slug_takenwithX-Slug-Suggestion. Sending the current slug changes nothing.if_revision: optional integer>= 1. A value that is0, negative, a string, a fraction ornullanswers400(body mismatch).if_revisionalone is not an update (400). When it differs from the currentrevision, nothing is stored and the answer is409 revision_conflictwithX-Current-Revision, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the sameif_revision, one answers200and the other409. Withoutif_revisionthe last write wins. To overwrite after a conflict, sendif_revisionequal to theX-Current-Revisionthe conflict showed; if somebody saved in between, the answer is409again with the newer revision.site_idon a version whose story holds several versions the caller can read answers409 story_move_requiredand changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story,content.updateis required on every version of the story, readable or not (403 missing_permissionotherwise, naming no version) and the request runs the story move rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (title,author_ids,slug, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and everyrevisionunchanged, and the edited version'srevisionrises by exactly 1 for the whole request. To move a story with several readable versions, use the story move. On a story with one version it moves the version when its language is enabled at the destination (422 language_not_enabled, withX-Missing-Languages, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (409 slug_taken).lang,story_id,source_article_idandcopyin the body are ignored.
The 200 body is the full article object with the new revision.
Publishing
PATCH /v1/article/{id}/publish on an offline version whose language is disabled on its site answers 422 language_disabled. Publishing an already online version is idempotent (200, unchanged) even when its language is disabled; a version without language publishes as before. unpublish and PATCH stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
Creating a translation
Two entry points share one implementation: the same result, errors, headers and stored data.
POST /v1/article/{id}/translations
Verified session or API key with content.create; the version {id} must be readable by the caller.
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
| Field | Rule |
|---|---|
lang |
required; normalized (FR is stored as fr) |
title |
required, 3 to 200 characters |
copy |
required boolean (400 when absent) |
source_article_id |
optional, only with copy: true (400 with copy: false); default is {id}; must be a readable version of the same story |
The new version joins the story of {id}, in the same site, as an offline draft created by the caller, with revision 1. The slug is generated from the new title in (site, language) and gets the next free -N suffix when taken.
copy: truecopiesbody,excerpt,seo_title,seo_description,social_title,social_description,cover_image_id,cover_image_alt,social_image_idand the authors from the source, and neverslug,canonical_url(empty),noindex(false) orseo_dismissed(empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows"source": {"article_id": <source>, "revision": <content revision then>}.copy: falsestarts empty: empty text, no images,authorsis the creating user's byline,"source": null.
Answers 201 with the created article object. Order of checks: permission, path id, body, version readable, copy rules, language tag, setup_required, source, language configuration. A story deleted in the meantime answers 404 Article not found. Both entry points leave the stored lang normalized.
POST /v1/article/ with story_id
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
story_id selects the story (404 Article not found when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of {id}. lang is then required (400). source_article_id and copy are only valid with story_id (400 otherwise).
POST /v1/article/ without story_id
Unchanged: it creates a new story with one offline version. lang is now optional: absent, the version gets the starting language of the site as before; present, it is normalized and must be an enabled language of the default site (422 invalid_language, unsupported_language, language_not_enabled; 409 language_unavailable when disabled; 409 setup_required before the site default is confirmed).
Deleting
DELETE /v1/article/{id} removes one version (204, empty body) and checks content.delete per version (403 missing_permission). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
Moving a story: POST /v1/story/{story_id}/move
{"site": "atelier"}
Verified session or API key. The caller needs content.update on every version of the story, readable or not, and must read at least one (404 Article not found otherwise, also for an unknown story). site is the key of the destination (400 when absent or empty, 404 site_not_found when unknown). Answers 200:
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
article_ids lists the readable versions only, oldest first. Every version moves in one transaction: site_id changes, slugs, languages, publication, content and authors are unchanged, revision of each moved version rises by 1 and its content revision does not. Moving to the current site answers 200 and changes nothing.
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (422 language_not_enabled, X-Missing-Languages limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (409 slug_conflict, X-Conflicting-Articles limited to readable versions). A conflict that appears between the check and the write answers 409 slug_conflict without the header.
Mock-ready examples
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
| Call | Status | Headers | Body |
|---|---|---|---|
GET /v1/article/10 (see above) |
200 | article object with revision, capabilities, translations, source |
|
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} |
201 | article object, lang "fr", "is_online": false, "revision": 1, "source": {"article_id": 10, "revision": 4} |
|
DELETE /v1/article/12 |
204 | empty | |
POST /v1/story/5/move {"site":"atelier"} |
200 | {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]} |
|
POST /v1/article/10/translations {"lang":"fr","title":"Couts"} |
400 | The request cannot be processed due to a mismatch in the format of the body. |
|
PATCH /v1/article/10 {"title":"ab"} |
400 | Article title must be between 3 and 200 characters. |
|
POST /v1/article/10/translations with a cookie of an anonymous client |
401 | You must be logged in to access this resource. |
|
POST /v1/story/5/move by a user holding content.update at own |
403 | X-Missing-Permission: content.update |
missing_permission |
POST /v1/article/9999/translations |
404 | Article not found |
|
POST /v1/article/10/translations with "source_article_id":9999 |
404 | source_unavailable |
|
POST /v1/story/5/move {"site":"nowhere"} |
404 | site_not_found |
|
PATCH /v1/article/10 {"body":"x","if_revision":3} after somebody saved |
409 | X-Current-Revision: 4 |
revision_conflict |
POST /v1/article/10/translations {"lang":"de","title":"Kosten","copy":true} |
409 | X-Existing-Article-Id: 11 |
language_exists |
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} with fr disabled, unreadable or raced |
409 | none | language_unavailable |
POST /v1/article/30/translations on an article without language |
409 | setup_required |
|
PATCH /v1/article/10 {"slug":"taken-en"} |
409 | X-Slug-Suggestion: taken-en-2 |
slug_taken |
POST /v1/story/5/move {"site":"atelier"} with an address taken at the destination |
409 | X-Conflicting-Articles: 12 |
slug_conflict |
PATCH /v1/article/10 {"site_id":2} on a story with several readable versions |
409 | story_move_required |
|
POST /v1/article/10/translations {"lang":"pt","title":"Custos","copy":true} with no pt row |
422 | language_not_enabled |
|
POST /v1/story/5/move {"site":"atelier"} with no ar at the destination |
422 | X-Missing-Languages: ar |
language_not_enabled |
PATCH /v1/article/15/publish with es disabled |
422 | language_disabled |
|
POST /v1/article/10/translations {"lang":"xx","title":"Couts","copy":true} |
422 | unsupported_language |
|
POST /v1/article/10/translations {"lang":"english","title":"Couts","copy":true} |
422 | invalid_language |
|
POST /v1/article/10/translations with "source_article_id":20 of another story |
422 | source_other_story |
Activity
No new action code. The existing events carry params.lang (the language of the version, omitted for a version without language): article.create, article.edit, article.publish, article.unpublish, article.delete. A translation creation records article.create on the new version with params.lang, params.story_id and params.source_article_id (only when copied). A story move records one article.edit per moved version, with a site change (site names before and after) and params.lang; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered 404 are not recorded.