1332 lines
52 KiB
Markdown
1332 lines
52 KiB
Markdown
# Grav API Plugin
|
|
|
|
A RESTful API for [Grav CMS](https://getgrav.org) that provides full headless access to your site's content, media, configuration, users, and system management.
|
|
|
|
Built for the AI-native era — designed to work seamlessly with AI agents, MCP servers, CLI tools, mobile apps, and custom frontends.
|
|
|
|
## Requirements
|
|
|
|
- Grav CMS 2.0+
|
|
- PHP 8.3+
|
|
- Login Plugin 3.8+
|
|
|
|
## Installation
|
|
|
|
### GPM (preferred)
|
|
|
|
```bash
|
|
bin/grav install api
|
|
```
|
|
|
|
### Manual
|
|
|
|
1. Download or clone this repository into `user/plugins/api`
|
|
2. Run `composer install` in the plugin directory
|
|
3. Enable the plugin in Admin or via `user/config/plugins/api.yaml`
|
|
|
|
## Quick Start
|
|
|
|
### 1. Enable the Plugin
|
|
|
|
```yaml
|
|
# user/config/plugins/api.yaml
|
|
enabled: true
|
|
```
|
|
|
|
### 2. Generate an API Key
|
|
|
|
**Via CLI** (recommended for initial setup):
|
|
|
|
```bash
|
|
bin/plugin api keys:generate --user=admin --name="My First Key"
|
|
```
|
|
|
|
**Via Admin Panel**: Go to a user's profile — the **API Keys** section lets you generate, view, and revoke keys with optional expiry dates.
|
|
|
|
The generated key is shown **once** — save it immediately.
|
|
|
|
### 3. Make Your First Request
|
|
|
|
```bash
|
|
curl https://yoursite.com/api/v1/pages \
|
|
-H "X-API-Key: grav_abc123..."
|
|
```
|
|
|
|
## Environments
|
|
|
|
Grav supports multiple environments (e.g., `localhost`, `staging.mysite.com`, `mysite.com`) with per-environment config overrides stored in `user/env/{environment}/config/`. The API respects this system via the optional `X-Grav-Environment` header.
|
|
|
|
```bash
|
|
# Explicitly target an environment
|
|
curl -H "X-Grav-Environment: mysite.com" -H "X-API-Key: ..." https://yoursite.com/api/v1/pages
|
|
```
|
|
|
|
If the header is omitted, the API defaults to Grav's auto-detected environment (derived from the hostname). When the header specifies a different environment, Grav reinitializes its config and cache context for that environment before processing the request.
|
|
|
|
**Discover available environments:**
|
|
|
|
```bash
|
|
curl -H "X-API-Key: ..." https://yoursite.com/api/v1/system/environments
|
|
```
|
|
|
|
Returns the current environment and all environment-specific overrides found in `user/env/`:
|
|
|
|
```json
|
|
{
|
|
"data": {
|
|
"current": "localhost",
|
|
"environments": [
|
|
{"name": "default", "active": true},
|
|
{"name": "mysite.com", "active": false}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Authentication
|
|
|
|
The API supports three authentication methods. All three provide the same level of access — the authenticated user's permissions apply regardless of which method is used. When a request is received, the API tries each method in order until one succeeds.
|
|
|
|
### API Key (recommended for servers, CLI, automation)
|
|
|
|
Long-lived credentials ideal for server-to-server integrations, CLI tools, MCP servers, and CI/CD pipelines. Keys don't expire by default (optional expiry can be set), and persist until explicitly revoked.
|
|
|
|
```bash
|
|
# Via header (recommended)
|
|
curl -H "X-API-Key: grav_abc123..." https://yoursite.com/api/v1/pages
|
|
|
|
# Via query parameter (useful for quick debugging — less secure, visible in logs)
|
|
curl https://yoursite.com/api/v1/pages?api_key=grav_abc123...
|
|
```
|
|
|
|
Keys are stored as bcrypt hashes in `user/data/api-keys.yaml`. Each key is associated with a user, can be named, given an optional expiry, and independently revoked. Generate keys via CLI (`bin/plugin api keys:generate`) or the admin panel.
|
|
|
|
### JWT Token (recommended for browser apps, mobile apps)
|
|
|
|
Short-lived credentials ideal for SPAs, mobile apps, and any client-side application where long-lived secrets shouldn't be stored. Access tokens expire after 1 hour (configurable), and refresh tokens allow obtaining new access tokens without re-entering credentials.
|
|
|
|
**Step 1 — Login** (exchange credentials for tokens):
|
|
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/auth/token \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username": "admin", "password": "your-password"}'
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{
|
|
"data": {
|
|
"access_token": "eyJ...",
|
|
"refresh_token": "eyJ...",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2 — Use the access token** for API calls:
|
|
|
|
```bash
|
|
# Recommended: X-API-Token custom header (survives FPM/FastCGI header stripping)
|
|
curl -H "X-API-Token: eyJ..." https://yoursite.com/api/v1/pages
|
|
|
|
# Also accepted: standard Authorization: Bearer
|
|
# (works on most hosts; may be stripped by Apache mod_fastcgi / CGI on MAMP
|
|
# and similar setups — if that happens, use X-API-Token instead)
|
|
curl -H "Authorization: Bearer eyJ..." https://yoursite.com/api/v1/pages
|
|
```
|
|
|
|
> **Why `X-API-Token`?** PHP running under FastCGI / CGI / PHP-FPM can silently strip the `Authorization` header before it reaches the application. A custom `X-*` header bypasses this entirely. The server accepts either; pick the one that works for your host.
|
|
|
|
**Step 3 — Refresh** before the access token expires (the old refresh token is automatically revoked — token rotation):
|
|
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/auth/refresh \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"refresh_token": "eyJ..."}'
|
|
```
|
|
|
|
**Step 4 — Revoke** when the user logs out (returns 204 No Content):
|
|
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/auth/revoke \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"refresh_token": "eyJ..."}'
|
|
```
|
|
|
|
### Session Passthrough (for admin panel integration)
|
|
|
|
If a user has an active Grav admin session, the API recognizes it automatically. This enables the current admin UI (or a future SPA admin) to call the API from the browser without separate authentication — no API key or JWT needed.
|
|
|
|
### Which method should I use?
|
|
|
|
| Use Case | Method | Why |
|
|
|----------|--------|-----|
|
|
| CLI tools, scripts | API Key | Simple, long-lived, no token management |
|
|
| MCP servers, AI agents | API Key | Persistent, no expiry concerns |
|
|
| Server-to-server | API Key | Static credential, easy to rotate |
|
|
| Mobile app | JWT | Short-lived, secure for client-side storage |
|
|
| SPA / browser frontend | JWT | Tokens in memory, refresh flow handles expiry |
|
|
| Admin panel extensions | Session | Seamless — already logged in |
|
|
|
|
## API Endpoints
|
|
|
|
All endpoints are prefixed with `/api/v1`. All responses use a standard JSON envelope.
|
|
|
|
### Pages
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/pages` | List pages (filterable, sortable, paginated) |
|
|
| `GET` | `/pages/{route}` | Get a single page |
|
|
| `POST` | `/pages` | Create a new page |
|
|
| `PATCH` | `/pages/{route}` | Update a page (partial) |
|
|
| `DELETE` | `/pages/{route}` | Delete a page |
|
|
| `POST` | `/pages/{route}/move` | Move a page |
|
|
| `POST` | `/pages/{route}/copy` | Copy a page |
|
|
| `POST` | `/pages/{route}/reorder` | Reorder child pages |
|
|
| `POST` | `/pages/batch` | Batch operations on multiple pages |
|
|
| `GET` | `/taxonomy` | List all taxonomy types and values |
|
|
|
|
**Filtering pages:**
|
|
```
|
|
GET /api/v1/pages?published=true&template=post&parent=blog
|
|
```
|
|
|
|
**Lazy-loading children** (for tree/miller column views):
|
|
```bash
|
|
# Direct children only (one level deep) — ideal for lazy-loading
|
|
GET /api/v1/pages?children_of=/blog
|
|
|
|
# Top-level pages only
|
|
GET /api/v1/pages?children_of=/
|
|
|
|
# Alternative: root-level pages
|
|
GET /api/v1/pages?root=true
|
|
```
|
|
|
|
Unlike `parent` (which returns all descendants), `children_of` returns only **direct children** — pages exactly one level below the given route. Combined with the `has_children` field in page responses, this enables efficient lazy-loading page trees and miller column interfaces.
|
|
|
|
**Sorting:**
|
|
```
|
|
GET /api/v1/pages?sort=date&order=desc
|
|
```
|
|
|
|
Allowed sort fields: `date`, `title`, `slug`, `modified`, `order`
|
|
|
|
**Pagination:**
|
|
```
|
|
GET /api/v1/pages?page=2&per_page=10
|
|
```
|
|
|
|
**Getting rendered HTML:**
|
|
```
|
|
GET /api/v1/pages/blog/my-post?render=true
|
|
```
|
|
|
|
**Including children:**
|
|
```
|
|
GET /api/v1/pages/blog?children=true&children_depth=2
|
|
```
|
|
|
|
**Reordering children:**
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/pages/blog/reorder \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{"order": ["third-post", "first-post", "second-post"]}'
|
|
```
|
|
|
|
**Batch operations** (publish, unpublish, delete, copy — up to 50 items):
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/pages/batch \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{"operation": "publish", "routes": ["/blog/draft-1", "/blog/draft-2"]}'
|
|
```
|
|
|
|
### Multi-Language
|
|
|
|
All page endpoints support the `?lang=xx` query parameter to target a specific language. Grav stores translations as separate files (e.g., `default.en.md`, `default.fr.md`).
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/languages` | List configured site languages |
|
|
| `GET` | `/pages/{route}/languages` | List available/missing translations for a page |
|
|
| `POST` | `/pages/{route}/translate` | Create a new translation |
|
|
|
|
```bash
|
|
# Get a page in French
|
|
GET /api/v1/pages/about?lang=fr
|
|
|
|
# List pages in German
|
|
GET /api/v1/pages?lang=de
|
|
|
|
# Create a French translation
|
|
curl -X POST https://yoursite.com/api/v1/pages/about/translate \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{"lang": "fr", "title": "À propos", "content": "# Bienvenue"}'
|
|
|
|
# Delete only the French translation (keeps other languages)
|
|
DELETE /api/v1/pages/about?lang=fr
|
|
|
|
# Include translation info in page response
|
|
GET /api/v1/pages/about?translations=true
|
|
```
|
|
|
|
### Media
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/pages/{route}/media` | List media for a page |
|
|
| `POST` | `/pages/{route}/media` | Upload media to a page |
|
|
| `DELETE` | `/pages/{route}/media/{filename}` | Delete page media |
|
|
| `GET` | `/media` | List site-level media |
|
|
| `POST` | `/media` | Upload site-level media |
|
|
| `DELETE` | `/media/{filename}` | Delete site-level media |
|
|
|
|
**Uploading:**
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/pages/blog/my-post/media \
|
|
-H "X-API-Key: grav_abc123..." \
|
|
-F "file=@photo.jpg"
|
|
```
|
|
|
|
### Configuration
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/config/{scope}` | Read configuration |
|
|
| `PATCH` | `/config/{scope}` | Update configuration |
|
|
| `POST` | `/config/{scope}/revert` | Revert overridden keys, or reset the whole scope |
|
|
|
|
Scopes: `system`, `site`, `plugins/{name}`, `themes/{name}`, plus any site-authored **custom scope** (see [Custom config scopes](#custom-config-scopes)).
|
|
|
|
```bash
|
|
# Read site config
|
|
curl -H "X-API-Key: ..." https://yoursite.com/api/v1/config/site
|
|
|
|
# Update a plugin config
|
|
curl -X PATCH https://yoursite.com/api/v1/config/plugins/markdown \
|
|
-H "X-API-Key: ..." \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"extra": true}'
|
|
```
|
|
|
|
**Differential saves.** Config writes persist only the delta against the relevant parent yaml — `system / site / media / security / scheduler / backups` diff against `system/config/<scope>.yaml` (Grav core defaults), `plugins/<name>` diffs against `user/plugins/<name>/<name>.yaml`, and `themes/<name>` diffs against `user/themes/<name>/<name>.yaml`. Defaults come from the raw yaml on disk (not from blueprints, which describe the form and routinely diverge from runtime). Sequential arrays like `languages.supported` are treated atomically — any difference retains the whole new list, avoiding the classic admin-classic bug where shortening a list silently re-merged removed entries.
|
|
|
|
**Targeting an environment for writes.** The optional `X-Config-Environment` header points writes at an existing env folder under `user/env/<name>/config/`; an empty/missing value writes to base `user/config/`. Env folders are **never** created implicitly — clients must opt in via `POST /system/environments`. A non-empty header that doesn't match an existing folder returns a clear `400`.
|
|
|
|
```bash
|
|
# Write only to the staging environment overrides
|
|
curl -X PATCH https://yoursite.com/api/v1/config/system \
|
|
-H "X-API-Key: ..." \
|
|
-H "X-Config-Environment: staging.example.com" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"languages": {"default_lang": "fr"}}'
|
|
```
|
|
|
|
> `X-Config-Environment` is a **write-target** header (which env folder receives the change). It is distinct from `X-Grav-Environment` (which env to *load* for the request).
|
|
|
|
**Override metadata.** Every `GET`/`PATCH` config response carries a `meta` block describing which leaf keys the active layer's file actually overrides, and the value each would revert to:
|
|
|
|
```json
|
|
{
|
|
"data": { "debugger": { "enabled": true } },
|
|
"meta": {
|
|
"overrides": ["debugger.enabled"],
|
|
"fallback": { "debugger.enabled": false }
|
|
}
|
|
}
|
|
```
|
|
|
|
`overrides` is the set of dotted leaf paths the active file sets on top of the layer beneath it (the base `user/config` for an env overlay, or the raw on-disk defaults for the base layer). `fallback` maps each of those paths to the value it would return to. Admin2 uses this to draw the per-field revert indicators.
|
|
|
|
**Reverting.** `POST /config/{scope}/revert` removes overrides so the value beneath takes over, honoring the same `If-Match` ETag and `X-Config-Environment` write-target as `PATCH`:
|
|
|
|
```bash
|
|
# Drop specific overridden keys from the staging overlay
|
|
curl -X POST https://yoursite.com/api/v1/config/system/revert \
|
|
-H "X-API-Key: ..." \
|
|
-H "X-Config-Environment: staging.example.com" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"keys": ["debugger.enabled"]}'
|
|
|
|
# Reset the whole scope — unlink the active layer's file entirely
|
|
curl -X POST https://yoursite.com/api/v1/config/system/revert \
|
|
-H "X-API-Key: ..." \
|
|
-H "X-Config-Environment: staging.example.com" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"reset": true}'
|
|
```
|
|
|
|
A `{"keys": [...]}` payload drops just those paths; `{"reset": true}` removes the active layer's file outright. The response is the same shape as a read, reflecting the post-revert state.
|
|
|
|
#### Custom config scopes
|
|
|
|
Beyond the built-in scopes, a site can expose its own top-level config — the Grav cookbook ["add a custom yaml file"](https://learn.getgrav.org/cookbook/general-recipes#add-a-custom-yaml-file) recipe. Drop a blueprint at `user/blueprints/config/<scope>.yaml` (or under `environment://blueprints/config/`) paired with `user/config/<scope>.yaml`, and the generic config endpoints accept `<scope>` automatically — no plugin code required. Admin2 lists it as a config tab alongside System and Site.
|
|
|
|
A scope qualifies as custom when it is a flat slug (`^[a-z0-9][a-z0-9_-]*$` — no slashes or dots, which also blocks path traversal), is not one of the built-in scopes, and has its blueprint under the `user://` or `environment://` stream. The user/environment requirement is deliberate: core ships its own blueprints under `blueprints://config` (e.g. `streams.yaml`) that must never become writable through the generic config permission.
|
|
|
|
> **Gotcha — use bare field keys.** A custom config blueprint must name its fields with bare keys (`company_name`), exactly like the core blueprints (`site.yaml`, `system.yaml`). Do **not** prefix them with the scope name (`custom.company_name`): the form fields would render blank and saves would nest the data under a spurious `custom:` block instead of writing it flat. The scope is already the file; the field key is the path within it.
|
|
|
|
### Users
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/users` | List users (paginated) |
|
|
| `POST` | `/users` | Create a user |
|
|
| `GET` | `/users/{username}` | Get user details |
|
|
| `PATCH` | `/users/{username}` | Update a user |
|
|
| `DELETE` | `/users/{username}` | Delete a user |
|
|
| `POST` | `/users/{username}/avatar` | Upload user avatar |
|
|
| `DELETE` | `/users/{username}/avatar` | Remove user avatar |
|
|
| `POST` | `/users/{username}/2fa` | Generate 2FA secret + QR code |
|
|
| `GET` | `/users/{username}/api-keys` | List API keys |
|
|
| `POST` | `/users/{username}/api-keys` | Generate an API key |
|
|
| `DELETE` | `/users/{username}/api-keys/{keyId}` | Revoke an API key |
|
|
|
|
### System
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/ping` | Keep-alive / health check |
|
|
| `GET` | `/system/environments` | List available environments (current host + `user/env/*` + legacy 1.6 layouts) |
|
|
| `POST` | `/system/environments` | Create a new env folder under `user/env/<name>/config/` |
|
|
| `GET` | `/system/info` | System information |
|
|
| `DELETE` | `/cache` | Clear cache |
|
|
| `GET` | `/system/logs` | Read logs |
|
|
| `POST` | `/system/backup` | Create a backup |
|
|
| `GET` | `/system/backups` | List backups |
|
|
|
|
### GPM (Package Manager)
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/gpm/plugins` | List installed plugins (with update status) |
|
|
| `GET` | `/gpm/plugins/{slug}` | Get installed plugin details |
|
|
| `GET` | `/gpm/plugins/{slug}/readme` | Get plugin README |
|
|
| `GET` | `/gpm/plugins/{slug}/changelog` | Get plugin changelog |
|
|
| `GET` | `/gpm/themes` | List installed themes (with thumbnails/screenshots) |
|
|
| `GET` | `/gpm/themes/{slug}` | Get installed theme details |
|
|
| `GET` | `/gpm/themes/{slug}/readme` | Get theme README |
|
|
| `GET` | `/gpm/themes/{slug}/changelog` | Get theme changelog |
|
|
| `GET` | `/gpm/updates` | Check for available updates |
|
|
| `POST` | `/gpm/install` | Install a plugin or theme |
|
|
| `POST` | `/gpm/remove` | Remove a plugin or theme |
|
|
| `POST` | `/gpm/update` | Update a specific package |
|
|
| `POST` | `/gpm/update-all` | Update all packages |
|
|
| `POST` | `/gpm/upgrade` | Self-upgrade Grav core |
|
|
| `POST` | `/gpm/direct-install` | Install from URL or zip upload |
|
|
| `GET` | `/gpm/search` | Search repository (plugins + themes) |
|
|
| `GET` | `/gpm/repository/plugins` | Browse available plugins |
|
|
| `GET` | `/gpm/repository/themes` | Browse available themes |
|
|
| `GET` | `/gpm/repository/{slug}` | Get repository package details |
|
|
|
|
**Installing a package:**
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/gpm/install \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{"package": "shortcode-core", "type": "plugin"}'
|
|
```
|
|
|
|
**Installing a premium package** (pass the license inline, or pre-register it via the license-manager plugin):
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/gpm/install \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{"package": "typhoon", "type": "theme", "license": "A1B2C3D4-E5F6A7B8-C9D0E1F2-A3B4C5D6"}'
|
|
```
|
|
|
|
**Searching the repository:**
|
|
```bash
|
|
# Search across all plugins and themes
|
|
GET /api/v1/gpm/search?q=email
|
|
|
|
# Search plugins only
|
|
GET /api/v1/gpm/repository/plugins?q=form
|
|
|
|
# Search themes only
|
|
GET /api/v1/gpm/repository/themes?q=blog
|
|
```
|
|
|
|
Searches match against slug, name, description, author, and keywords. All repository endpoints support pagination (`?page=2&per_page=50`).
|
|
|
|
### Scheduler
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/scheduler/jobs` | List all scheduler jobs with status |
|
|
| `GET` | `/scheduler/status` | Get cron installation status |
|
|
| `GET` | `/scheduler/history` | Job execution history (paginated) |
|
|
| `POST` | `/scheduler/run` | Trigger scheduler run manually |
|
|
|
|
### System Info & Reports
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/systeminfo` | System info overview (PHP, disk, cache, plugins) |
|
|
| `GET` | `/reports` | Plugin-extensible diagnostic reports |
|
|
|
|
### Dashboard
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/dashboard/notifications` | Get system notifications (v2 schema — see below) |
|
|
| `POST` | `/dashboard/notifications/{id}/hide` | Dismiss a notification |
|
|
| `GET` | `/dashboard/feed` | Get getgrav.org news feed |
|
|
| `GET` | `/dashboard/stats` | Dashboard statistics snapshot |
|
|
| `GET` | `/dashboard/popularity` | Page-view popularity series for chart widgets |
|
|
| `GET` | `/dashboard/widgets` | Resolved widget list + layouts for the current user |
|
|
| `PATCH` | `/dashboard/layout` | Save the current user's dashboard layout |
|
|
| `PATCH` | `/dashboard/site-layout` | Save the site-wide default layout (super-admin) |
|
|
|
|
**Customizable dashboard.** `GET /dashboard/widgets` returns a merged widget list combining (1) a built-in core registry, (2) plugin contributions via the `onApiDashboardWidgets` event, (3) the site-default layout, and (4) the current user's overrides. Site-hidden widgets are dropped entirely from a user's view (cannot be re-enabled per-user); the user's overrides win for size/order on the rest. Each resolved widget carries its allowed `sizes[]`, `defaultSize`, icon, and authorize permission so the client can render the customize-mode picker without a second round-trip.
|
|
|
|
**Notifications schema (v2).** Each notification has structured fields — `type` (`info` | `notice` | `warning` | `promo`), `icon`, `title`, `message` (markdown), `link`, `image` + `accent` (for `promo` cards), `action: {label, url}`, and `dependencies` — so clients can render natively rather than receive embedded HTML. The endpoint fetches from `https://getgrav.org/notifications2.json` and caches per-user under `user/data/notifications/{md5}_v2.yaml`.
|
|
|
|
**Plugin-contributed widgets** — listen for `onApiDashboardWidgets` and append to `$event['widgets']`. Each entry can declare an `authorize` permission so the resolver hides widgets the user lacks access to.
|
|
|
|
### Webhooks
|
|
|
|
Webhooks send HTTP POST notifications to external URLs when content changes via the API.
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/webhooks` | List configured webhooks |
|
|
| `POST` | `/webhooks` | Create a webhook |
|
|
| `GET` | `/webhooks/{id}` | Get webhook details |
|
|
| `PATCH` | `/webhooks/{id}` | Update a webhook |
|
|
| `DELETE` | `/webhooks/{id}` | Delete a webhook |
|
|
| `GET` | `/webhooks/{id}/deliveries` | View delivery log (paginated) |
|
|
| `POST` | `/webhooks/{id}/test` | Send a test payload |
|
|
|
|
**Creating a webhook:**
|
|
```bash
|
|
curl -X POST https://yoursite.com/api/v1/webhooks \
|
|
-H "X-API-Key: ..." -H "Content-Type: application/json" \
|
|
-d '{
|
|
"url": "https://yourapp.com/webhook-receiver",
|
|
"events": ["page.created", "page.updated", "page.deleted"],
|
|
"enabled": true
|
|
}'
|
|
```
|
|
|
|
The response includes a `secret` for verifying payload signatures. Use `"events": ["*"]` to subscribe to all events.
|
|
|
|
**Available events:**
|
|
|
|
| Event | Trigger |
|
|
|-------|---------|
|
|
| `page.created` | Page created |
|
|
| `page.updated` | Page updated |
|
|
| `page.deleted` | Page deleted |
|
|
| `page.moved` | Page moved |
|
|
| `page.translated` | Translation created |
|
|
| `pages.reordered` | Children reordered |
|
|
| `media.uploaded` | Media uploaded |
|
|
| `media.deleted` | Media deleted |
|
|
| `user.created` | User created |
|
|
| `user.updated` | User updated |
|
|
| `user.deleted` | User deleted |
|
|
| `config.updated` | Config changed |
|
|
| `gpm.installed` | Package installed |
|
|
| `gpm.removed` | Package removed |
|
|
| `grav.upgraded` | Grav core upgraded |
|
|
|
|
**Payload format:**
|
|
```json
|
|
{
|
|
"event": "page.created",
|
|
"timestamp": "2026-03-26T20:00:00+00:00",
|
|
"webhook_id": "wh_abc123...",
|
|
"data": {
|
|
"page": {"route": "/blog/new-post", "title": "New Post", "slug": "new-post"},
|
|
"route": "/blog/new-post"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Security:** Each delivery includes an `X-Grav-Signature` header containing an HMAC-SHA256 hash of the payload body, signed with the webhook's `secret`. Verify it in your receiver:
|
|
|
|
```php
|
|
$signature = hash_hmac('sha256', $rawBody, $webhookSecret);
|
|
$valid = hash_equals($signature, $_SERVER['HTTP_X_GRAV_SIGNATURE']);
|
|
```
|
|
|
|
**Reliability:** Failed deliveries (5xx responses or timeouts) are retried up to 3 times with exponential backoff. After 5 consecutive failures, the webhook is automatically disabled. The failure count resets on any successful delivery.
|
|
|
|
**Note:** Webhooks fire only for changes made through the API. Changes via the admin panel or direct filesystem edits use different code paths and won't trigger webhooks.
|
|
|
|
### Authentication
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `POST` | `/auth/token` | Login (get JWT tokens) |
|
|
| `POST` | `/auth/refresh` | Refresh access token |
|
|
| `POST` | `/auth/revoke` | Revoke refresh token |
|
|
| `GET` | `/auth/setup` | First-run check (returns `needs_setup` plus the password policy) |
|
|
| `POST` | `/auth/setup` | Create the very first super-admin user (only when no users exist) |
|
|
| `GET` | `/auth/password-policy` | Structured representation of `system.pwd_regex` for client-side strength meters |
|
|
|
|
These endpoints do **not** require authentication.
|
|
|
|
**Password policy.** `GET /auth/password-policy` parses `system.pwd_regex` into `{ regex, min_length, rules[] }` by recognizing the common lookahead form (`(?=.*\d)`, `(?=.*[a-z])`, `(?=.*[A-Z])`, `(?=.*\W)`, `.{N,}`) and mapping each to a human-readable rule label. Admins can override the auto-detected rules with an optional `system.pwd_rules: [{id, label, pattern}, …]` list for custom or localized messaging without touching `pwd_regex`. The same policy is piggybacked on `GET /auth/setup` so the first-run setup screen renders its strength meter without a second round-trip; `POST /auth/setup` enforces it server-side regardless of what the UI shows.
|
|
|
|
### Translations
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/translations/{lang}` | Get all translation strings for a language |
|
|
| `GET` | `/thumbnails/{file}` | Serve a cached thumbnail image (public) |
|
|
|
|
Translation and thumbnail endpoints do **not** require authentication.
|
|
|
|
**Get all English translations:**
|
|
|
|
```bash
|
|
curl -s "https://yoursite.com/api/v1/translations/en"
|
|
```
|
|
|
|
**Filter by prefix** (for faster partial loads):
|
|
|
|
```bash
|
|
curl -s "https://yoursite.com/api/v1/translations/en?prefix=PLUGIN_ADMIN"
|
|
```
|
|
|
|
```json
|
|
{
|
|
"data": {
|
|
"lang": "en",
|
|
"count": 1248,
|
|
"checksum": "6101ee5fcfeabc085cea537e4583038f",
|
|
"strings": {
|
|
"PLUGIN_ADMIN.TITLE": "Title",
|
|
"PLUGIN_ADMIN.CONTENT": "Content",
|
|
"PLUGIN_ADMIN.OPTIONS": "Options",
|
|
"PLUGIN_ADMIN.PUBLISHING": "Publishing",
|
|
"..."
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The `checksum` can be used for cache invalidation — only re-fetch when the checksum changes. The `prefix` parameter enables a two-phase loading strategy: fetch a small subset for immediate use, then load the full set in the background.
|
|
|
|
### Blueprints
|
|
|
|
Blueprints provide form schema definitions used to render configuration and content editing interfaces. The API resolves blueprint inheritance (`extends@`, `import@`) and returns a normalized JSON structure suitable for client-side form rendering.
|
|
|
|
| Method | Endpoint | Description | Permission |
|
|
|--------|----------|-------------|------------|
|
|
| `GET` | `/blueprints/pages` | List available page templates | `api.pages.read` |
|
|
| `GET` | `/blueprints/pages/{template}` | Get resolved blueprint for a page template | `api.pages.read` |
|
|
| `GET` | `/blueprints/plugins/{plugin}` | Get blueprint for a plugin's configuration | `api.config.read` |
|
|
| `GET` | `/blueprints/themes/{theme}` | Get blueprint for a theme's configuration | `api.config.read` |
|
|
| `GET` | `/blueprints/users` | Get user account blueprint | `api.users.read` |
|
|
| `GET` | `/blueprints/users/permissions` | Get all registered permission actions | `api.users.read` |
|
|
| `GET` | `/blueprints/config/{scope}` | Get blueprint for system config (`system`, `site`, `media`) | `api.config.read` |
|
|
| `GET` | `/data/resolve` | Resolve blueprint data-options@ directives | `api.pages.read` |
|
|
| `POST` | `/blueprint-upload` | Upload a file targeted by a blueprint `destination` | (scope-derived) |
|
|
| `DELETE` | `/blueprint-upload` | Remove a previously-uploaded blueprint file (idempotent) | (scope-derived) |
|
|
|
|
**List page templates:**
|
|
|
|
```bash
|
|
curl -s "https://yoursite.com/api/v1/blueprints/pages" \
|
|
-H "X-API-Key: YOUR_KEY"
|
|
```
|
|
|
|
```json
|
|
{
|
|
"data": [
|
|
{ "type": "default", "label": "Default" },
|
|
{ "type": "blog", "label": "Blog" },
|
|
{ "type": "item", "label": "Item" }
|
|
]
|
|
}
|
|
```
|
|
|
|
**Get a page blueprint (resolved with inheritance):**
|
|
|
|
```bash
|
|
curl -s "https://yoursite.com/api/v1/blueprints/pages/blog" \
|
|
-H "X-API-Key: YOUR_KEY"
|
|
```
|
|
|
|
```json
|
|
{
|
|
"data": {
|
|
"name": "blog",
|
|
"title": "blog",
|
|
"child_type": "item",
|
|
"validation": "loose",
|
|
"fields": [
|
|
{
|
|
"name": "tabs",
|
|
"type": "tabs",
|
|
"fields": [
|
|
{
|
|
"name": "content",
|
|
"type": "tab",
|
|
"title": "Content",
|
|
"fields": [
|
|
{ "name": "header.title", "type": "text", "label": "Title" },
|
|
{ "name": "content", "type": "markdown" },
|
|
{ "name": "header.media_order", "type": "pagemedia", "label": "Page Media" }
|
|
]
|
|
},
|
|
{
|
|
"name": "blog",
|
|
"type": "tab",
|
|
"title": "Blog Config",
|
|
"fields": [
|
|
{ "name": "header.content.limit", "type": "text", "label": "Max Item Count", "validate": { "required": true, "type": "int" } },
|
|
{ "name": "header.content.order.by", "type": "select", "label": "Order By", "options": { "folder": "Folder", "title": "Title", "date": "Date" } },
|
|
{ "name": "header.content.pagination", "type": "toggle", "label": "Pagination" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
The resolved blueprint includes all inherited fields from parent blueprints (e.g., a theme's `blog.yaml` extending the system `default.yaml`), with `extends@` and `import@` directives fully resolved.
|
|
|
|
**Supported field types** in the serialized output include: `text`, `textarea`, `select`, `toggle`, `checkbox`, `radio`, `markdown`, `editor`, `filepicker`, `pagemedia`, `taxonomy`, `list`, `array`, `tabs`, `tab`, `section`, `fieldset`, `columns`, `column`, `spacer`, `display`, `hidden`, and more. Unknown field types are passed through with their properties intact for client-side fallback rendering.
|
|
|
|
**Get a plugin blueprint:**
|
|
|
|
```bash
|
|
curl -s "https://yoursite.com/api/v1/blueprints/plugins/email" \
|
|
-H "X-API-Key: YOUR_KEY"
|
|
```
|
|
|
|
**Blueprint file uploads.** `POST /blueprint-upload` mirrors admin-classic's `taskFilesUpload` for theme/plugin config forms. It accepts a blueprint `destination` (a Grav stream like `theme://images/logo`, `user://assets`, `account://avatars`; the `self@:subpath` form relative to a blueprint owner; or a plain user-rooted relative path) plus a `scope` (`plugins/<slug>`, `themes/<slug>`, `pages/<route>`, `users/<username>`) and writes the file to the right place.
|
|
|
|
Streams resolve through Grav's locator so symlinked theme/plugin folders (common in dev setups) work cleanly — the response returns a *logical* user-rooted path (`user/themes/quark2/images/logo/foo.png`) independent of realpath, so a subsequent `DELETE /blueprint-upload` round-trips through the symlink to remove the actual file. `..` traversal and absolute paths are rejected, filenames are sanitized, and the dangerous-extension allowlist is checked. `DELETE` is idempotent — a missing file returns `204 No Content`.
|
|
|
|
## Response Format
|
|
|
|
### Success
|
|
|
|
```json
|
|
{
|
|
"data": { ... },
|
|
"meta": {
|
|
"pagination": {
|
|
"page": 1,
|
|
"per_page": 20,
|
|
"total": 47,
|
|
"total_pages": 3
|
|
}
|
|
},
|
|
"links": {
|
|
"self": "/api/v1/pages?page=1&per_page=20",
|
|
"next": "/api/v1/pages?page=2&per_page=20",
|
|
"last": "/api/v1/pages?page=3&per_page=20"
|
|
}
|
|
}
|
|
```
|
|
|
|
Non-paginated responses omit `meta` and `links`.
|
|
|
|
Page objects include a `has_children` boolean field indicating whether the page has child pages, enabling tree/column UIs to show expand indicators without loading children upfront.
|
|
|
|
### Errors (RFC 7807)
|
|
|
|
```json
|
|
{
|
|
"status": 404,
|
|
"title": "Not Found",
|
|
"detail": "Page not found at route: /blog/missing-post"
|
|
}
|
|
```
|
|
|
|
Validation errors include field-level details:
|
|
|
|
```json
|
|
{
|
|
"status": 422,
|
|
"title": "Unprocessable Entity",
|
|
"detail": "Missing required fields: title, route",
|
|
"errors": [
|
|
{"field": "title", "message": "The 'title' field is required."},
|
|
{"field": "route", "message": "The 'route' field is required."}
|
|
]
|
|
}
|
|
```
|
|
|
|
## Concurrency Control
|
|
|
|
Write endpoints support optimistic concurrency via ETags:
|
|
|
|
1. `GET` responses include an `ETag` header
|
|
2. Send `If-Match: "<etag>"` with your `PATCH`/`DELETE` request
|
|
3. If the resource changed since your last read, you get a `409 Conflict`
|
|
|
|
This prevents accidental overwrites when multiple clients edit the same content.
|
|
|
|
## Rate Limiting
|
|
|
|
Enabled by default: 120 requests per 60-second window, per authenticated user (or per IP for unauthenticated requests).
|
|
|
|
Response headers on every request:
|
|
```
|
|
X-RateLimit-Limit: 120
|
|
X-RateLimit-Remaining: 117
|
|
X-RateLimit-Reset: 1711382460
|
|
```
|
|
|
|
Exceeding the limit returns `429 Too Many Requests`.
|
|
|
|
Configure in `api.yaml`:
|
|
```yaml
|
|
rate_limit:
|
|
enabled: true
|
|
requests: 120
|
|
window: 60
|
|
excluded_paths:
|
|
- /sync/ # default — exempt collab endpoints from the per-user bucket
|
|
```
|
|
|
|
`excluded_paths` exempts matching path prefixes from the bucket entirely — useful for high-frequency authenticated traffic (e.g. the sync plugin's polling, which fires ~90 req/min per active editor and would otherwise trip the global anti-abuse limit). Auth and per-route permissions still apply, so the bypass is gated by normal authentication rather than being a free pass.
|
|
|
|
## CORS
|
|
|
|
Enabled by default for all origins. Configure allowed origins, methods, and headers in the admin panel or `api.yaml`:
|
|
|
|
```yaml
|
|
cors:
|
|
enabled: true
|
|
origins:
|
|
- https://myapp.example.com
|
|
- https://admin.example.com
|
|
methods: [GET, POST, PATCH, DELETE, OPTIONS]
|
|
headers: [Content-Type, X-API-Token, X-API-Key, Authorization, If-Match]
|
|
credentials: false
|
|
```
|
|
|
|
## HTTP Method Override
|
|
|
|
Some shared-hosting nginx configs reject `DELETE`, `PATCH`, and `PUT` at the edge with a `405 Method Not Allowed` before the request ever reaches PHP. To work around this, the API accepts an `X-HTTP-Method-Override` header on `POST` requests that rewrites the verb before dispatch:
|
|
|
|
```bash
|
|
# Equivalent to DELETE /pages/blog/old-post
|
|
curl -X POST https://yoursite.com/api/v1/pages/blog/old-post \
|
|
-H "X-API-Key: ..." \
|
|
-H "X-HTTP-Method-Override: DELETE"
|
|
```
|
|
|
|
Only `DELETE`, `PATCH`, and `PUT` are honored (never `GET`), and the override is opt-in per request — clients that don't need it pay zero cost. Admin-next auto-detects the need on a failed mutation and caches the fallback decision in `sessionStorage`, so subsequent requests in the same session skip straight to the compatible path.
|
|
|
|
## Permissions
|
|
|
|
The API uses Grav's built-in ACL system. Available permissions:
|
|
|
|
| Permission | Description |
|
|
|------------|-------------|
|
|
| `api.access` | Basic API access (required for all authenticated requests) |
|
|
| `api.pages.read` | Read pages and taxonomy |
|
|
| `api.pages.write` | Create, update, delete, move, copy, reorder, batch pages |
|
|
| `api.media.read` | Read/list media files |
|
|
| `api.media.write` | Upload and delete media files |
|
|
| `api.config.read` | Read configuration |
|
|
| `api.config.write` | Update configuration |
|
|
| `api.users.read` | Read user accounts |
|
|
| `api.users.write` | Create, update, delete users |
|
|
| `api.system.read` | Read system info, logs, dashboard, notifications, feed |
|
|
| `api.system.write` | Clear cache, dismiss notifications |
|
|
| `api.system.backup` | Create, list, download, and delete site backups (the archive includes account hashes and config secrets) |
|
|
| `api.gpm.read` | List packages, check updates, browse/search repository |
|
|
| `api.gpm.write` | Install, remove, update packages |
|
|
| `api.scheduler.read` | View scheduler jobs, status, history |
|
|
| `api.scheduler.write` | Trigger scheduler runs |
|
|
| `api.webhooks.read` | View webhooks and delivery logs |
|
|
| `api.webhooks.write` | Create, update, delete, test webhooks |
|
|
|
|
Users with `admin.super` bypass all permission checks.
|
|
|
|
## CLI Commands
|
|
|
|
Manage API keys from the command line:
|
|
|
|
```bash
|
|
# Generate a new key (interactive prompts if flags omitted)
|
|
bin/plugin api keys:generate --user=admin --name="My Key"
|
|
|
|
# Generate with expiry (30 days)
|
|
bin/plugin api keys:generate --user=admin --name="Temp Key" --expiry=30
|
|
|
|
# List all keys for a user
|
|
bin/plugin api keys:list --user=admin
|
|
|
|
# Revoke a key (interactive selection if key-id omitted)
|
|
bin/plugin api keys:revoke --user=admin [key-id]
|
|
```
|
|
|
|
## Extending the API
|
|
|
|
Other Grav plugins can register their own API routes by listening to the `onApiRegisterRoutes` event:
|
|
|
|
```php
|
|
// In your plugin class
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
|
|
];
|
|
}
|
|
|
|
public function onApiRegisterRoutes(Event $event): void
|
|
{
|
|
$routes = $event['routes'];
|
|
|
|
$routes->get('/comments/{pageRoute:.+}', [CommentsApiController::class, 'index']);
|
|
$routes->post('/comments/{pageRoute:.+}', [CommentsApiController::class, 'create']);
|
|
|
|
// Group related routes
|
|
$routes->group('/webhooks', function ($group) {
|
|
$group->get('', [WebhookController::class, 'index']);
|
|
$group->post('', [WebhookController::class, 'create']);
|
|
$group->delete('/{id}', [WebhookController::class, 'delete']);
|
|
});
|
|
}
|
|
```
|
|
|
|
Your controller should extend `AbstractApiController` to get access to all the standard helpers (auth, pagination, response building, etc).
|
|
|
|
### Admin-Next Integration
|
|
|
|
Plugins can integrate with the admin-next UI by registering sidebar navigation items and providing page definitions. The API plugin provides dedicated events and endpoints that the admin-next frontend consumes to dynamically build plugin pages.
|
|
|
|
**Endpoints:**
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `GET` | `/sidebar/items` | Collect sidebar navigation items from all plugins |
|
|
| `GET` | `/gpm/plugins/{slug}/page` | Get a plugin's page definition for admin-next |
|
|
| `GET` | `/gpm/plugins/{slug}/page-script` | Serve a plugin's page web component JS |
|
|
| `GET` | `/gpm/plugins/{slug}/report-script/{reportId}` | Serve a plugin's report web component JS |
|
|
| `GET` | `/blueprints/plugins/{plugin}/pages/{pageId}` | Get a custom page blueprint for a plugin |
|
|
|
|
**Register a sidebar item** via `onApiSidebarItems`:
|
|
|
|
```php
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
'onApiSidebarItems' => ['onApiSidebarItems', 0],
|
|
'onApiPluginPageInfo' => ['onApiPluginPageInfo', 0],
|
|
];
|
|
}
|
|
|
|
public function onApiSidebarItems(Event $event): void
|
|
{
|
|
$items = $event['items'];
|
|
|
|
$items[] = [
|
|
'id' => 'license-manager',
|
|
'plugin' => 'license-manager',
|
|
'label' => 'Licenses',
|
|
'icon' => 'fa-key',
|
|
'route' => '/plugin/license-manager',
|
|
'priority' => 10,
|
|
'badge' => null,
|
|
];
|
|
|
|
$event['items'] = $items;
|
|
}
|
|
```
|
|
|
|
The sidebar item structure:
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | string | Unique identifier for the sidebar item |
|
|
| `plugin` | string | Plugin slug that owns this item |
|
|
| `label` | string | Display text in the sidebar |
|
|
| `icon` | string | FontAwesome icon class (e.g., `fa-key`) |
|
|
| `route` | string | Frontend route the item navigates to |
|
|
| `priority` | int | Sort order (lower values appear first) |
|
|
| `badge` | string\|null | Optional badge text (e.g., count or status) |
|
|
|
|
**Provide a page definition** via `onApiPluginPageInfo`:
|
|
|
|
```php
|
|
public function onApiPluginPageInfo(Event $event): void
|
|
{
|
|
if ($event['plugin'] !== 'license-manager') {
|
|
return;
|
|
}
|
|
|
|
$event['definition'] = [
|
|
'id' => 'license-manager',
|
|
'plugin' => 'license-manager',
|
|
'title' => 'License Manager',
|
|
'icon' => 'fa-key',
|
|
'page_type' => 'blueprint', // or 'component'
|
|
'blueprint' => 'licenses',
|
|
'data_endpoint' => '/licenses/form-data',
|
|
'save_endpoint' => '/licenses',
|
|
'actions' => [
|
|
['id' => 'import', 'label' => 'Import', 'icon' => 'fa-upload', 'upload' => true, 'endpoint' => '/licenses/import'],
|
|
['id' => 'export', 'label' => 'Export', 'icon' => 'fa-download', 'download' => true, 'endpoint' => '/licenses/export'],
|
|
['id' => 'save', 'label' => 'Save', 'icon' => 'fa-check', 'primary' => true],
|
|
],
|
|
];
|
|
}
|
|
```
|
|
|
|
The page definition structure:
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | string | Unique page identifier |
|
|
| `plugin` | string | Plugin slug that owns this page |
|
|
| `title` | string | Page title displayed in the header |
|
|
| `icon` | string | FontAwesome icon class |
|
|
| `page_type` | string | Rendering mode: `blueprint` (form from YAML) or `component` (custom web component) |
|
|
| `blueprint` | string | Blueprint name to load (when `page_type` is `blueprint`) |
|
|
| `data_endpoint` | string | API path to fetch form data |
|
|
| `save_endpoint` | string | API path to save form data |
|
|
| `actions` | array | Toolbar action buttons (see below) |
|
|
|
|
Each action in the `actions` array:
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | string | Action identifier |
|
|
| `label` | string | Button text |
|
|
| `icon` | string | FontAwesome icon class |
|
|
| `primary` | bool | Whether this is the primary action (styled prominently) |
|
|
| `upload` | bool | Whether this action opens a file upload dialog |
|
|
| `download` | bool | Whether this action triggers a file download |
|
|
| `endpoint` | string | API path for the action (required for upload/download actions) |
|
|
|
|
## Events
|
|
|
|
The API fires events before and after all write operations, allowing plugins to react, validate, modify data, or cancel operations.
|
|
|
|
### Page Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiBeforePageCreate` | Before a page is saved | `route`, `header`, `content`, `template`, `lang` (modifiable by reference) |
|
|
| `onApiPageCreated` | After page creation | `page` (PageInterface), `route`, `lang` |
|
|
| `onApiBeforePageUpdate` | Before a page is updated | `page` (PageInterface), `data` (request body, modifiable by reference) |
|
|
| `onApiPageUpdated` | After page update | `page` (PageInterface) |
|
|
| `onApiBeforePageDelete` | Before a page is deleted | `page` (PageInterface), `lang` (if language-specific delete) |
|
|
| `onApiPageDeleted` | After page deletion | `route`, `lang` (if language-specific delete) |
|
|
| `onApiPageMoved` | After page move | `page` (PageInterface), `old_route`, `new_route` |
|
|
| `onApiBeforePageTranslate` | Before a translation is created | `page`, `lang`, `header`, `content` (modifiable by reference) |
|
|
| `onApiPageTranslated` | After translation created | `page`, `route`, `lang` |
|
|
| `onApiBeforePagesReorder` | Before children are reordered | `parent` (PageInterface), `order` (slug array) |
|
|
| `onApiPagesReordered` | After children reordered | `parent` (PageInterface), `order` |
|
|
|
|
### Media Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiBeforeMediaUpload` | Before each file is saved | `page`, `filename`, `type`, `size` |
|
|
| `onApiMediaUploaded` | After upload completes | `page`, `filenames` (array) |
|
|
| `onApiBeforeMediaDelete` | Before a media file is deleted | `page`, `filename` |
|
|
| `onApiMediaDeleted` | After media deletion | `page`, `filename` |
|
|
|
|
### Config Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiConfigUpdated` | After config is saved | `scope`, `data` |
|
|
|
|
### User Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiUserCreated` | After user creation | `user` (UserInterface) |
|
|
| `onApiUserUpdated` | After user update | `user` (UserInterface) |
|
|
| `onApiBeforeUserDelete` | Before user deletion | `user` (UserInterface) |
|
|
| `onApiUserDeleted` | After user deletion | `username` |
|
|
|
|
### GPM Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiBeforePackageInstall` | Before package install | `package`, `type` |
|
|
| `onApiPackageInstalled` | After package installed | `package`, `type` |
|
|
| `onApiBeforePackageRemove` | Before package removal | `package`, `type` |
|
|
| `onApiPackageRemoved` | After package removed | `package`, `type` |
|
|
| `onApiBeforeGravUpgrade` | Before Grav upgrade | `current_version`, `available_version` |
|
|
| `onApiGravUpgraded` | After Grav upgraded | `previous_version`, `new_version` |
|
|
|
|
### Route Registration
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiRegisterRoutes` | During router initialization | `routes` (ApiRouteCollector) |
|
|
|
|
### Admin-Next Integration Events
|
|
|
|
| Event | When | Event Data |
|
|
|-------|------|------------|
|
|
| `onApiSidebarItems` | Sidebar items are collected via `GET /sidebar/items` | `items` (array, modifiable), `user` (UserInterface) |
|
|
| `onApiPluginPageInfo` | Plugin page definition requested via `GET /gpm/plugins/{slug}/page` | `plugin` (string), `definition` (array\|null, modifiable), `user` (UserInterface) |
|
|
| `onApiDashboardWidgets` | Widget registry is collected via `GET /dashboard/widgets` | `widgets` (array, modifiable), `user` (UserInterface) |
|
|
|
|
### Using Events in Your Plugin
|
|
|
|
**React to content changes** (e.g., clear a search index when pages change):
|
|
|
|
```php
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
'onApiPageCreated' => ['onApiPageCreated', 0],
|
|
'onApiPageUpdated' => ['onApiPageUpdated', 0],
|
|
'onApiPageDeleted' => ['onApiPageDeleted', 0],
|
|
];
|
|
}
|
|
|
|
public function onApiPageCreated(Event $event): void
|
|
{
|
|
$page = $event['page'];
|
|
$this->searchIndex->add($page);
|
|
}
|
|
|
|
public function onApiPageUpdated(Event $event): void
|
|
{
|
|
$page = $event['page'];
|
|
$this->searchIndex->update($page);
|
|
}
|
|
|
|
public function onApiPageDeleted(Event $event): void
|
|
{
|
|
$route = $event['route'];
|
|
$this->searchIndex->remove($route);
|
|
}
|
|
```
|
|
|
|
**Validate or modify data before save** (e.g., enforce content rules):
|
|
|
|
```php
|
|
public function onApiBeforePageCreate(Event $event): void
|
|
{
|
|
$header = &$event['header'];
|
|
$content = &$event['content'];
|
|
|
|
// Auto-add a timestamp
|
|
$header['api_created'] = date('c');
|
|
|
|
// Reject empty content
|
|
if (empty(trim($content))) {
|
|
throw new \RuntimeException('Page content cannot be empty.');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Reject file uploads** (e.g., enforce image-only policy):
|
|
|
|
```php
|
|
public function onApiBeforeMediaUpload(Event $event): void
|
|
{
|
|
$type = $event['type'];
|
|
if (!str_starts_with($type, 'image/')) {
|
|
throw new \RuntimeException('Only image uploads are allowed.');
|
|
}
|
|
}
|
|
```
|
|
|
|
### Admin-Compatible Events
|
|
|
|
In addition to the `onApi*` events above, the API plugin fires the same `onAdmin*` events that Grav's admin plugin fires. This ensures third-party plugins that subscribe to admin events (SEO Magic, Auto Date, Mega Frontmatter, etc.) work correctly regardless of whether changes come from the admin UI or the API.
|
|
|
|
Both event families fire for every operation — `onAdmin*` events first, then `onApi*` events.
|
|
|
|
#### Events Fired
|
|
|
|
| Event | Controller | Methods | Event Data (matches admin plugin signatures) |
|
|
|-------|-----------|---------|----------------------------------------------|
|
|
| `onAdminCreatePageFrontmatter` | Pages | `create` | `header` (array, modifiable), `data` (request body) |
|
|
| `onAdminSave` | Pages | `create`, `update`, `translate` | `object` (Page, by reference), `page` (Page, by reference) |
|
|
| `onAdminAfterSave` | Pages | `create`, `update`, `translate` | `object` (Page), `page` (Page) |
|
|
| `onAdminAfterDelete` | Pages | `delete` | `object` (Page), `page` (Page) |
|
|
| `onAdminAfterSaveAs` | Pages | `move` | `path` (new filesystem path) |
|
|
| `onAdminAfterAddMedia` | Media | `uploadPageMedia` | `object` (Page), `page` (Page) |
|
|
| `onAdminAfterDelMedia` | Media | `deletePageMedia` | `object` (Page), `page` (Page), `media` (Media), `filename` (string) |
|
|
| `onAdminSave` | Users | `create`, `update` | `object` (User, by reference) |
|
|
| `onAdminAfterSave` | Users | `create`, `update` | `object` (User) |
|
|
| `onAdminSave` | Config | `update` | `object` (Data, by reference) |
|
|
| `onAdminAfterSave` | Config | `update` | `object` (Data) |
|
|
|
|
#### Event Ordering
|
|
|
|
For a page create operation, events fire in this order:
|
|
|
|
1. `onApiBeforePageCreate` — API before event
|
|
2. `onAdminCreatePageFrontmatter` — admin frontmatter injection
|
|
3. `onAdminSave` — admin pre-save (plugins can modify the page)
|
|
4. `onAdminAfterSave` — admin post-save (indexing, notifications)
|
|
5. `onApiPageCreated` — API after event (triggers webhooks)
|
|
|
|
#### Example: Existing Plugin Compatibility
|
|
|
|
Plugins that already listen for admin events will automatically work with the API — no code changes needed:
|
|
|
|
```php
|
|
// This SEO Magic listener fires for both admin UI saves and API saves
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
'onAdminAfterSave' => ['onObjectSave', 0],
|
|
'onAdminAfterDelete' => ['onObjectDelete', 0],
|
|
];
|
|
}
|
|
```
|
|
|
|
## Configuration Reference
|
|
|
|
Full configuration in `user/config/plugins/api.yaml`:
|
|
|
|
```yaml
|
|
enabled: true
|
|
route: /api
|
|
version_prefix: v1
|
|
|
|
auth:
|
|
api_keys_enabled: true
|
|
jwt_enabled: true
|
|
jwt_secret: '' # Auto-generated on first use
|
|
jwt_algorithm: HS256
|
|
jwt_expiry: 3600 # Access token lifetime (seconds)
|
|
jwt_refresh_expiry: 604800 # Refresh token lifetime (seconds)
|
|
session_enabled: true
|
|
|
|
cors:
|
|
enabled: true
|
|
origins: ['*']
|
|
methods: [GET, POST, PATCH, DELETE, OPTIONS]
|
|
headers: [Content-Type, X-API-Token, X-API-Key, Authorization, If-Match, If-None-Match]
|
|
expose_headers: [ETag, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset]
|
|
max_age: 86400
|
|
credentials: false
|
|
|
|
rate_limit:
|
|
enabled: true
|
|
requests: 120
|
|
window: 60
|
|
|
|
pagination:
|
|
default_per_page: 20
|
|
max_per_page: 100
|
|
```
|
|
|
|
## OpenAPI Specification
|
|
|
|
A complete OpenAPI 3.0 specification is included at [`openapi.yaml`](openapi.yaml). Import it into:
|
|
|
|
- **Grav Docs** via the [API Doc Import](https://github.com/getgrav/grav-plugin-api-doc-import) plugin
|
|
- **Postman** for interactive testing
|
|
- **Swagger UI** for browsable documentation
|
|
- **Any OpenAPI-compatible tool** for client SDK generation
|
|
|
|
## Development
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
composer install
|
|
composer test
|
|
```
|
|
|
|
Run the tests from inside a Grav install (the plugin's normal home at
|
|
`user/plugins/api`). The bootstrap auto-detects the hosting Grav and loads its
|
|
autoloader, which provides `symfony/yaml` — the plugin relies on Grav's copy
|
|
rather than bundling its own (see the `replace` entry in `composer.json`).
|
|
|
|
Most unit tests fall back to lightweight stubs and run without a Grav
|
|
installation, but the YAML-dependent tests (config diffing, page header
|
|
merging, API key storage) need Grav reachable. If you're working from a
|
|
standalone source clone that the bootstrap can't locate automatically, point it
|
|
at your Grav root:
|
|
|
|
```bash
|
|
GRAV_ROOT=/path/to/grav composer test
|
|
```
|
|
|
|
For integration tests within a Grav instance:
|
|
|
|
```bash
|
|
vendor/bin/phpunit --group integration
|
|
```
|
|
|
|
### Project Structure
|
|
|
|
```
|
|
grav-plugin-api/
|
|
├── api.php # Plugin entry point
|
|
├── api.yaml # Default configuration
|
|
├── blueprints.yaml # Admin UI configuration
|
|
├── permissions.yaml # ACL permission definitions
|
|
├── openapi.yaml # OpenAPI 3.0 specification
|
|
├── languages/en.yaml # Translation strings
|
|
├── composer.json
|
|
├── classes/Api/
|
|
│ ├── ApiRouter.php # FastRoute dispatcher + middleware chain
|
|
│ ├── ApiRouteCollector.php # Plugin route registration helper
|
|
│ ├── Auth/
|
|
│ │ ├── AuthenticatorInterface.php
|
|
│ │ ├── ApiKeyAuthenticator.php
|
|
│ │ ├── JwtAuthenticator.php
|
|
│ │ ├── SessionAuthenticator.php
|
|
│ │ └── ApiKeyManager.php
|
|
│ ├── Controllers/
|
|
│ │ ├── AbstractApiController.php
|
|
│ │ ├── AuthController.php
|
|
│ │ ├── ConfigController.php
|
|
│ │ ├── DashboardController.php
|
|
│ │ ├── DashboardWidgetController.php
|
|
│ │ ├── GpmController.php
|
|
│ │ ├── MediaController.php
|
|
│ │ ├── PagesController.php
|
|
│ │ ├── SchedulerController.php
|
|
│ │ ├── SystemController.php
|
|
│ │ ├── UsersController.php
|
|
│ │ └── WebhookController.php
|
|
│ ├── Exceptions/
|
|
│ │ ├── ApiException.php
|
|
│ │ ├── ConflictException.php
|
|
│ │ ├── ForbiddenException.php
|
|
│ │ ├── NotFoundException.php
|
|
│ │ ├── UnauthorizedException.php
|
|
│ │ └── ValidationException.php
|
|
│ ├── Middleware/
|
|
│ │ ├── AuthMiddleware.php
|
|
│ │ ├── CorsMiddleware.php
|
|
│ │ ├── JsonBodyParserMiddleware.php
|
|
│ │ └── RateLimitMiddleware.php
|
|
│ ├── Response/
|
|
│ │ ├── ApiResponse.php
|
|
│ │ └── ErrorResponse.php
|
|
│ ├── Serializers/
|
|
│ │ ├── SerializerInterface.php
|
|
│ │ ├── PageSerializer.php
|
|
│ │ ├── MediaSerializer.php
|
|
│ │ ├── PackageSerializer.php
|
|
│ │ └── UserSerializer.php
|
|
│ └── Webhooks/
|
|
│ ├── WebhookManager.php
|
|
│ └── WebhookDispatcher.php
|
|
└── tests/
|
|
├── bootstrap.php
|
|
├── Stubs/
|
|
└── Unit/
|
|
```
|
|
|
|
## License
|
|
|
|
MIT License. See [LICENSE](LICENSE) for details.
|
|
|
|
## Credits
|
|
|
|
Built by [Team Grav](https://getgrav.org) for the Grav CMS community.
|