diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b490a1..9a2dda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Waygate will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-05-25 + +### Added +- REST API endpoints under `waygate/v1`: `GET /patterns` (requires `edit_posts`) lists all registered patterns with optional `?category=` filter; `POST /pages` (requires `publish_pages`) creates a draft page from an ordered pattern slug list and returns `page_id`, `edit_url`, and `view_url` +- Per-user rate limiting on `POST /wp-json/waygate/v1/pages`: maximum 10 requests per 60-second window per user; returns `429` when exceeded; limit is filterable via `waygate_rate_limit` +- `POST /pages` always creates pages as `draft`; the `status` parameter has been removed to prevent accidental publishing via the API +- Failed page creation via the REST API returns a `422` status code with the underlying error message +- PHPUnit tests for both REST endpoints and the rate limiter (19 new tests, 91 total assertions) +- REST bootstrap stubs (`WP_REST_Request`, `WP_REST_Response`, `WP_REST_Server`, `rest_ensure_response`, `register_rest_route`, transient helpers) added to the unit test bootstrap + ## [0.6.2] - 2026-05-24 ### Documentation diff --git a/README.md b/README.md index e3258bc..0e33f49 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or via a natural-language AI prompt powered by the WordPress AI Client (WordPress 7.0+). Works with any block theme; [Elayne](https://github.com/imagewize/elayne) is the primary supported theme. -> **Beta** — v0.6.0. Use on staging/development sites; not yet recommended for production. +> **Beta** — v0.7.0. Use on staging/development sites; not yet recommended for production. --- @@ -17,6 +17,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or vi - **Prompt templates** — Six built-in page templates (Homepage, About, Services, Contact, Landing Page, Portfolio) pre-fill the AI prompt; extend via the `waygate_prompt_templates` filter - **Feature detection** — AI form is hidden automatically when no provider supports text generation - **Abilities API** — Exposes `elayne/list-patterns` and `elayne/create-page` abilities for WP 7.0+ +- **REST API** — `GET /wp-json/waygate/v1/patterns` and `POST /wp-json/waygate/v1/pages` for headless and external tool integration - **Multi-provider** — Works with Mistral, Claude, OpenAI, or Gemini via WP AI Client - **Any block theme** — Default prefix is `elayne/`; extend via the `waygate_pattern_prefixes` filter @@ -103,6 +104,37 @@ When WordPress 7.0's Abilities API is available, Waygate registers two abilities --- +## REST API + +Waygate exposes two REST endpoints under `/wp-json/waygate/v1/`: + +| Method | Endpoint | Permission | Description | +|---|---|---|---| +| `GET` | `/patterns` | `edit_posts` | List all registered patterns; optional `?category=hero` filter | +| `POST` | `/pages` | `publish_pages` | Create a **draft** page from pattern slugs (max 10 req/min per user) | + +**Example — list patterns filtered by category:** + +```bash +curl -u admin:password https://example.com/wp-json/waygate/v1/patterns?category=hero +``` + +**Example — create a page:** + +```bash +curl -u admin:password -X POST https://example.com/wp-json/waygate/v1/pages \ + -H "Content-Type: application/json" \ + -d '{"title":"My Page","patterns":["elayne/hero","elayne/features","elayne/cta"],"status":"draft"}' +``` + +Response: + +```json +{ "page_id": 42, "edit_url": "https://example.com/wp-admin/post.php?post=42&action=edit", "view_url": "https://example.com/?page_id=42" } +``` + +--- + ## Development ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e55304..7626fe8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # Waygate Roadmap & Improvement Ideas > **Document Version**: 1.1.0 -> **Last Updated**: 2026-05-24 +> **Last Updated**: 2026-05-25 > **Status**: Draft This document outlines potential improvements and feature additions for Waygate, based on WordPress 7.0 AI Client and Abilities API capabilities. @@ -143,6 +143,14 @@ Add a dropdown to filter patterns by category in the catalog table. ### Phase 2: Medium Effort ← **Next Up** +#### 8. Add Prompt Templates ✅ +**File**: `includes/class-ai-integration.php` +**File**: `includes/class-admin.php` + +Built-in prompt templates with `waygate_prompt_templates` filter, and a "Quick template" dropdown in the admin UI that populates the description field (with confirmation if the field already has content). + +**Status**: Complete as of 2026-05-25. + #### 5. Add Image Generation for Pattern Previews **New file**: `includes/class-image-generator.php` @@ -177,8 +185,8 @@ class ImageGenerator { **Benefit**: Visual pattern selection, better UX. -#### 6. Add REST API Endpoints for Remote Access -**New file**: `includes/class-rest-api.php` +#### 6. Add REST API Endpoints for Remote Access ✅ +**File**: `includes/class-rest-api.php` ```php class RestApi { @@ -447,9 +455,9 @@ public static function track_pattern_usage( string $pattern_slug ): void { ### Recommended Order 1. ~~Feature detection, ability annotations, generic prefixes — *Phase 1*~~ ✅ Done -2. Prompt templates — *Phase 2* (1–2 days) ← **Start here** -3. REST API endpoints — *Phase 2* (2–3 days) -4. Client-side abilities for editor integration — *Phase 2* (2–3 days) +2. ~~Prompt templates — *Phase 2* (1–2 days)~~ ✅ Done +3. ~~REST API endpoints — *Phase 2* (2–3 days)~~ ✅ Done +4. Client-side abilities for editor integration — *Phase 2* (2–3 days) ← **Start here** 5. Image generation for previews — *Phase 2* (2–3 days) 6. Batch page creation — *Phase 2* (1–2 days) 7. Cost tracking, pattern popularity — *Phase 3* (2–3 days) @@ -577,3 +585,4 @@ const abilities = getAbilities(); |------|--------|--------| | 2026-05-22 | Initial draft | Created roadmap document | | 2026-05-24 | Jasper Frumau | Marked Phase 1 complete; Phase 2 is next | +| 2026-05-25 | Jasper Frumau | Marked Prompt Templates (#8) complete; REST API is next | diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php new file mode 100644 index 0000000..a788d94 --- /dev/null +++ b/includes/class-rest-api.php @@ -0,0 +1,152 @@ + \WP_REST_Server::READABLE, + 'callback' => array( self::class, 'get_patterns' ), + 'permission_callback' => fn() => current_user_can( 'edit_posts' ), + 'args' => array( + 'category' => array( + 'type' => 'string', + 'description' => 'Filter by category slug, e.g. "hero", "features", "cta".', + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/pages', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( self::class, 'create_page' ), + 'permission_callback' => function () { + if ( ! current_user_can( 'publish_pages' ) ) { + return false; + } + if ( self::is_rate_limited() ) { + return new \WP_Error( + 'rate_limited', + 'Too many requests. Try again in a minute.', + array( 'status' => 429 ) + ); + } + return true; + }, + 'args' => array( + 'title' => array( + 'type' => 'string', + 'description' => 'Page title.', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'patterns' => array( + 'type' => 'array', + 'description' => 'Ordered list of pattern slugs.', + 'required' => true, + 'items' => array( 'type' => 'string' ), + ), + ), + ) + ); + } + + /** + * GET /waygate/v1/patterns + * + * @param \WP_REST_Request $request Incoming REST request. + * @return \WP_REST_Response + */ + public static function get_patterns( \WP_REST_Request $request ): \WP_REST_Response { + $patterns = Pattern_Lab::get_patterns(); + + $category = $request->get_param( 'category' ); + if ( ! empty( $category ) ) { + $cat_slug = 'elayne/' . $category; + $patterns = array_values( + array_filter( $patterns, fn( $p ) => in_array( $cat_slug, $p['categories'], true ) ) + ); + } + + return rest_ensure_response( $patterns ); + } + + /** + * Returns true if the current user has exceeded the page-creation rate limit. + * Uses a per-user transient counter with a 60-second sliding window. + */ + public static function is_rate_limited(): bool { + $key = 'waygate_rl_' . get_current_user_id(); + $count = (int) get_transient( $key ); + $limit = apply_filters( 'waygate_rate_limit', self::RATE_LIMIT ); + + if ( $count >= $limit ) { + return true; + } + + set_transient( $key, $count + 1, 60 ); + return false; + } + + /** + * POST /waygate/v1/pages + * + * @param \WP_REST_Request $request Incoming REST request. + * @return \WP_REST_Response|\WP_Error + */ + public static function create_page( \WP_REST_Request $request ) { + $result = Pattern_Lab::create_page( + $request->get_param( 'title' ), + (array) $request->get_param( 'patterns' ), + 'draft' + ); + + if ( is_wp_error( $result ) ) { + return new \WP_Error( + $result->get_error_code(), + $result->get_error_message(), + array( 'status' => 422 ) + ); + } + + return rest_ensure_response( + array( + 'page_id' => $result, + 'edit_url' => get_edit_post_link( $result, 'raw' ), + 'view_url' => get_permalink( $result ), + ) + ); + } +} diff --git a/tests/Unit/RestApiTest.php b/tests/Unit/RestApiTest.php new file mode 100644 index 0000000..84dba00 --- /dev/null +++ b/tests/Unit/RestApiTest.php @@ -0,0 +1,196 @@ +reset(); + $GLOBALS['wp_filters'] = []; + $GLOBALS['wp_rest_routes'] = []; + $GLOBALS['wp_transients'] = []; + } + + private function register( string $slug, string $title = 'Test Pattern', array $categories = [] ): void { + WP_Block_Patterns_Registry::get_instance()->register( + [ + 'slug' => $slug, + 'title' => $title, + 'description' => '', + 'categories' => $categories, + 'keywords' => [], + ] + ); + } + + private function request( array $params ): WP_REST_Request { + return new WP_REST_Request( $params ); + } + + // --- register_routes() --- + + public function test_register_routes_registers_patterns_endpoint(): void { + Rest_API::register_routes(); + + $this->assertArrayHasKey( 'waygate/v1/patterns', $GLOBALS['wp_rest_routes'] ); + } + + public function test_register_routes_registers_pages_endpoint(): void { + Rest_API::register_routes(); + + $this->assertArrayHasKey( 'waygate/v1/pages', $GLOBALS['wp_rest_routes'] ); + } + + public function test_patterns_endpoint_uses_get_method(): void { + Rest_API::register_routes(); + + $this->assertSame( 'GET', $GLOBALS['wp_rest_routes']['waygate/v1/patterns']['methods'] ); + } + + public function test_pages_endpoint_uses_post_method(): void { + Rest_API::register_routes(); + + $this->assertSame( 'POST', $GLOBALS['wp_rest_routes']['waygate/v1/pages']['methods'] ); + } + + // --- get_patterns() --- + + public function test_get_patterns_returns_all_registered_patterns(): void { + $this->register( 'elayne/hero' ); + $this->register( 'elayne/cta' ); + + $data = Rest_API::get_patterns( $this->request( [] ) )->get_data(); + + $this->assertCount( 2, $data ); + } + + public function test_get_patterns_returns_empty_array_when_no_patterns(): void { + $data = Rest_API::get_patterns( $this->request( [] ) )->get_data(); + + $this->assertSame( [], $data ); + } + + public function test_get_patterns_filters_by_category(): void { + $this->register( 'elayne/hero', 'Hero', [ 'elayne/header' ] ); + $this->register( 'elayne/cta', 'CTA', [ 'elayne/footer' ] ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => 'header' ] ) )->get_data(); + + $this->assertCount( 1, $data ); + $this->assertSame( 'elayne/hero', $data[0]['slug'] ); + } + + public function test_get_patterns_returns_empty_for_unknown_category(): void { + $this->register( 'elayne/hero', 'Hero', [ 'elayne/header' ] ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => 'nonexistent' ] ) )->get_data(); + + $this->assertSame( [], $data ); + } + + public function test_get_patterns_without_category_param_returns_all(): void { + $this->register( 'elayne/hero' ); + $this->register( 'elayne/features' ); + + $data = Rest_API::get_patterns( $this->request( [ 'category' => null ] ) )->get_data(); + + $this->assertCount( 2, $data ); + } + + // --- create_page() --- + + public function test_create_page_returns_page_id_on_success(): void { + $this->register( 'elayne/hero' ); + + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); + + $this->assertArrayHasKey( 'page_id', $data ); + $this->assertIsInt( $data['page_id'] ); + } + + public function test_create_page_returns_edit_url_on_success(): void { + $this->register( 'elayne/hero' ); + + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); + + $this->assertArrayHasKey( 'edit_url', $data ); + $this->assertStringContainsString( 'action=edit', $data['edit_url'] ); + } + + public function test_create_page_returns_view_url_on_success(): void { + $this->register( 'elayne/hero' ); + + $data = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'elayne/hero' ] ] ) + )->get_data(); + + $this->assertArrayHasKey( 'view_url', $data ); + $this->assertNotEmpty( $data['view_url'] ); + } + + public function test_create_page_returns_wp_error_for_no_valid_patterns(): void { + $result = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'nonexistent/slug' ] ] ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_valid_patterns', $result->get_error_code() ); + } + + public function test_create_page_error_carries_422_http_status(): void { + $result = Rest_API::create_page( + $this->request( [ 'title' => 'My Page', 'patterns' => [ 'nonexistent/slug' ] ] ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 422, $result->get_error_data()['status'] ); + } + + // --- is_rate_limited() --- + + public function test_is_rate_limited_returns_false_under_limit(): void { + $this->assertFalse( Rest_API::is_rate_limited() ); + } + + public function test_is_rate_limited_increments_transient_counter(): void { + Rest_API::is_rate_limited(); + Rest_API::is_rate_limited(); + + $this->assertSame( 2, $GLOBALS['wp_transients']['waygate_rl_1'] ); + } + + public function test_is_rate_limited_returns_true_at_limit(): void { + $GLOBALS['wp_transients']['waygate_rl_1'] = 10; + + $this->assertTrue( Rest_API::is_rate_limited() ); + } + + public function test_is_rate_limited_returns_true_above_limit(): void { + $GLOBALS['wp_transients']['waygate_rl_1'] = 99; + + $this->assertTrue( Rest_API::is_rate_limited() ); + } + + public function test_rate_limit_is_filterable(): void { + add_filter( 'waygate_rate_limit', fn() => 2 ); + + Rest_API::is_rate_limited(); // count → 1 + Rest_API::is_rate_limited(); // count → 2, now at limit + $result = Rest_API::is_rate_limited(); // should be blocked + + remove_all_filters( 'waygate_rate_limit' ); + + $this->assertTrue( $result ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7dc32c1..08f4516 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -46,16 +46,86 @@ function current_user_can( string $capability ): bool { return true; } +function get_current_user_id(): int { + return 1; +} + +$GLOBALS['wp_transients'] = []; + +function get_transient( string $key ): mixed { + return $GLOBALS['wp_transients'][ $key ] ?? false; +} + +function set_transient( string $key, mixed $value, int $expiration = 0 ): bool { + $GLOBALS['wp_transients'][ $key ] = $value; + return true; +} + +function sanitize_key( string $key ): string { + return strtolower( preg_replace( '/[^a-z0-9_-]/', '', $key ) ); +} + +function get_edit_post_link( int $id, string $context = 'display' ): string { + return "https://example.com/wp-admin/post.php?post={$id}&action=edit"; +} + +function get_permalink( int $id ): string { + return "https://example.com/?page_id={$id}"; +} + +function rest_ensure_response( mixed $data ): WP_REST_Response { + if ( $data instanceof WP_REST_Response ) { + return $data; + } + return new WP_REST_Response( $data ); +} + +$GLOBALS['wp_rest_routes'] = []; + +function register_rest_route( string $namespace, string $route, array $args ): bool { + $GLOBALS['wp_rest_routes'][ $namespace . $route ] = $args; + return true; +} + // --- WordPress class stubs --- class WP_Error { public function __construct( private string $code = '', - private string $message = '' + private string $message = '', + private mixed $data = null ) {} public function get_error_code(): string { return $this->code; } public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } +} + +class WP_REST_Server { + const READABLE = 'GET'; + const CREATABLE = 'POST'; +} + +class WP_REST_Request { + private array $params; + + public function __construct( array $params = [] ) { + $this->params = $params; + } + + public function get_param( string $key ): mixed { + return $this->params[ $key ] ?? null; + } +} + +class WP_REST_Response { + public function __construct( + private mixed $data = null, + private int $status = 200 + ) {} + + public function get_data(): mixed { return $this->data; } + public function get_status(): int { return $this->status; } } class WP_Block_Patterns_Registry { diff --git a/waygate.php b/waygate.php index d6cf6b1..11c0f22 100644 --- a/waygate.php +++ b/waygate.php @@ -3,7 +3,7 @@ * Plugin Name: Waygate * Plugin URI: https://github.com/imagewize/waygate * Description: AI-powered pattern page builder for the Elayne block theme. Lists registered patterns, creates pages from pattern slugs, and integrates with WordPress AI Client for natural-language page generation. - * Version: 0.6.2 + * Version: 0.7.0 * Author: Jasper Frumau * Author URI: https://imagewize.com * License: GPL-2.0-or-later @@ -18,7 +18,7 @@ defined( 'ABSPATH' ) || exit; -define( 'WAYGATE_VERSION', '0.6.2' ); +define( 'WAYGATE_VERSION', '0.7.0' ); define( 'WAYGATE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'WAYGATE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -26,8 +26,10 @@ require_once WAYGATE_PLUGIN_DIR . 'includes/class-abilities-api.php'; require_once WAYGATE_PLUGIN_DIR . 'includes/class-ai-integration.php'; require_once WAYGATE_PLUGIN_DIR . 'includes/class-admin.php'; +require_once WAYGATE_PLUGIN_DIR . 'includes/class-rest-api.php'; add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Pattern_Lab', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\AI_Integration', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Abilities_API', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Admin', 'init' ) ); +add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Rest_API', 'init' ) );