docs: add approved public content API contract
This commit is contained in:
110
docs/content-api-schema-draft.md
Normal file
110
docs/content-api-schema-draft.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Public editorial content API — proposed v1 schema
|
||||
|
||||
Status: **response contract approved through Bundle 0 review on 2026-09-26**. This document specifies the response contract; it does not add endpoints. The architecture and permissions rationale live in `../../guilan-ace-deployment/tasks/cms-api-contract-draft.md`.
|
||||
|
||||
## Common types
|
||||
|
||||
- `revision`: positive integer, monotonic per site or page.
|
||||
- `published_at`: ISO 8601 UTC timestamp.
|
||||
- `Media`: `{ "url": "https://…", "alt": "…", "width": 320, "height": 320 }` or `null`. URL is served from backed-up media; informative images require nonempty alt.
|
||||
- `RouteId`: one of `home`, `about`, `events`, `blog`. Backend returns IDs; frontend maps IDs to application paths. No editor-supplied route href.
|
||||
- `Seo`: `{ "title": string, "description": string, "og_image": Media|null, "canonical_path": string }`; canonical path comes from the fixed slug map.
|
||||
- Text fields are plain text. Clients escape them; no HTML payload is accepted.
|
||||
|
||||
## `GET /api/v1/content/site`
|
||||
|
||||
`200` only for a published site identity; otherwise `404`.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": 2,
|
||||
"published_at": "2026-09-24T10:00:00Z",
|
||||
"official_name": "انجمن علمی مهندسی کامپیوتر دانشگاه گیلان (دانشکدهی فنی و مهندسی شرق گیلان)",
|
||||
"short_name": "انجمن علمی مهندسی کامپیوتر",
|
||||
"faculty_subline": "دانشکدهی فنی و مهندسی شرق گیلان",
|
||||
"description": "…",
|
||||
"logo": { "url": "https://example.invalid/media/logo.ico", "alt": "نشان انجمن علمی مهندسی کامپیوتر", "width": 64, "height": 64 },
|
||||
"navigation": [
|
||||
{ "route_id": "home", "label": "خانه", "position": 0, "visible": true },
|
||||
{ "route_id": "about", "label": "درباره انجمن", "position": 1, "visible": true },
|
||||
{ "route_id": "events", "label": "رویدادها", "position": 2, "visible": true },
|
||||
{ "route_id": "blog", "label": "بلاگ", "position": 3, "visible": true }
|
||||
],
|
||||
"contacts": [
|
||||
{ "kind": "email", "label": "ایمیل", "url": "mailto:eastguilanceassociation@gmail.com", "position": 0, "visible": true },
|
||||
{ "kind": "telegram", "label": "تلگرام", "url": "https://t.me/guilancea", "position": 1, "visible": true },
|
||||
{ "kind": "instagram", "label": "اینستاگرام", "url": "https://instagram.com/guilance.ir", "position": 2, "visible": true }
|
||||
],
|
||||
"footer_copy": "انجمن علمی مهندسی کامپیوتر دانشگاه گیلان",
|
||||
"seo_defaults": { "title": "…", "description": "…", "og_image": null, "canonical_path": "/" }
|
||||
}
|
||||
```
|
||||
|
||||
The example media URL is illustrative and must be replaced with a real managed-media URL after asset import. A missing logo is `null`; the short name remains visible. Contact `kind` is a closed enum (`email`, `phone`, `telegram`, `instagram`, `website`); URL schemes are validated by kind.
|
||||
|
||||
## `GET /api/v1/content/pages/{slug}`
|
||||
|
||||
Allowed slugs: `home`, `about`, `events`, `blog`. `200` returns only the published revision. Unknown and unpublished-only slugs both return `404`. `events` and `blog` have `sections: []`.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "home",
|
||||
"revision": 3,
|
||||
"published_at": "2026-09-24T10:00:00Z",
|
||||
"title": "انجمن علمی مهندسی کامپیوتر",
|
||||
"intro": "…",
|
||||
"seo": { "title": "…", "description": "…", "og_image": null, "canonical_path": "/" },
|
||||
"sections": [
|
||||
{
|
||||
"key": "hero", "type": "hero", "position": 0,
|
||||
"heading": "انجمن علمی مهندسی کامپیوتر",
|
||||
"body": "…", "image": null,
|
||||
"primary_action": { "label": "آشنایی با انجمن", "route_id": "about" },
|
||||
"secondary_action": { "label": "دیدن رویدادها", "route_id": "events" }
|
||||
},
|
||||
{
|
||||
"key": "featured_events", "type": "featured_events", "position": 1,
|
||||
"heading": "رویدادهای برگزیده",
|
||||
"intro": "نگاهی به برنامههای انجمن",
|
||||
"all_link_label": "همه رویدادها"
|
||||
},
|
||||
{
|
||||
"key": "featured_posts", "type": "featured_posts", "position": 2,
|
||||
"heading": "از بلاگ انجمن",
|
||||
"intro": "نوشتهها و تجربههایی برای یادگیری",
|
||||
"all_link_label": "همه نوشتهها"
|
||||
},
|
||||
{
|
||||
"key": "participation", "type": "participation", "position": 3,
|
||||
"heading": "در جریان برنامههای انجمن باشید",
|
||||
"body": "رویدادها و نوشتههای تازه را دنبال کنید.",
|
||||
"primary_action": { "label": "دیدن رویدادها", "route_id": "events" },
|
||||
"secondary_action": { "label": "ارتباط با انجمن", "route_id": "about" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`sections` contains only visible sections, sorted by position. Required Home keys are `hero`, `featured_events`, `featured_posts`, then `participation`; required About keys are `identity` first and `contact` last. Optional About keys are `mission`, `activities`, `values`, `committees`, `affiliations`, and `faq`. Each optional section has a bounded heading/body or typed entry array as specified in the architecture note. The Home featured sections contain headings and link labels only. Event and post cards are fetched from their existing domain APIs, so no stale title, poster, availability, or price is copied into the CMS response. Required routes remain available through navigation. The response contains editorial content, not layout instructions.
|
||||
|
||||
An About page uses the same envelope. Its required sections have these shapes:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "key": "identity", "type": "identity", "position": 0, "heading": "درباره انجمن", "body": "…" },
|
||||
{ "key": "contact", "type": "contact", "position": 2, "heading": "ارتباط با انجمن" }
|
||||
]
|
||||
```
|
||||
|
||||
The frontend resolves official name, mark, faculty subline, and contacts from the published `site` response. It must not duplicate those values in page section payloads. For editor preview, the site draft and page draft may be selected independently; the preview UI labels both revision numbers so a mixed preview cannot be mistaken for one published snapshot.
|
||||
|
||||
## Draft preview
|
||||
|
||||
- `GET /api/v1/content/preview/site?revision=<id>` returns the site shape for the exact authorized draft.
|
||||
- `GET /api/v1/content/preview/pages/{slug}?revision=<id>` returns the page shape for the exact authorized draft; for editor review it may include `visible: false` sections and entries so hidden content is inspectable, but the preview UI must distinguish them and never render them as public content.
|
||||
- Unauthenticated requests receive `401` with no revision details; authenticated users without permission receive `403`; missing or wrong-target draft receives `404`. Preview sets `Cache-Control: private, no-store`, `Vary: Cookie, Authorization`, and `X-Robots-Tag: noindex, nofollow`.
|
||||
- Public reads use the published revision only, with `ETag` derived from revision and response checksum. Proposed shared cache bound: `Cache-Control: public, s-maxage=60, must-revalidate`; the frontend CMS adapter must avoid a second persistent cache that extends this bound.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
The frontend must reject an unknown section `type` safely, log the mismatch, and render the rest of the page. The backend rejects unknown section keys/types at draft validation. Changing a field name or meaning requires a v2 contract; adding a new optional section type requires coordinated backend/frontend rollout. No public response exposes draft metadata, editor identity, or unpublished assets.
|
||||
Reference in New Issue
Block a user