ghost-io-api 0.2.0

Strongly-typed, async Rust client for the Ghost CMS Content and Admin APIs
Documentation
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] — 2026-06-21

### Added

#### Admin API authentication (`src/auth/admin.rs`) — #17

- `AdminApiKey` — type-safe wrapper for Ghost Admin API key pairs (`{id}:{hex-secret}`)
  - Parses and validates the colon-separated `kid:hex-secret` format at construction time
  - `generate_jwt()` — produces a short-lived (5-minute) HS256 JWT signed with the hex
    secret; freshly minted on every request so tokens never expire mid-flight
  - `sign_jwt(iat: u64)` — deterministic variant for testing with a fixed timestamp
  - JWT header: `{"alg":"HS256","kid":"<id>","typ":"JWT"}` (alphabetical field order)
  - JWT payload: `{"exp":<iat+300>,"iat":<iat>}`
  - Pure RustCrypto stack: `hmac 0.12` + `sha2 0.10` + `base64 0.22` (no OpenSSL)
  - Masked `Display` impl — `AdminApiKey(kid:***)` keeps secrets out of logs
  - `key_id()`, `is_valid()`, `AsRef<str>` accessors

#### Admin API client (`src/client/admin.rs`) — #18, #21

- `GhostAdminClient` — write-capable async HTTP client for the Ghost Admin API
  - `new(base_url, api_key)` — strips trailing slashes; sets `Accept-Version: v5.0`
  - Injects `Authorization: Ghost <jwt>` on every request with a freshly generated token
  - Parses Ghost's `{"errors":[{"message","type","context"}]}` error envelope into
    `GhostError::Api`
- **Posts CRUD** (6 public methods):
  - `browse_posts(AdminBrowsePostsParams)``AdminPostsResponse` — paginated list;
    returns all statuses (draft, scheduled, published) unlike the Content API
  - `read_post_by_id(id, include?)``Post`
  - `read_post_by_slug(slug, include?)``Post`
  - `create_post(PostCreate)``Post`
  - `update_post(id, PostUpdate)``Post`
  - `delete_post(id)``()`
- `AdminBrowsePostsParams` — query parameter struct: `page`, `limit`, `filter` (NQL),
  `order`, `include`, `fields`; all optional, omitted fields use Ghost's defaults
- `AdminPostsResponse` — public browse response carrying `posts: Vec<Post>` + `meta: Meta`

#### Post write models (`src/models/post.rs`) — #19, #20

- `PostCreate` — input model for `POST /ghost/api/admin/posts/`
  - `title` is the only required field; all others are `Option<_>` and skipped when `None`
  - Fields: content (`lexical`, `html`, `custom_excerpt`), identifiers (`slug`),
    status & visibility, scheduling (`published_at`), media (`feature_image`, alt, caption),
    relationships (`tags: Vec<TagRef>`, `authors: Vec<AuthorRef>`, `newsletter`),
    SEO, Open Graph, Twitter Cards, code injection, custom template
  - `PostCreate::new(title)` constructor
  - `PostCreateEnvelope::new(post)` — wraps in `{"posts":[…]}` as the API requires
- `PostUpdate` — input model for `PUT /ghost/api/admin/posts/{id}/`
  - `updated_at: String` is **required** — Ghost's optimistic-concurrency token; the
    server rejects stale values with `ConflictError` to prevent silent overwrites
  - All other fields are `Option<_>`, identical set to `PostCreate` minus `title`
  - `PostUpdate::new(updated_at)` constructor
  - `PostUpdateEnvelope::new(post)` — wraps in `{"posts":[…]}`
- `TagRef` — lightweight tag reference for create/update: `by_slug()` or `by_name()`
- `AuthorRef` — author reference: `by_id()` or `by_email()`

#### Generic response envelopes (`src/models/envelope.rs`) — #22

- `BrowseEnvelope<T>` — generic browse response envelope
  - Deserializes `{"<resource>": [...], "meta": {...}}` into `items: Vec<T>` + `meta: Meta`
  - Custom `Deserialize` impl locates the resource array by scanning for the first
    array-valued field (skipping `"meta"`); works for any resource key
- `SingleEnvelope<T>` — generic single-item response envelope
  - Deserializes `{"<resource>": [item]}` into `item: T`
  - Same key-detection strategy; tolerates an optional `"meta"` field

#### Examples (`examples/`) — #23

- `publish_post` — full Admin API authoring workflow:
  1. Create a timestamped draft post with a custom excerpt and tag
  2. Publish it by calling `update_post` with `status: PostStatus::Published` and the
     `updated_at` token from the create response
  3. Delete the post (cleanup so the example is safe to run repeatedly)
  - Configurable via `GHOST_URL` and `GHOST_ADMIN_KEY` environment variables (required)

#### Documentation — #82

- `RELEASE_NOTES.md` — replaced with v0.2.0 release notes
- `docs/release_notes/v0.1.0.md` — archived v0.1.0 release notes
- `README.md` — added Admin API section: key format, client construction, all six CRUD
  methods, optimistic concurrency explanation, `publish_post` example invocation, and
  updated capability matrix
- `src/lib.rs` — updated crate-level docs: Admin API quick-start example, module
  description for `auth::admin` and `client::admin`

### Dependencies

- `hmac = "0.12"` — HMAC-SHA256 for JWT signing
- `sha2 = "0.10"` — SHA-256 digest
- `base64 = "0.22"` — base64url encoding for JWT segments
- `wiremock = "0.6"` (dev) — HTTP mock server for client unit tests

---

## [0.1.0] — 2026-06-06

Initial release. Full read-only access to the Ghost Content API.

### Added

#### Authentication (`src/auth/`)

- `ContentApiKey` — type-safe wrapper for 26-character hex Content API keys
  - Validation at construction time (length, hex-only characters)
  - Auto-normalises input: trims whitespace, lowercases uppercase hex
  - `as_str()`, `as_query_param()`, `is_valid()` accessors
  - Masked `Display` impl (shows first 8 and last 4 characters)

#### Error types (`src/error.rs`)

- `GhostError` enum backed by `thiserror`:
  - `Api { message, error_type, context }` — structured Ghost API errors
  - `Http(reqwest::Error)` — network and transport errors
  - `Json(serde_json::Error)` — serialisation / deserialisation errors
  - `Auth(String)` — authentication failures
- `Result<T>` type alias
- Constructor helpers: `GhostError::api()`, `GhostError::auth()`
- Predicate methods: `is_api_error()`, `is_http_error()`, `is_json_error()`, `is_auth_error()`

#### Models (`src/models/`)

- **`Post`** — full Ghost post resource with all API fields
  - `PostStatus` enum: `Draft`, `Published`, `Scheduled`, `Sent`
  - Helper methods: `is_published()`, `is_draft()`, `is_scheduled()`, `is_sent()`, `is_featured()`, `is_email_only()`
  - All optional fields use `#[serde(skip_serializing_if = "Option::is_none")]`
- **`Page`** — static page resource (mirrors Post schema)
  - `PageStatus` enum: `Draft`, `Published`, `Scheduled`
  - Helper methods: `is_published()`, `is_draft()`, `is_scheduled()`
- **`Tag`** — tag resource with SEO and code injection fields
  - `PostCount` struct (populated via `include=count.posts`)
  - `is_public()` helper
- **`Author`** — author resource with social and bio fields
  - Re-uses `PostCount` from the tag module
  - `has_profile_image()`, `post_count()` helpers
- **`Settings`** — read-only site settings (title, logo, nav, social, SEO)
  - `NavItem` struct for primary and secondary navigation
  - `has_logo()`, `nav_count()` helpers
- **`Pagination`** / **`Meta`** — browse endpoint pagination envelope
  - Helpers: `has_next()`, `has_prev()`, `is_first_page()`, `is_last_page()`, `items_on_page()`, `start_index()`, `end_index()`

#### Content API client (`src/client/content.rs`)

- `GhostContentClient` — async HTTP client using `reqwest`
  - Sets `Accept-Version: v5.0` on every request
  - Strips trailing slashes from the base URL
  - Parses Ghost's JSON error envelope (`{ "errors": [...] }`) into `GhostError::Api`
- **Posts**: `browse_posts()`, `read_post_by_id()`, `read_post_by_slug()`
- **Pages**: `browse_pages()`, `read_page_by_id()`, `read_page_by_slug()`
- **Tags**: `browse_tags()`, `read_tag_by_id()`, `read_tag_by_slug()`
- **Authors**: `browse_authors()`, `read_author_by_id()`, `read_author_by_slug()`
- **Tiers**: `browse_tiers()` with inline `Tier` model (free / paid membership tiers)
- **Settings**: `get_settings()`
- `BrowsePostsParams` struct (and type aliases for each resource) for optional
  filtering, sorting, pagination, field selection, and relation inclusion

#### Query parameter builder (`src/params/browse.rs`)

- `BrowseParams` — fluent builder for Ghost browse query parameters
  - Chainable setters: `limit()`, `page()`, `filter()`, `order()`, `include()`, `fields()`, `formats()`
  - Getter accessors for each field
  - `to_query_pairs()``Vec<(&'static str, String)>` for `reqwest`
  - `to_query_string()` with RFC 3986 percent-encoding

#### Examples (`examples/`)

- `list_posts` — pages through published posts, printing title, slug, and date
  - Configurable via `GHOST_URL` and `GHOST_CONTENT_KEY` environment variables
  - Falls back to `demo.ghost.io` when env vars are absent

#### CI / CD (`.github/workflows/`)

- `docs.yml` — builds and deploys API documentation to GitHub Pages on every
  push to `main`; strict lint flags (`-D warnings`, `-D missing_docs`,
  `-D rustdoc::redundant_explicit_links`)

### Fixed

- `Post::status` field: added `#[serde(default)]` so posts where the Ghost API
  omits the `status` key deserialise correctly (defaulting to `PostStatus::Draft`)
  instead of returning a JSON decoding error.

### Documentation

- `lib.rs`: replaced placeholder crate-level docs with accurate module
  cross-links and a working `no_run` quick-start example
- `README.md`: rewrote with real usage examples, feature table, running-tests
  instructions, and correct badge set

### Dependencies

- `reqwest 0.12` with `json` feature
- `serde 1` with `derive` feature
- `serde_json 1`
- `thiserror 1.0`
- `tokio 1` (dev/test only, with `rt-multi-thread` and `macros`)

[Unreleased]: https://github.com/arunkumar-mourougappane/ghost-io-api/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/arunkumar-mourougappane/ghost-io-api/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/arunkumar-mourougappane/ghost-io-api/releases/tag/v0.1.0